Compare commits
315 Commits
590fa0b735
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7f7eb9d2f | |||
| b527e24705 | |||
| 1af039ab0a | |||
| 03496884d5 | |||
| c87b5f0304 | |||
| b1ceb7cccb | |||
| 3a86385038 | |||
| 70750890e3 | |||
| 8f5310754c | |||
| d79839b438 | |||
| f0a591f474 | |||
| 2874285cc7 | |||
| c9d6a4d9d4 | |||
| dbea35abe2 | |||
| 18c63953cc | |||
| 4df92752bb | |||
| ae47f66ef5 | |||
| e4ac7e248c | |||
| 18e4a92241 | |||
| 97b308e9f0 | |||
| 1d4116d1a3 | |||
| 6de899112b | |||
| 3910063ed3 | |||
| 7031979571 | |||
| a3a878854e | |||
| f942bc45b9 | |||
| 9c263e7e8d | |||
| 350bb25ecd | |||
| d0f10c4d7e | |||
| 1a85d3930e | |||
| c82d113adf | |||
| fe836ae69f | |||
| ec6246bd72 | |||
| e8e941c436 | |||
| 6a2f4438f9 | |||
| ff6743c9b1 | |||
| d03baec163 | |||
| b456143d20 | |||
| 2710a9c111 | |||
| 93689d4486 | |||
| cfb351a251 | |||
| 2dffeb1fde | |||
| 4b4ddf0825 | |||
| 43ff0f450d | |||
| f4dfa6eeca | |||
| 36e1be8f40 | |||
| 3bf01cba1f | |||
| 12c4a154e5 | |||
| 2037bb1caa | |||
| 1ed9d9d95e | |||
| a43d067e61 | |||
| a6ac21aed0 | |||
| 5c5f2b4050 | |||
| 252c0e09cb | |||
| f40bb123d2 | |||
| d07d9e3919 | |||
| 76edd7abd1 | |||
| fb9d4dc956 | |||
| eb3e45d88f | |||
| d7790bb7ef | |||
| a0d7125dd3 | |||
| 459ab27c5b | |||
| abd4574ee3 | |||
| f50f2aaba1 | |||
| 7476ca5cd1 | |||
| f1683e2ff5 | |||
| 407e9ee731 | |||
| 8a21ffddb5 | |||
| 2b2b184f04 | |||
| eea7cb91e7 | |||
| 05f7d48bf1 | |||
| fe55e6481a | |||
| 97f1dace4f | |||
| d6a37d5948 | |||
| 6e5c39607c | |||
| 13e5e3a627 | |||
| 92dbcf2780 | |||
| ba91fce44c | |||
| 2b2e98fa48 | |||
| 0b03f15e4a | |||
| 6af892c9a6 | |||
| 941d216f38 | |||
| 163d71d505 | |||
| 8c4650d1bc | |||
| f48ce589ca | |||
| 225dcd718b | |||
| 7276228a98 | |||
| e77176841c | |||
| 72094d7d15 | |||
| 65e7cc4007 | |||
| 9dd3392e95 | |||
| 780020fc46 | |||
| d0c15287c4 | |||
| 85177a643f | |||
| ce6e03d66b | |||
| b5672eb4a2 | |||
| 7c0c3e1132 | |||
| 117b5cbe4c | |||
| bb5a3b6813 | |||
| 44799f76cc | |||
| 2e3e4100f5 | |||
| dec24eb842 | |||
| bcd9f4a060 | |||
| 477b25dfa0 | |||
| 01f37e60be | |||
| eca93aae28 | |||
| ad67330d20 | |||
| a0183458eb | |||
| 10e3df25f9 | |||
| 048abcfbfd | |||
| f9024a6f3a | |||
| b3cc9727e4 | |||
| cf9b22feb5 | |||
| 3c7d3002f1 | |||
| de15d28d3a | |||
| 15bc1d110e | |||
| 6e179eb1ec | |||
| edc7885a6b | |||
| 14c80f436a | |||
| 2d164958d8 | |||
| 64522764cb | |||
| 2495a2c358 | |||
| bfb67261b2 | |||
| 3d7e75a1e6 | |||
| d2ed487079 | |||
| ef3935560d | |||
| 8e05d02695 | |||
| 88135c7e77 | |||
| 819d60d693 | |||
| 21dc788cd3 | |||
| 25a9fa4a02 | |||
| 89bc8d00f7 | |||
| 344ca70b64 | |||
| b72ce57544 | |||
| ca470f1bb6 | |||
| 300cf5d869 | |||
| 8116b307f7 | |||
| 7d6b28b4a3 | |||
| 2856505f8d | |||
| bddfcaac7e | |||
| c3d20c51a3 | |||
| 6a2dc83764 | |||
| aef1ef90fe | |||
| 8a2ca50032 | |||
| 69515c9e95 | |||
| e2cff3180e | |||
| c71abc67b8 | |||
| 9245c6032b | |||
| 253bc1f963 | |||
| c5a8d728d2 | |||
| e0e800012c | |||
| 82a521f12c | |||
| 60df2cf97e | |||
| f60730d0a5 | |||
| 596fd0f969 | |||
| 01a9b97f0d | |||
| cad1c44605 | |||
| fc0c071d1d | |||
| 6da08942a7 | |||
| cfdbdd9a44 | |||
| 3ac72b7ac9 | |||
| 0a048271c6 | |||
| 50e7adf246 | |||
| d2c157e8d0 | |||
| 8a99fb5ed6 | |||
| b224237cf7 | |||
| 66fd4c5377 | |||
| 3990471275 | |||
| 241d913e0f | |||
| f49fd5b885 | |||
| a072a13706 | |||
| f2e32dae92 | |||
| 419f7320b2 | |||
| 99e5cf9a7a | |||
| ac7cba0f92 | |||
| 7dc4cfcbce | |||
| 33b0264080 | |||
| 6169ad5d99 | |||
| 6b7d555879 | |||
| 71575f278e | |||
| a069407fbb | |||
| 600abc1800 | |||
| ca2d472837 | |||
| de22432cc5 | |||
| 58e0607e4c | |||
| d9f0986e26 | |||
| d88d74bd49 | |||
| bff1ad6d89 | |||
| a65541a4f1 | |||
| 8346d4658a | |||
| 779234e906 | |||
| 7d44bff526 | |||
| 3c97fd6769 | |||
| fbce4ce397 | |||
| 830567305b | |||
| 083da849b9 | |||
| fde8bd1a38 | |||
| 19932d9ca8 | |||
| efaec151c6 | |||
| 3d3e8068db | |||
| d5abdab4ca | |||
| 6fb685d273 | |||
| cb12804ca2 | |||
| 7418575935 | |||
| 4602c2e586 | |||
| 85fc75dd19 | |||
| 10659a37b5 | |||
| b2b09cb0d0 | |||
| c823c86b63 | |||
| b90087f5da | |||
| 8bde33ffc5 | |||
| dc7be22ed2 | |||
| 12aa4e0bca | |||
| a480a78ba7 | |||
| 5f8e86c914 | |||
| e4f5504e52 | |||
| 5d21420ed9 | |||
| c7b0b0462d | |||
| 7b89985a3d | |||
| 417952af40 | |||
| 0e7a20e45b | |||
| fed5e75122 | |||
| 3c62782a8d | |||
| ccfccaac0c | |||
| 177c05d7da | |||
| ee4bb4b86d | |||
| 5322eabee3 | |||
| de5912c206 | |||
| 5025f0043d | |||
| 3519042591 | |||
| 3f7579e6be | |||
| 7d9d86d5cc | |||
| 28ae533b0e | |||
| 5f50fa74df | |||
| 99e63a83cf | |||
| 161f4cec3a | |||
| aed750e4ee | |||
| 5728fd294e | |||
| d3f6dcecd5 | |||
| 02c8427cad | |||
| add52cfd09 | |||
| 7bc0d26cdd | |||
| 53d665cc6f | |||
| 41319157b2 | |||
| 5c1dd94efb | |||
| 8fcadf7f0d | |||
| d058c9cd5f | |||
| 55adf514de | |||
| 4307e16fda | |||
| 878299d661 | |||
| 5086d86340 | |||
| 42b3226306 | |||
| 313095984f | |||
| 9409f4bb08 | |||
| 2ffa6202f1 | |||
| 2d0214fc10 | |||
| e38c7358f6 | |||
| d7a1b09253 | |||
| 71f97e89c9 | |||
| ea2d84d36e | |||
| 1fc50fdd6d | |||
| 11277c6167 | |||
| e7f1bdb195 | |||
| 28678dc5b0 | |||
| 00f0cbfece | |||
| 62b89b6548 | |||
| 5301d8a525 | |||
| e02cf69745 | |||
| 9f7266bc6a | |||
| 83b2105a9a | |||
| 6d435844c9 | |||
| 022cbd41ea | |||
| b3aac1981c | |||
| a02facff1a | |||
| 33b5f2ee4d | |||
| 25a1343838 | |||
| 5697e3a59c | |||
| 67bea6c2c3 | |||
| 08243be745 | |||
| 03a8983b7d | |||
| 8202c838f5 | |||
| 6ad9584ea9 | |||
| d822784826 | |||
| 39fa6e9ff2 | |||
| 3fe17c7901 | |||
| 861ded53e0 | |||
| a1f0c35e8d | |||
| fd041ea95a | |||
| 34d834944b | |||
| acc24f4d1d | |||
| 2b1a25b9b0 | |||
| 8fc7a01778 | |||
| 9da2f64f5e | |||
| 741cf5390f | |||
| 3b52093be8 | |||
| daaf4a4805 | |||
| 2d21aac35f | |||
| 09e6a75a9b | |||
| 120f76c6f8 | |||
| 85cfad6318 | |||
| 605d10ae02 | |||
| 110624c866 | |||
| d5087cd693 | |||
| 843e07ab88 | |||
| 78dc7d5cb3 | |||
| 27bee43f89 | |||
| 08d57309ae | |||
| 0f3983dc93 | |||
| 1e4dfe5110 | |||
| c192c4d7a1 | |||
| 31f61cc056 | |||
| 8469ff7b6a | |||
| 42ad779750 | |||
| f37fe0086f | |||
| edbee5c8ef |
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(/mnt/c/Program Files/nodejs/npm.cmd run build)",
|
||||
"Bash(cmd.exe /c \"npm run build\")",
|
||||
"Bash(cmd.exe /c \"cd /d d:\\\\_projects\\\\gitea\\\\jama\\\\frontend && npm run build 2>&1\")",
|
||||
"Bash(cmd.exe /c \"cd /d d:\\\\_projects\\\\gitea\\\\jama\\\\frontend && npm run build\")",
|
||||
"Bash(powershell.exe -Command \"cd ''d:\\\\_projects\\\\gitea\\\\jama\\\\frontend''; & ''C:\\\\Program Files\\\\nodejs\\\\npm.cmd'' run build 2>&1\")",
|
||||
"Bash(powershell.exe -Command \"Get-Command npm -ErrorAction SilentlyContinue; \\(Get-Command node -ErrorAction SilentlyContinue\\).Source\")",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
46
.env
Normal file
@@ -0,0 +1,46 @@
|
||||
#** Required
|
||||
DB_PASSWORD=r0sterCh!rp2026
|
||||
JWT_SECRET=changemesupersecretjwtkey
|
||||
|
||||
#** App identity
|
||||
PROJECT_NAME=rosterchirp
|
||||
APP_NAME=RosterChirp
|
||||
DEFCHAT_NAME=General Chat
|
||||
ADMIN_NAME=Admin User
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
ADMIN_PASS=Admin@1234
|
||||
ADMPW_RESET=false
|
||||
|
||||
#** Database
|
||||
DB_NAME=rosterchirp
|
||||
DB_USER=rosterchirp
|
||||
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
|
||||
|
||||
#** Tenancy mode
|
||||
# selfhost = single tenant (RosterChirp-Chat / RosterChirp-Brand / RosterChirp-Team)
|
||||
# host = multi-tenant (RosterChirp-Host only)
|
||||
APP_TYPE=selfhost
|
||||
|
||||
#** RosterChirp-Host only (ignored in selfhost mode)
|
||||
HOST_DOMAIN=yourdomain.com
|
||||
HOST_ADMIN_KEY=VBGFHETSTTGRDDWAASJKH
|
||||
|
||||
#** Optional
|
||||
PORT=3144
|
||||
TZ=America/Toronto
|
||||
|
||||
#** Firebase Cloud Messaging (FCM) — Android background push
|
||||
# Web app config — from Firebase Console → Project Settings → General → Your apps
|
||||
FIREBASE_API_KEY=
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_APP_ID=
|
||||
# VAPID key — from Firebase Console → Project Settings → Cloud Messaging → Web Push certificates
|
||||
FIREBASE_VAPID_KEY=
|
||||
# Service account — from Firebase Console → Project Settings → Service accounts → Generate new private key
|
||||
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=
|
||||
60
.env.example
@@ -1,23 +1,53 @@
|
||||
# TeamChat Configuration
|
||||
# Copy this file to .env and customize
|
||||
# ── Required ──────────────────────────────────────────────────────────────────
|
||||
DB_PASSWORD=change_me_strong_password
|
||||
JWT_SECRET=change_me_super_secret_jwt_key
|
||||
|
||||
# Image version to run (set by build.sh, or use 'latest')
|
||||
TEAMCHAT_VERSION=latest
|
||||
|
||||
# Default admin credentials (used on FIRST RUN only)
|
||||
# ── App identity ──────────────────────────────────────────────────────────────
|
||||
PROJECT_NAME=rosterchirp
|
||||
APP_NAME=rosterchirp
|
||||
DEFCHAT_NAME=General Chat
|
||||
ADMIN_NAME=Admin User
|
||||
ADMIN_EMAIL=admin@teamchat.local
|
||||
ADMIN_EMAIL=admin@rosterchirp.local
|
||||
ADMIN_PASS=Admin@1234
|
||||
ADMPW_RESET=false
|
||||
|
||||
# Set to true to reset admin password to ADMIN_PASS on every restart
|
||||
# WARNING: Leave false in production - shows a warning on login page when true
|
||||
PW_RESET=false
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
DB_NAME=rosterchirp
|
||||
DB_USER=rosterchirp
|
||||
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
|
||||
|
||||
# JWT secret - change this to a random string in production!
|
||||
JWT_SECRET=changeme_super_secret_jwt_key_change_in_production
|
||||
# ── Tenancy mode ──────────────────────────────────────────────────────────────
|
||||
# selfhost = single tenant (RosterChirp-Chat / RosterChirp-Brand / RosterChirp-Team)
|
||||
# host = multi-tenant (RosterChirp-Host only)
|
||||
APP_TYPE=selfhost
|
||||
|
||||
# App port (default 3000)
|
||||
# ── RosterChirp-Host only (ignored in selfhost mode) ─────────────────────────────────
|
||||
# APP_DOMAIN=example.com
|
||||
# HOST_SLUG=chathost
|
||||
# HOST_ADMIN_KEY=change_me_host_admin_secret
|
||||
# To access the tenant host it would be http|https://HOST_SLUG.APP_DOMAIN (ie: http|https://chathost.example.com)
|
||||
|
||||
# ── Optional ──────────────────────────────────────────────────────────────────
|
||||
PORT=3000
|
||||
TZ=UTC
|
||||
|
||||
# App name (can also be changed in Settings UI)
|
||||
APP_NAME=TeamChat
|
||||
# ── Firebase Cloud Messaging (FCM) https://firebase.google.com/ — Android background push ──────────────────
|
||||
# Required for push notifications to work on Android when the app is backgrounded.
|
||||
# -- Get these from: Firebase Console → Project Settings → General → Your web app
|
||||
# FIREBASE_API_KEY=
|
||||
# FIREBASE_PROJECT_ID=
|
||||
# FIREBASE_MESSAGING_SENDER_ID=
|
||||
# FIREBASE_APP_ID=
|
||||
# -- Get VAPID key from: Firebase Console → Project Settings → Cloud Messaging → Web Push certificates
|
||||
# FIREBASE_VAPID_KEY=
|
||||
# -- Get service account JSON from: Firebase Console → Project Settings → Service accounts → Generate new private key
|
||||
# -- Paste the entire JSON content as a single-line string (include curlybracket to curlybracket):
|
||||
# FIREBASE_SERVICE_ACCOUNT={"type":"service_account","project_id":"..."}
|
||||
|
||||
# ── iOS (iPhone) background push ──────────────────
|
||||
# Required for push notifications to work on iOS when the app is backgrounded.
|
||||
# -- Get these from: https://vapidkeys.com/
|
||||
# -- The subject requires the "mailto:yourvalid@email.com" without quotes
|
||||
# VAPID_SUBJECT=
|
||||
# VAPID_PUBLIC=
|
||||
# FVAPID_PRIVATE=
|
||||
|
||||
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Environment — never commit real credentials
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
backend/node_modules/
|
||||
|
||||
# Build output
|
||||
frontend/dist/
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
uploads/
|
||||
|
||||
# Docker local volume mounts
|
||||
postgres-data/
|
||||
|
||||
# OS / editor artefacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Private reference / scratch docs
|
||||
ReferenceDocs/
|
||||
51
Bulk_User_Import.txt
Normal file
@@ -0,0 +1,51 @@
|
||||
<info_box>
|
||||
CVS Format (title>
|
||||
|
||||
FULL: email,firstname,lastname,password,role,default_usergroup
|
||||
MINIMUM: email,firstname,lastname,,,
|
||||
OPTIONAL: email,firstname,lastname,,,default_usergroup
|
||||
|
||||
We highly recommend using spreadsheet editor and save as a CSV file to ensure maximum accuracy
|
||||
You can include this header row (ie: email,firstname,lastname,password,role,usergroup,minor,guardian-user-email)
|
||||
|
||||
Examples:
|
||||
Parent: example@rosterchirp.com,Barney,Rubble,,member,parents,,
|
||||
Player: example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players,,
|
||||
Minor: Player: example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players,minor,example@rosterchirp.com
|
||||
|
||||
CVS Details (accordion title) - collapsed by default, click title to expand accordion
|
||||
<accordion>
|
||||
|
||||
CSV requirements:
|
||||
five commas, exactly, are required per row (rows with more or less will be ignored)
|
||||
email,firstname,lastname are the minimum required fields
|
||||
user can onlt be added to one group during a bulk import.
|
||||
optional fields: these fields can be left blank and the system defaults will be used
|
||||
|
||||
User Groups available: *
|
||||
list $user_groups (single column, multiple rows) that new users can be added to
|
||||
|
||||
Roles available: *
|
||||
member - non-priviledged user (default)
|
||||
manager - priviledged user: add/edit/remove schedules/users/user groups etc
|
||||
admin - priviledged user: manager + edit settings, change branding
|
||||
|
||||
* Only available group values (user group, roles) will be used, group values that do not exist will be ignored
|
||||
|
||||
Option field defaults:
|
||||
password ($setpass)
|
||||
role = member
|
||||
usergroup = <unset>
|
||||
minor = <unset>
|
||||
guardian-user-email = <unset>
|
||||
|
||||
</accordion>
|
||||
</info_box>
|
||||
|
||||
[Select CVS file] button as it currently exists
|
||||
checkbox "Ignore first row (header)"
|
||||
|
||||
**** Do not include the following text in the details above, they are your build instrcutions
|
||||
Build Instructions
|
||||
- validate all CVS requirements, skip rows that do not mean requirements
|
||||
- even if ignore first row is unchecked, check first header row for any values in "FULL" format, if true ignore row
|
||||
75
Caddyfile.example
Normal file
@@ -0,0 +1,75 @@
|
||||
# Caddyfile.example — RosterChirp-Host reverse proxy
|
||||
#
|
||||
# Caddy handles SSL automatically via Let's Encrypt.
|
||||
# Wildcard certs require a DNS challenge provider.
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Install the Caddy DNS plugin for your provider:
|
||||
# https://caddyserver.com/docs/automatic-https#dns-challenge
|
||||
# Common providers: cloudflare, route53, digitalocean
|
||||
#
|
||||
# 2. Set your DNS API token as an environment variable:
|
||||
# CF_API_TOKEN=your_cloudflare_token (or equivalent)
|
||||
#
|
||||
# 3. Add a wildcard DNS record in your DNS provider:
|
||||
# *.example.com → your server IP
|
||||
#
|
||||
# Usage:
|
||||
# Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it)
|
||||
# Reload: caddy reload
|
||||
|
||||
# ── Wildcard subdomain ────────────────────────────────────────────────────────
|
||||
# Handles mychat.example.com, teamB.example.com, chathost.example.com, etc.
|
||||
# Replace example.com with your actual APP_DOMAIN.
|
||||
|
||||
*.example.com {
|
||||
tls {
|
||||
dns cloudflare {env.CF_API_TOKEN}
|
||||
}
|
||||
|
||||
# Forward all requests to the rosterchirp app container
|
||||
reverse_proxy localhost:3000
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
-Server
|
||||
}
|
||||
|
||||
# Logs (optional)
|
||||
log {
|
||||
output file /var/log/caddy/rosterchirp-access.log
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
# ── Custom tenant domains ─────────────────────────────────────────────────────
|
||||
# When a tenant sets up a custom domain (e.g. chat.theircompany.com):
|
||||
#
|
||||
# 1. They add a DNS CNAME: chat.theircompany.com → your server IP
|
||||
#
|
||||
# 2. You add a block here and reload Caddy.
|
||||
# Caddy will automatically obtain and renew the SSL cert.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# chat.theircompany.com {
|
||||
# reverse_proxy localhost:3000
|
||||
# }
|
||||
#
|
||||
# Alternatively, use Caddy's on-demand TLS to handle custom domains
|
||||
# automatically without editing this file:
|
||||
#
|
||||
# (on_demand_tls) {
|
||||
# on_demand {
|
||||
# ask http://localhost:3000/api/host/verify-domain
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# *.example.com {
|
||||
# tls { on_demand }
|
||||
# reverse_proxy localhost:3000
|
||||
# }
|
||||
21
Dockerfile
@@ -2,43 +2,32 @@ ARG VERSION=dev
|
||||
ARG BUILD_DATE=unknown
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install frontend dependencies and build
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm install
|
||||
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
|
||||
# Backend
|
||||
FROM node:20-alpine
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE=unknown
|
||||
|
||||
LABEL org.opencontainers.image.title="TeamChat" \
|
||||
LABEL org.opencontainers.image.title="rosterchirp" \
|
||||
org.opencontainers.image.description="Self-hosted team chat PWA" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.source="https://github.com/yourorg/teamchat"
|
||||
org.opencontainers.image.created="${BUILD_DATE}"
|
||||
|
||||
ENV TEAMCHAT_VERSION=${VERSION}
|
||||
|
||||
RUN apk add --no-cache sqlite
|
||||
ENV ROSTERCHIRP_VERSION=${VERSION}
|
||||
|
||||
# No native build tools needed — pg uses pure JS by default
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY backend/ ./
|
||||
COPY --from=builder /app/frontend/dist ./public
|
||||
|
||||
# Create data and uploads directories
|
||||
RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
||||
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
|
||||
152
FEATURES.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# RosterChirp — Feature Reference
|
||||
|
||||
> **Current version:** 0.12.42
|
||||
> **Application types:** RosterChirp-Chat · RosterChirp-Brand · RosterChirp-Team
|
||||
|
||||
---
|
||||
|
||||
## All Users
|
||||
|
||||
### Messaging
|
||||
|
||||
- **Public Messages** — Read and post in public group channels open to all members. Channels can be marked read-only by an admin (announcements-style).
|
||||
- **Private Group Messages** — Participate in named private groups with a specific set of members.
|
||||
- **Direct Messages (U2U)** — Start a private one-on-one conversation with any user who has not blocked direct messages.
|
||||
- **Group Messages** — Access managed private group conversations assigned to you through User Groups (requires RosterChirp-Team).
|
||||
- **Message History** — Scroll back through conversation history with paginated loading (50 messages per page).
|
||||
- **Message Reactions** — React to any message with an emoji.
|
||||
- **Image Sharing** — Attach and send images in any conversation.
|
||||
- **Reply Threading** — Reply to a specific message to preserve context.
|
||||
- **@Mentions** — Mention users by name; mentioned users receive a notification badge.
|
||||
- **Link Previews** — URLs pasted into messages automatically generate a title/image preview card.
|
||||
- **Message Deletion** — Authors can delete their own messages; deleted messages are replaced with a tombstone.
|
||||
|
||||
### Schedule (requires RosterChirp-Team)
|
||||
|
||||
- **Calendar View** — Browse events in a full monthly calendar grid (desktop) or a day-list view (mobile).
|
||||
- **Event Details** — Tap any event to view its full details: date/time, location, description, event type, assigned user groups, and recurrence pattern.
|
||||
- **Availability Response** — Respond to events with **Going**, **Maybe**, or **Not Going**, plus an optional short note (up to 20 characters).
|
||||
- **Bulk Availability** — Respond to multiple pending events at once from a single screen.
|
||||
- **Response Summary** — See how many group members have responded Going / Maybe / Not Going on any event you are assigned to.
|
||||
- **Filter & Search** — Filter the calendar by event type, keyword, or your own availability status. Keyword search supports word-boundary matching and exact quoted terms.
|
||||
|
||||
### Profile & Account
|
||||
|
||||
- **Display Name** — Set a public display name shown alongside your username (must be unique).
|
||||
- **Avatar** — Upload a custom profile photo. A consistent colour avatar is generated automatically from your name if no photo is set.
|
||||
- **About Me** — Add a short bio visible on your profile.
|
||||
- **Hide Admin Tag** — Admins can choose to hide the "Admin" role badge on their messages.
|
||||
- **Block Direct Messages** — Opt out of receiving unsolicited direct messages from other users.
|
||||
- **Change Password** — Change your own account password at any time.
|
||||
- **Font Scale** — Adjust the interface text size (80%–200%) stored per-device.
|
||||
|
||||
### Notifications & Presence
|
||||
|
||||
- **Push Notifications** — Receive push notifications for new messages when the app is backgrounded. Supports Android (Firebase Cloud Messaging) and iOS 16.4+ PWA (Web Push / VAPID).
|
||||
- **Notification Permission** — Grant or revoke push notification permission from the Notifications tab in your profile.
|
||||
- **Unread Badges** — Conversations with unread messages display a count badge in the sidebar and on the PWA app icon.
|
||||
- **Online Presence** — A green indicator shows which users are currently active. Last-seen time is displayed for offline users.
|
||||
- **Browser Tab Badge** — The page title and PWA icon badge update with the total unread count across all conversations.
|
||||
|
||||
### App Experience
|
||||
|
||||
- **Progressive Web App (PWA)** — Install RosterChirp to your home screen on Android, iOS, and desktop for a native app feel.
|
||||
- **Dark / Light Theme** — The interface respects your operating system's colour scheme preference automatically.
|
||||
- **Mobile-Optimised Layout** — A dedicated mobile layout with a slide-in sidebar, swipe-back navigation, and mobile-native time/date pickers.
|
||||
- **Keyboard Shortcuts** — Press Enter to send messages; Escape to dismiss modals.
|
||||
|
||||
---
|
||||
|
||||
## Managers (Tool Managers)
|
||||
|
||||
Tool Manager access is granted by an admin to members of one or more designated **User Groups**. Managers have access to the following tools in addition to all user features.
|
||||
|
||||
### User Manager
|
||||
|
||||
- **View All Users** — Browse the full user directory including email, role, phone, status, and last seen time.
|
||||
- **Create Users** — Add individual new user accounts with name, email, role, and phone.
|
||||
- **Bulk Import** — Import multiple users at once from a structured list (CSV-compatible).
|
||||
- **Edit Users** — Update names, email addresses, phone numbers, and minor status for any user.
|
||||
- **Suspend / Activate** — Suspend a user to block login without deleting their account or messages. Reversible at any time.
|
||||
- **Reset Password** — Set a new temporary password for any user.
|
||||
|
||||
### Group Manager (requires RosterChirp-Team)
|
||||
|
||||
- **Create User Groups** — Create named user groups to organise members into teams or departments.
|
||||
- **Manage Members** — Add or remove users from any user group. Member changes trigger a system notification in the group's conversation.
|
||||
- **Multi-Groups** — Create a multi-group conversation that spans multiple user groups simultaneously.
|
||||
- **Assign Schedule Groups** — Link user groups to schedule events to control who is invited and whose availability is tracked.
|
||||
|
||||
### Schedule Manager (requires RosterChirp-Team)
|
||||
|
||||
- **Create Events** — Create new calendar events with title, type, date/time, location, description, visibility (public/private), and assigned user groups.
|
||||
- **Edit & Delete Events** — Modify or remove any event. Recurring events support editing/deleting a single occurrence, all future occurrences, or the entire series.
|
||||
- **Recurring Events** — Schedule repeating events (daily, weekly, bi-weekly, monthly) with optional end date or occurrence count. Supports specific weekday selection for weekly recurrence.
|
||||
- **Event Types** — Create and manage colour-coded event type categories (e.g. Training, Match, Meeting).
|
||||
- **Track Availability** — Enable availability tracking on an event to collect Going / Maybe / Not Going responses from assigned group members.
|
||||
- **View Full Responses** — See the complete list of who has responded and with what answer, including individual notes. The **No Response** count shows how many assigned members have not yet replied.
|
||||
- **Download Availability List** — Export a formatted `.txt` file of all availability responses for an event, organised by section (Going, Maybe, Not Going, No Response) and sorted alphabetically by last name within each section.
|
||||
- **Import Schedule** — Upload and preview a schedule import file, then confirm to bulk-create events.
|
||||
- **Past Event Visibility** — View and manage past events in the calendar; past events are displayed in a greyed style.
|
||||
|
||||
---
|
||||
|
||||
## Admins
|
||||
|
||||
Admins have full access to all user and manager features plus the following administrative controls.
|
||||
|
||||
### User Manager (extended)
|
||||
|
||||
- **Delete Users** — Permanently scrub a user's account: email and name are anonymised, all their messages are marked deleted, and direct message threads become read-only. Frees the email address for re-registration immediately.
|
||||
- **Assign Roles** — Promote or demote users between the **User**, **Manager**, and **Admin** roles.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Message Features** — Enable or disable individual message channel types across the entire instance: Public Messages, Group Messages, Private Group Messages, and Private Messages (U2U). Disabled features are hidden from all menus, sidebars, and modals.
|
||||
- **Registration** — Apply a registration code to unlock the application type (Chat / Brand / Team) and associated features. View the instance serial number and current registration status.
|
||||
|
||||
### Branding (requires RosterChirp-Brand or higher)
|
||||
|
||||
- **App Name** — Set a custom application name that appears in the header, browser tab, and push notifications.
|
||||
- **Logo / Favicon** — Upload a custom logo used as the app header image and PWA icon (192×512 px generated automatically).
|
||||
- **Header Colour** — Set custom header bar colours for light mode and dark mode independently.
|
||||
- **Avatar Colours** — Customise the default avatar colours used for public channel icons and direct message icons.
|
||||
- **Reset Branding** — Restore all branding settings to the default RosterChirp values in one click.
|
||||
|
||||
### Team Configuration (requires RosterChirp-Team)
|
||||
|
||||
- **Tool Manager Groups** — Designate one or more User Groups whose members are granted Tool Manager access (User Manager, Group Manager, Schedule Manager). Admins always have full access regardless of this setting.
|
||||
|
||||
### Control Panel (Host mode only — admin on the host domain)
|
||||
|
||||
- **Tenant Management** — View, create, suspend, and delete tenant instances from a central dashboard.
|
||||
- **Assign Plans** — Set the application type (Chat / Brand / Team) for each tenant.
|
||||
- **Custom Domains** — Assign a custom domain to a tenant in addition to its default subdomain.
|
||||
- **Tenant Details** — View each tenant's slug, plan, status, custom domain, and creation date.
|
||||
|
||||
---
|
||||
|
||||
## Hosting & Tenant Privacy
|
||||
|
||||
RosterChirp supports two deployment modes configured via the `APP_TYPE` environment variable.
|
||||
|
||||
### Self-Hosted (Single Tenant)
|
||||
|
||||
`APP_TYPE=selfhost` — The default mode for teams running their own private instance. All data is stored in a single PostgreSQL schema. There are no subdomains or tenant concepts; the application runs at the root of whatever domain or IP the server is deployed on.
|
||||
|
||||
### RosterChirp-Host (Multi-Tenant)
|
||||
|
||||
`APP_TYPE=host` — Enables multi-tenant hosting from a single server. Each tenant is provisioned with:
|
||||
|
||||
- **A unique slug** — for example, the slug `acme` creates a dedicated instance accessible at `acme.yourdomain.com`. The slug is set at provisioning time and forms the permanent subdomain for that tenant.
|
||||
- **An isolated Postgres schema** — every tenant's data (users, messages, groups, events, settings) lives in its own named schema (`tenant_acme`, etc.) within the same database. No data is shared between tenants.
|
||||
- **An optional custom domain** — a tenant can be mapped to a fully custom domain (e.g. `chat.acme.com`) in addition to its default subdomain. Custom domain lookups are cached for performance.
|
||||
- **Plan-level feature control** — each tenant can be assigned a different application type (Chat / Brand / Team), enabling per-tenant feature gating from the host control panel.
|
||||
|
||||
### Privacy & Isolation Guarantees
|
||||
|
||||
- **Schema isolation** — all database queries are scoped to the tenant's schema. A query in one tenant's context cannot read or write another tenant's tables.
|
||||
- **Socket room isolation** — all real-time socket rooms are prefixed with the tenant schema name (`acme:group:42`). Events emitted in one tenant's rooms cannot reach sockets in another tenant.
|
||||
- **Online presence isolation** — the online user map is keyed by `schema:userId`, preventing user ID collisions between tenants from leaking presence data.
|
||||
- **Session isolation** — JWT tokens are validated against the tenant schema. A valid token for one tenant is not accepted by another.
|
||||
- **Host control plane separation** — the host admin control panel is only accessible on the host's own root domain, protected by a separate `HOST_ADMIN_KEY`, and hidden from all tenant subdomains.
|
||||
650
README.md
@@ -1,221 +1,603 @@
|
||||
# TeamChat 💬
|
||||
<<<<<<< HEAD
|
||||
# RosterChirp
|
||||
|
||||
A modern, self-hosted team chat Progressive Web App (PWA) — similar to Google Messages / Facebook Messenger for teams.
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **Authentication** — Login, remember me, forced password change on first login
|
||||
- 💬 **Real-time messaging** — WebSocket (Socket.io) powered chat
|
||||
- 👥 **Public channels** — Admin-created, all users auto-joined
|
||||
- 🔒 **Private groups** — User-created, owner-managed
|
||||
- 📷 **Image uploads** — Attach images to messages
|
||||
- 💬 **Message quoting** — Reply to any message with preview
|
||||
- 😎 **Emoji reactions** — Quick reactions + full emoji picker
|
||||
- @**Mentions** — @mention users with autocomplete, they get notified
|
||||
- 🔗 **Link previews** — Auto-fetches OG metadata for URLs
|
||||
- 📱 **PWA** — Install to home screen, works offline
|
||||
- 👤 **Profiles** — Custom avatars, display names, about me
|
||||
- ⚙️ **Admin settings** — Custom logo, app name
|
||||
- 👨💼 **User management** — Create, reset password, suspend, delete, bulk CSV import
|
||||
- 📢 **Read-only channels** — Announcement-style public channels
|
||||
### Messaging
|
||||
- **Real-time messaging** — WebSocket-powered (Socket.io); messages appear instantly across all clients
|
||||
- **Image attachments** — Attach and send images via the + menu; auto-compressed client-side before upload
|
||||
- **Camera capture** — Take a photo directly from the + menu on mobile devices
|
||||
- **Emoji picker** — Send standalone emoji messages at large size via the + menu
|
||||
- **Message replies** — Quote and reply to any message with an inline preview
|
||||
- **Emoji reactions** — Quick-react with common emojis or open the full emoji picker; one reaction per user, replaceable
|
||||
- **@Mentions** — Type `@` to search and tag users using `@[Display Name]` syntax; autocomplete scoped to group members; mentioned users receive a notification
|
||||
- **Link previews** — URLs are automatically expanded with Open Graph metadata (title, image, site name)
|
||||
- **Typing indicators** — See when others are composing a message
|
||||
- **Image lightbox** — Tap any image to open it full-screen with pinch-to-zoom support
|
||||
- **Message grouping** — Consecutive messages from the same user are visually grouped; avatar and name shown only on first message
|
||||
- **Last message preview** — Sidebar shows "You:" prefix when the current user sent the last message
|
||||
|
||||
### Channels & Groups
|
||||
- **Public channels** — Admin-created; all users are automatically added
|
||||
- **Private groups / DMs** — Any user can create; membership is invite-only by the owner
|
||||
- **Direct messages** — One-to-one private conversations; sidebar title always shows the other user's real name
|
||||
- **Duplicate group prevention** — Creating a private group with the same member set as an existing group redirects to the existing group automatically
|
||||
- **Read-only channels** — Admin-configurable announcement-style channels; only admins can post
|
||||
- **Support group** — A private admin-only group that receives submissions from the login page contact form
|
||||
- **Custom group names** — Each user can set a personal display name for any group, visible only to them
|
||||
- **Group Messages** — Managed private groups (created and controlled by admins via Group Manager) appear in a separate "Private Group Messages" section in the sidebar
|
||||
|
||||
### Schedule
|
||||
- **Team schedule** — Full calendar view for creating and managing team events (Team plan)
|
||||
- **Desktop & mobile views** — Dedicated layout for each; desktop shows a full monthly grid, mobile shows a scrollable event list
|
||||
- **Event types** — Colour-coded event categories (configurable by admins)
|
||||
- **Recurring events** — Create daily, weekly, or custom-interval recurring events; only future occurrences are shown
|
||||
- **Availability** — Users can mark their availability per event
|
||||
- **Keyword filter** — Search events by keyword with word-boundary matching; quoted terms match exactly
|
||||
- **Type filter** — Filter events by event type across the current month (including past events, shown greyed)
|
||||
- **Past event protection** — New events cannot be created with a start date/time in the past
|
||||
|
||||
### Users & Profiles
|
||||
- **Authentication** — Email/password login with optional Remember Me (30-day session)
|
||||
- **Forced password change** — New users must change their password on first login
|
||||
- **User profiles** — Custom display name, avatar upload, About Me text
|
||||
- **Profile popup** — Click any user's avatar in chat to view their profile card
|
||||
- **Admin badge** — Admins display a role badge; can be hidden per-user in Profile settings
|
||||
- **Online presence** — Real-time online/offline status tracked per user
|
||||
- **Last seen** — Users' last online timestamp updated on disconnect
|
||||
|
||||
### Notifications
|
||||
- **In-app notifications** — Mention alerts with toast notifications
|
||||
- **Unread indicators** — Private groups with new unread messages are highlighted and bolded in the sidebar
|
||||
- **Push notifications** — Firebase Cloud Messaging (FCM) push notifications for mentions and new private messages when the app is backgrounded or closed (Android PWA; requires HTTPS and Firebase setup)
|
||||
|
||||
### Admin & Settings
|
||||
- **User Manager** — Create, suspend, activate, delete users; reset passwords; change roles
|
||||
- **Bulk CSV import** — Import multiple users at once from a CSV file
|
||||
- **Group Manager** — Create and manage private groups and their membership centrally (Team plan)
|
||||
- **App branding** — Customize app name, logo, and icons via the Settings panel (Brand+ plan)
|
||||
- **Reset to defaults** — One-click reset of all branding customizations
|
||||
- **Version display** — Current app version shown in the Settings panel
|
||||
- **Default user password** — Configurable via `USER_PASS` env var; shown live in User Manager
|
||||
- **Feature flags** — Plan-gated features (branding, group manager, schedule manager) controlled via settings
|
||||
|
||||
### User Deletion
|
||||
- Deleting a user scrubs their email, name, and avatar immediately
|
||||
- Their messages are marked deleted (content removed); direct message threads become read-only
|
||||
- Group memberships, sessions, push subscriptions, and notifications are purged
|
||||
- Suspended users retain all data and can be re-activated
|
||||
|
||||
### Help & Onboarding
|
||||
- **Getting Started modal** — Appears automatically on first login; users can dismiss permanently with "Do not show again"
|
||||
- **Help menu item** — Always accessible from the user menu regardless of dismissed state
|
||||
- **Editable help content** — `data/help.md` is edited before build and baked into the image at build time
|
||||
|
||||
### PWA
|
||||
- **Installable** — Install to home screen on mobile and desktop via the browser install prompt
|
||||
- **Adaptive icons** — Separate `any` and `maskable` icon entries; maskable icons sized for Android circular crop
|
||||
- **Dynamic app icon** — Uploaded logo is automatically resized and used as the PWA shortcut icon
|
||||
- **Dynamic manifest** — App name and icons update live when changed in Settings
|
||||
- **Pull-to-refresh disabled** — In PWA standalone mode, pull-to-refresh is disabled to prevent a layout shift bug on mobile
|
||||
|
||||
### Contact Form
|
||||
- **Login page contact form** — A "Contact Support" button on the login page opens a form that posts directly into the admin Support group
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Deployment Modes
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
| 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`. |
|
||||
|
||||
### 1. Build a versioned image
|
||||
Set `APP_TYPE=selfhost` or `APP_TYPE=host` in `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Plans & Feature Flags
|
||||
|
||||
| Plan | Features |
|
||||
|---|---|
|
||||
| **RosterChirp-Chat** | Messaging, channels, DMs, profiles, push notifications |
|
||||
| **RosterChirp-Brand** | Everything in Chat + custom branding (logo, app name, icons) |
|
||||
| **RosterChirp-Team** | Everything in Brand + Group Manager + Schedule Manager |
|
||||
|
||||
Feature flags are stored in the database `settings` table and can be toggled by the admin.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend | Node.js, Express, Socket.io |
|
||||
| Database | PostgreSQL 16 (via `pg`) |
|
||||
| Frontend | React 18, Vite |
|
||||
| Push notifications | Firebase Cloud Messaging (FCM) |
|
||||
| Image processing | sharp |
|
||||
| Containerization | Docker, Docker Compose v2 |
|
||||
| Reverse proxy / SSL | Caddy (recommended) |
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Docker** and **Docker Compose v2**
|
||||
- A domain name with DNS pointed at your server (required for HTTPS and push notifications)
|
||||
- Ports **80** and **443** open on your server firewall (if using Caddy for SSL)
|
||||
- (Optional) A Firebase project for push notifications
|
||||
|
||||
---
|
||||
|
||||
## Building the Image
|
||||
|
||||
All builds use `build.sh`. No host Node.js installation is required.
|
||||
|
||||
> **Tip:** Edit `data/help.md` before running `build.sh` to customise the Getting Started help content baked into the image.
|
||||
|
||||
```bash
|
||||
# Build and tag as v1.0.0 (also tags :latest)
|
||||
./build.sh 1.0.0
|
||||
|
||||
# Build latest only
|
||||
# Build and tag as :latest only
|
||||
./build.sh
|
||||
|
||||
# Build and tag as a specific version
|
||||
./build.sh 0.13.1
|
||||
```
|
||||
|
||||
### 2. Deploy with Docker Compose
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
<<<<<<< HEAD
|
||||
git clone https://your-git/youruser/rosterchirp.git
|
||||
=======
|
||||
git clone https://your-gitea/youruser/rosterchirp.git
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
cd rosterchirp
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
./build.sh 0.13.1
|
||||
```
|
||||
|
||||
### 3. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env — set TEAMCHAT_VERSION, admin credentials, JWT_SECRET
|
||||
nano .env
|
||||
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
App will be available at **http://localhost:3000**
|
||||
At minimum, set `ADMIN_EMAIL`, `ADMIN_PASS`, `ADMIN_NAME`, `JWT_SECRET`, and `DB_PASSWORD`.
|
||||
|
||||
### 4. Start the services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f rosterchirp
|
||||
```
|
||||
|
||||
### 5. Log in
|
||||
|
||||
Open `http://your-server:3000`, log in with your `ADMIN_EMAIL` and `ADMIN_PASS`, and change your password when prompted.
|
||||
|
||||
---
|
||||
|
||||
## Release Workflow
|
||||
## HTTPS & SSL
|
||||
|
||||
TeamChat uses a **build-then-run** pattern. You build the image once on your build machine (or CI), then the compose file just runs the pre-built image — no build step at deploy time.
|
||||
<<<<<<< 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
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌──────────────────────────┐
|
||||
│ Build machine / CI │ │ Server / Portainer │
|
||||
│ │ │ │
|
||||
│ ./build.sh 1.2.0 │─────▶│ TEAMCHAT_VERSION=1.2.0 │
|
||||
│ (or push to │ │ docker compose up -d │
|
||||
│ registry first) │ │ │
|
||||
└─────────────────────┘ └──────────────────────────┘
|
||||
chat.yourdomain.com {
|
||||
reverse_proxy rosterchirp:3000
|
||||
}
|
||||
```
|
||||
|
||||
### Build script usage
|
||||
### docker-compose.yaml (with Caddy)
|
||||
|
||||
```bash
|
||||
# Build locally (image stays on this machine)
|
||||
./build.sh 1.0.0
|
||||
```yaml
|
||||
services:
|
||||
rosterchirp:
|
||||
<<<<<<< HEAD
|
||||
image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
|
||||
=======
|
||||
image: rosterchirp:${rosterchirp_VERSION:-latest}
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
container_name: rosterchirp
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- APP_TYPE=${APP_TYPE:-selfhost}
|
||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@rosterchirp.local}
|
||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||
- USER_PASS=${USER_PASS:-user@1234}
|
||||
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||
- JWT_SECRET=${JWT_SECRET:-changeme}
|
||||
<<<<<<< HEAD
|
||||
- APP_NAME=${APP_NAME:-RosterChirp}
|
||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||
- DB_HOST=db
|
||||
- DB_NAME=${DB_NAME:-rosterchirp}
|
||||
- DB_USER=${DB_USER:-rosterchirp}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- ROSTERCHIRP_VERSION=${ROSTERCHIRP_VERSION:-latest}
|
||||
volumes:
|
||||
- rosterchirp_uploads:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
# Build and push to Docker Hub
|
||||
REGISTRY=yourdockerhubuser ./build.sh 1.0.0 push
|
||||
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
|
||||
|
||||
# Build and push to GHCR
|
||||
REGISTRY=ghcr.io/yourorg ./build.sh 1.0.0 push
|
||||
caddy:
|
||||
image: caddy:alpine
|
||||
container_name: caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_certs:/config
|
||||
depends_on:
|
||||
- rosterchirp
|
||||
|
||||
volumes:
|
||||
rosterchirp_db:
|
||||
rosterchirp_uploads:
|
||||
caddy_data:
|
||||
caddy_certs:
|
||||
```
|
||||
|
||||
### Deploying a specific version
|
||||
|
||||
Set `TEAMCHAT_VERSION` in your `.env` before running compose:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
TEAMCHAT_VERSION=1.2.0
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose pull # if pulling from a registry
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Rolling back
|
||||
|
||||
```bash
|
||||
# .env
|
||||
TEAMCHAT_VERSION=1.1.0
|
||||
|
||||
docker compose up -d # instantly rolls back to previous image
|
||||
```
|
||||
|
||||
Data volumes are unaffected by version changes.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ADMIN_NAME` | `Admin User` | Default admin display name |
|
||||
| `ADMIN_EMAIL` | `admin@teamchat.local` | Default admin email (login) |
|
||||
| `ADMIN_PASS` | `Admin@1234` | Default admin password (first run only) |
|
||||
| `PW_RESET` | `false` | If `true`, resets admin password to `ADMIN_PASS` on every restart |
|
||||
| `JWT_SECRET` | *(insecure default)* | **Change this!** Used to sign auth tokens |
|
||||
| `PORT` | `3000` | HTTP port to listen on |
|
||||
| `APP_NAME` | `TeamChat` | Initial app name (can be changed in Settings) |
|
||||
|---|---|---|
|
||||
<<<<<<< HEAD
|
||||
| `APP_TYPE` | `selfhost` | Deployment mode: `selfhost` (single tenant) or `host` (multi-tenant) |
|
||||
| `ROSTERCHIRP_VERSION` | `latest` | Docker image tag to run |
|
||||
=======
|
||||
| `rosterchirp_VERSION` | `latest` | Docker image tag to run |
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
| `TZ` | `UTC` | Container timezone (e.g. `America/Toronto`) |
|
||||
| `ADMIN_NAME` | `Admin User` | Display name of the default admin account |
|
||||
| `ADMIN_EMAIL` | `admin@rosterchirp.local` | Login email for the default admin account |
|
||||
| `ADMIN_PASS` | `Admin@1234` | Initial password for the default admin account |
|
||||
| `USER_PASS` | `user@1234` | Default temporary password for bulk-imported users when no password is specified in CSV |
|
||||
| `ADMPW_RESET` | `false` | If `true`, resets the admin password to `ADMIN_PASS` on every restart. Emergency recovery only. |
|
||||
| `JWT_SECRET` | *(insecure default)* | Secret used to sign auth tokens. **Must be changed in production.** |
|
||||
<<<<<<< HEAD
|
||||
| `APP_NAME` | `RosterChirp` | Initial application name (can also be changed in Settings UI) |
|
||||
| `DEFCHAT_NAME` | `General Chat` | Name of the default public channel created on first run |
|
||||
| `DB_HOST` | `db` | PostgreSQL hostname |
|
||||
| `DB_NAME` | `rosterchirp` | PostgreSQL database name |
|
||||
| `DB_USER` | `rosterchirp` | PostgreSQL username |
|
||||
| `DB_PASSWORD` | *(required)* | PostgreSQL password. **Avoid `!` — shell interpolation issue with Docker Compose.** |
|
||||
| `APP_DOMAIN` | — | Base domain for multi-tenant host mode (e.g. `example.com`) |
|
||||
| `HOST_SLUG` | — | Subdomain slug for the host control panel (e.g. `chathost` → `chathost.example.com`) |
|
||||
| `HOST_ADMIN_KEY` | — | Secret key for the host control plane API |
|
||||
=======
|
||||
| `PORT` | `3000` | Host port to bind (without Caddy) |
|
||||
| `APP_NAME` | `rosterchirp` | Initial application name (can also be changed in Settings UI) |
|
||||
| `DEFCHAT_NAME` | `General Chat` | Name of the default public group created on first run |
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
|
||||
> **Important:** `ADMIN_EMAIL` and `ADMIN_PASS` are only used on the very first run to create the admin account. After the admin changes their password, these variables are ignored — **unless** `PW_RESET=true`.
|
||||
### Firebase Push Notification Variables (optional)
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `FIREBASE_API_KEY` | Firebase web app API key |
|
||||
| `FIREBASE_PROJECT_ID` | Firebase project ID |
|
||||
| `FIREBASE_MESSAGING_SENDER_ID` | Firebase messaging sender ID |
|
||||
| `FIREBASE_APP_ID` | Firebase web app ID |
|
||||
| `FIREBASE_VAPID_KEY` | Web Push certificate public key (from Firebase Cloud Messaging tab) |
|
||||
| `FIREBASE_SERVICE_ACCOUNT` | Full service account JSON, stringified (remove all newlines) |
|
||||
|
||||
> `ADMIN_EMAIL` and `ADMIN_PASS` are only used on the **first run**. Once the database is seeded they are ignored — unless `ADMPW_RESET=true`.
|
||||
|
||||
### Example `.env`
|
||||
|
||||
```env
|
||||
<<<<<<< HEAD
|
||||
ROSTERCHIRP_VERSION=0.13.1
|
||||
APP_TYPE=selfhost
|
||||
=======
|
||||
rosterchirp_VERSION=1.0.0
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
TZ=America/Toronto
|
||||
|
||||
ADMIN_NAME=Your Name
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
ADMIN_PASS=ChangeThisNow!
|
||||
|
||||
USER_PASS=Welcome@123
|
||||
ADMPW_RESET=false
|
||||
|
||||
JWT_SECRET=replace-this-with-a-long-random-string-at-least-32-chars
|
||||
|
||||
<<<<<<< HEAD
|
||||
APP_NAME=RosterChirp
|
||||
=======
|
||||
PORT=3000
|
||||
APP_NAME=rosterchirp
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
DEFCHAT_NAME=General Chat
|
||||
|
||||
DB_NAME=rosterchirp
|
||||
DB_USER=rosterchirp
|
||||
DB_PASSWORD=a-strong-db-password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## First Login
|
||||
## First Login & Setup Checklist
|
||||
|
||||
1. Navigate to `http://localhost:3000`
|
||||
2. Login with `ADMIN_EMAIL` / `ADMIN_PASS`
|
||||
3. You'll be prompted to **change your password** immediately
|
||||
4. You're in! The default **TeamChat** public channel is ready
|
||||
|
||||
---
|
||||
|
||||
## PW_RESET Warning
|
||||
|
||||
If you set `PW_RESET=true`:
|
||||
- The admin password resets to `ADMIN_PASS` on **every container restart**
|
||||
- A ⚠️ warning banner appears on the login page
|
||||
- This is intentional for emergency access recovery
|
||||
- **Always set back to `false` after recovering access**
|
||||
1. Log in with `ADMIN_EMAIL` / `ADMIN_PASS`
|
||||
2. Change your password when prompted
|
||||
3. Read the **Getting Started** guide that appears on first login
|
||||
4. Open ⚙️ **Settings** → upload a logo and set the app name
|
||||
5. Open 👥 **User Manager** to create accounts for your team
|
||||
|
||||
---
|
||||
|
||||
## User Management
|
||||
|
||||
Admins can access **User Manager** from the bottom menu:
|
||||
Accessible from the bottom-left menu (admin only).
|
||||
|
||||
- **Create single user** — Name, email, temp password, role
|
||||
- **Bulk import via CSV** — Format: `name,email,password,role`
|
||||
- **Reset password** — User is forced to change on next login
|
||||
- **Suspend / Activate** — Suspended users cannot login
|
||||
- **Delete** — Soft delete; messages remain, sessions invalidated
|
||||
- **Elevate / Demote** — Change member ↔ admin role
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| Create user | Set name, email, temporary password, and role |
|
||||
| Bulk CSV import | Upload a CSV to create multiple users at once |
|
||||
| Reset password | User is forced to set a new password on next login |
|
||||
| Suspend | Blocks login; messages are preserved |
|
||||
| Activate | Re-enables a suspended account |
|
||||
| Delete | Scrubs account data; messages are removed; threads become read-only |
|
||||
| Change role | Promote member → admin or demote admin → member |
|
||||
|
||||
### CSV Import Format
|
||||
|
||||
```csv
|
||||
name,email,password,role
|
||||
John Doe,john@example.com,TempPass123,member
|
||||
Jane Smith,jane@example.com,,admin
|
||||
```
|
||||
|
||||
- `password` is optional — defaults to the value of `USER_PASS` if omitted
|
||||
- All imported users must change their password on first login
|
||||
|
||||
---
|
||||
|
||||
## Group Types
|
||||
|
||||
| | Public Channels | Private Groups |
|
||||
|--|--|--|
|
||||
| Creator | Admin only | Any user |
|
||||
| Members | All users (auto) | Invited by owner |
|
||||
| Visible to admins | ✅ Yes | ❌ No (unless admin takes ownership) |
|
||||
| Leave | ❌ Not allowed | ✅ Yes |
|
||||
| Rename | Admin only | Owner only |
|
||||
| Read-only mode | ✅ Optional | ❌ N/A |
|
||||
| Default group | TeamChat (permanent) | — |
|
||||
| | Public Channels | Private Groups | Direct Messages |
|
||||
|---|---|---|---|
|
||||
| Who can create | Admin only | Any user | Any user |
|
||||
| Membership | All users (automatic) | Invite-only by owner | Two users only |
|
||||
| Sidebar title | Group name | Group name (customisable per user) | Other user's real name |
|
||||
| Rename | Admin only | Owner only | ❌ Not allowed |
|
||||
| Read-only mode | ✅ Optional | ❌ N/A | ❌ N/A |
|
||||
| Duplicate prevention | N/A | ✅ Redirects to existing | ✅ Redirects to existing |
|
||||
| Managed (Group Manager) | ❌ | ✅ Optional | ❌ |
|
||||
|
||||
### @Mention Scoping
|
||||
|
||||
- **Public channels** — all active users appear in the `@` autocomplete
|
||||
- **Private groups** — only members of that group appear
|
||||
- **Direct messages** — only the other participant appears
|
||||
|
||||
---
|
||||
|
||||
## CSV Import Format
|
||||
## Custom Group Names
|
||||
|
||||
```csv
|
||||
name,email,password,role
|
||||
John Doe,john@example.com,TempPass123,member
|
||||
Jane Admin,jane@example.com,Admin@456,admin
|
||||
Any user can set a personal display name for any group:
|
||||
|
||||
1. Open the group and tap the **ⓘ info** icon
|
||||
2. Enter a name under **Your custom name** and tap **Save**
|
||||
3. The custom name appears in your sidebar and chat header only
|
||||
4. Message Info shows: `Custom Name (Owner's Name)`
|
||||
5. Clear the field and tap **Save** to revert to the owner's name
|
||||
|
||||
---
|
||||
|
||||
## Schedule
|
||||
|
||||
The Schedule page (Team plan) provides a full team calendar:
|
||||
|
||||
- **Desktop view** — Monthly grid with event cards per day
|
||||
- **Mobile view** — Scrollable event list with a date picker
|
||||
- **Event types** — Colour-coded categories created by admins
|
||||
- **Recurring events** — Set daily, weekly, or custom recurrence intervals
|
||||
- **Availability** — Members can mark availability per event
|
||||
- **Keyword search** — Unquoted terms match word prefixes; quoted terms match whole words exactly
|
||||
- **Type filter** — Filter by event type across the full current month
|
||||
|
||||
---
|
||||
|
||||
## Push Notifications
|
||||
|
||||
RosterChirp uses **Firebase Cloud Messaging (FCM)** for push notifications. HTTPS is required.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Create a Firebase project at [console.firebase.google.com](https://console.firebase.google.com)
|
||||
2. Add a **Web app** → copy the config values into `.env`
|
||||
3. Go to **Project Settings → Cloud Messaging → Web Push certificates** → generate a key pair → copy the public key as `FIREBASE_VAPID_KEY`
|
||||
4. Go to **Project Settings → Service accounts → Generate new private key** → download the JSON → stringify it (remove all newlines) → set as `FIREBASE_SERVICE_ACCOUNT`
|
||||
|
||||
Push notifications are sent for:
|
||||
- New messages in private groups (to all members except the sender)
|
||||
- New messages in public channels (to all subscribers except the sender)
|
||||
- Image messages show as `📷 Image`
|
||||
|
||||
---
|
||||
|
||||
## Help Content
|
||||
|
||||
The Getting Started guide is sourced from `data/help.md`. Edit before running `build.sh` — it is baked into the image at build time.
|
||||
|
||||
```bash
|
||||
nano data/help.md
|
||||
./build.sh 0.13.1
|
||||
```
|
||||
|
||||
- `role` can be `member` or `admin`
|
||||
- `password` defaults to `TempPass@123` if omitted
|
||||
- All imported users must change password on first login
|
||||
Users can access the guide at any time via **User menu → Help**.
|
||||
|
||||
---
|
||||
|
||||
## Data Persistence
|
||||
|
||||
All data is stored in Docker volumes:
|
||||
- `teamchat_db` — SQLite database
|
||||
- `teamchat_uploads` — User avatars, logos, message images
|
||||
| Volume | Container path | Contents |
|
||||
|---|---|---|
|
||||
<<<<<<< HEAD
|
||||
| `rosterchirp_db` | `/var/lib/postgresql/data` | PostgreSQL data directory |
|
||||
=======
|
||||
| `rosterchirp_db` | `/app/data` | SQLite database (`rosterchirp.db`), `help.md` |
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
| `rosterchirp_uploads` | `/app/uploads` | Avatars, logos, PWA icons, message images |
|
||||
|
||||
Data survives container restarts and redeployments.
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# Backup database
|
||||
<<<<<<< HEAD
|
||||
docker compose exec db pg_dump -U rosterchirp rosterchirp | gzip > rosterchirp_db_$(date +%Y%m%d).sql.gz
|
||||
|
||||
# Restore database
|
||||
gunzip -c rosterchirp_db_20240101.sql.gz | docker compose exec -T db psql -U rosterchirp rosterchirp
|
||||
=======
|
||||
docker run --rm \
|
||||
-v rosterchirp_db:/data \
|
||||
-v $(pwd):/backup alpine \
|
||||
tar czf /backup/rosterchirp_db_$(date +%Y%m%d).tar.gz -C /data .
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
|
||||
# Backup uploads
|
||||
docker run --rm \
|
||||
-v rosterchirp_uploads:/data \
|
||||
-v $(pwd):/backup alpine \
|
||||
tar czf /backup/rosterchirp_uploads_$(date +%Y%m%d).tar.gz -C /data .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PWA Installation
|
||||
## Upgrades & Rollbacks
|
||||
|
||||
On mobile: **Share → Add to Home Screen**
|
||||
On desktop (Chrome): Click the install icon in the address bar
|
||||
Database migrations run automatically on startup. There is no manual migration step.
|
||||
|
||||
```bash
|
||||
# Upgrade
|
||||
<<<<<<< HEAD
|
||||
./build.sh 0.13.1
|
||||
# Set ROSTERCHIRP_VERSION=0.13.1 in .env
|
||||
docker compose up -d
|
||||
|
||||
# Rollback
|
||||
# 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
|
||||
```
|
||||
|
||||
Data volumes are untouched in both cases.
|
||||
|
||||
---
|
||||
|
||||
## Portainer / Dockhand Deployment
|
||||
## PWA Icons
|
||||
|
||||
Use the `docker-compose.yaml` directly in Portainer's Stack editor. Set environment variables in the `.env` section or directly in the compose file.
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## ADMPW_RESET Flag
|
||||
|
||||
Resets the **admin account** password to `ADMIN_PASS` on every container restart. Use only when the admin password has been lost.
|
||||
|
||||
```env
|
||||
# Enable for recovery
|
||||
ADMPW_RESET=true
|
||||
|
||||
# Disable after recovering access
|
||||
ADMPW_RESET=false
|
||||
```
|
||||
|
||||
A ⚠️ warning banner is shown on the login page and in Settings when active.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
# Backend (port 3000)
|
||||
cd backend && npm install && npm run dev
|
||||
|
||||
# Frontend (in another terminal)
|
||||
# Frontend (port 5173)
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
Frontend dev server proxies API calls to `localhost:3000`.
|
||||
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
|
||||
|
||||
Proprietary — all rights reserved.
|
||||
|
||||
311
Reference/FCM_IMPLEMENTATION_NOTES.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# FCM PWA Implementation Notes
|
||||
_Reference for applying FCM fixes to other projects_
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Guide Key Points (fcm_details.txt)
|
||||
|
||||
### How FCM works (the correct flow)
|
||||
1. User grants notification permission
|
||||
2. Firebase generates a unique FCM token for the device
|
||||
3. Token is stored on your server for targeting
|
||||
4. Server sends push requests to Firebase
|
||||
5. Firebase delivers notifications to the device
|
||||
6. Service worker handles display and click interactions
|
||||
|
||||
### Common vibe-coding failures with FCM
|
||||
|
||||
**1. Service worker confusion**
|
||||
Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail.
|
||||
|
||||
**2. Deprecated API usage**
|
||||
Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is:
|
||||
```javascript
|
||||
const token = await messaging.getToken({
|
||||
vapidKey: VAPID_KEY,
|
||||
serviceWorkerRegistration: registration
|
||||
});
|
||||
```
|
||||
|
||||
**3. Token generation without durable storage**
|
||||
Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart.
|
||||
|
||||
**4. Poor permission flow**
|
||||
Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit.
|
||||
|
||||
**5. Missing notificationclick handler**
|
||||
Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app.
|
||||
|
||||
**6. Silent failures**
|
||||
Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback.
|
||||
|
||||
**7. iOS blind spots**
|
||||
iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop.
|
||||
|
||||
### Correct `getToken()` pattern (from guide)
|
||||
```javascript
|
||||
// Register SW first, then pass it directly to getToken
|
||||
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
|
||||
const token = await getToken(messaging, {
|
||||
vapidKey: VAPID_KEY,
|
||||
serviceWorkerRegistration: registration
|
||||
});
|
||||
if (!token) throw new Error('getToken() returned empty — check VAPID key and SW');
|
||||
```
|
||||
|
||||
### Correct `firebase-messaging-sw.js` pattern (from guide)
|
||||
```javascript
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
|
||||
|
||||
firebase.initializeApp({ /* config */ });
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
self.registration.showNotification(payload.notification.title, {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-notification',
|
||||
data: payload.data
|
||||
});
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
if (event.action === 'close') return;
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url === '/' && 'focus' in client) return client.focus();
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Code Fixes Applied to fcm-app
|
||||
|
||||
### app.js fixes
|
||||
|
||||
**Fix: `showUserInfo()` missing**
|
||||
Function was called on login and session restore but never defined — crashed immediately on login.
|
||||
```javascript
|
||||
function showUserInfo() {
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('userInfo').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `setupApp()` wrong element IDs**
|
||||
`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML.
|
||||
```javascript
|
||||
// Wrong
|
||||
document.getElementById('sendNotification').addEventListener('click', sendNotification);
|
||||
// Fixed
|
||||
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
|
||||
// Also added id="logoutBtn" to the logout button in index.html
|
||||
```
|
||||
|
||||
**Fix: `logout()` not clearing localStorage**
|
||||
Session was restored on next page load even after logout.
|
||||
```javascript
|
||||
function logout() {
|
||||
currentUser = null;
|
||||
fcmToken = null;
|
||||
localStorage.removeItem('currentUser'); // was missing
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Race condition in messaging initialization**
|
||||
`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise:
|
||||
```javascript
|
||||
let initPromise = null;
|
||||
function initializeFirebase() {
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
swRegistration = registration;
|
||||
messaging = firebase.messaging();
|
||||
})
|
||||
.catch((error) => { initPromise = null; throw error; });
|
||||
return initPromise;
|
||||
}
|
||||
// In login():
|
||||
await initializeFirebase(); // ensures messaging is ready before getToken()
|
||||
```
|
||||
|
||||
**Fix: `deleteToken()` invalidating tokens on every page load**
|
||||
`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0.
|
||||
Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`.
|
||||
|
||||
**Fix: Session restore without re-registering token**
|
||||
When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token.
|
||||
```javascript
|
||||
// In setupApp(), after restoring session:
|
||||
if (Notification.permission === 'granted') {
|
||||
initializeFirebase()
|
||||
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
|
||||
.then(token => { if (token) return registerToken(currentUser, token); })
|
||||
.catch(err => console.error('Token refresh on session restore failed:', err));
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Deprecated VAPID/SW API replaced**
|
||||
```javascript
|
||||
// Removed (deprecated):
|
||||
messaging.usePublicVapidKey(VAPID_KEY);
|
||||
messaging.useServiceWorker(registration);
|
||||
const token = await messaging.getToken();
|
||||
|
||||
// Replaced with:
|
||||
const VAPID_KEY = 'your-vapid-key';
|
||||
let swRegistration = null;
|
||||
// swRegistration set inside initializeFirebase() .then()
|
||||
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
|
||||
```
|
||||
|
||||
**Fix: Null token guard**
|
||||
`getToken()` can return null — passing null to the server produced a confusing 400 error.
|
||||
```javascript
|
||||
if (!token) {
|
||||
throw new Error('getToken() returned empty — check VAPID key and service worker');
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Error message included server response**
|
||||
```javascript
|
||||
// Before: throw new Error('Failed to register token');
|
||||
// After:
|
||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||
```
|
||||
|
||||
**Fix: Duplicate foreground message handlers**
|
||||
`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners.
|
||||
```javascript
|
||||
let foregroundHandlerSetup = false;
|
||||
function handleForegroundMessages() {
|
||||
if (foregroundHandlerSetup) return;
|
||||
foregroundHandlerSetup = true;
|
||||
messaging.onMessage(/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `login()` event.preventDefault() crash**
|
||||
Button called `login()` with no argument, so `event.preventDefault()` threw on undefined.
|
||||
```javascript
|
||||
async function login(event) {
|
||||
if (event) event.preventDefault(); // guard added
|
||||
```
|
||||
|
||||
**Fix: `firebase-messaging-sw.js` redirect stub replaced**
|
||||
File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above).
|
||||
|
||||
**Fix: `notificationclick` handler added to `sw.js`**
|
||||
Clicking a background notification did nothing. Handler added to focus existing window or open a new one.
|
||||
|
||||
**Fix: CDN URLs removed from `urlsToCache` in `sw.js`**
|
||||
External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install.
|
||||
```javascript
|
||||
// Removed from urlsToCache:
|
||||
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js',
|
||||
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'
|
||||
```
|
||||
|
||||
### server.js fixes
|
||||
|
||||
**Fix: `icon`/`badge`/`tag` in wrong notification object**
|
||||
These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`).
|
||||
```javascript
|
||||
// Wrong:
|
||||
notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }
|
||||
// Fixed:
|
||||
notification: { title, body },
|
||||
webpush: {
|
||||
notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' },
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `saveTokens()` in route handler not crash-safe**
|
||||
```javascript
|
||||
try {
|
||||
saveTokens();
|
||||
} catch (saveError) {
|
||||
console.error('Failed to persist tokens to disk:', saveError);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `setInterval(saveTokens)` uncaught exception crashed the server**
|
||||
An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state.
|
||||
```javascript
|
||||
setInterval(() => {
|
||||
try { saveTokens(); }
|
||||
catch (error) { console.error('Auto-save tokens failed:', error); }
|
||||
}, 30000);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Docker / Infrastructure Fixes
|
||||
|
||||
### Root cause of "no other users" bug
|
||||
The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain:
|
||||
1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`)
|
||||
2. This propagated out of `setInterval` as an uncaught exception
|
||||
3. Node.js exited the process
|
||||
4. Docker restarted the container with empty state
|
||||
5. Tokens were never on disk, so restart = all tokens lost
|
||||
|
||||
### Dockerfile fix
|
||||
```dockerfile
|
||||
# Create non-root user AND a writable data directory (while still root)
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
mkdir -p /app/data && \
|
||||
chown nodejs:nodejs /app/data
|
||||
```
|
||||
`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it.
|
||||
|
||||
### docker-compose.yml fix
|
||||
```yaml
|
||||
services:
|
||||
your-app:
|
||||
volumes:
|
||||
- app_data:/app/data # named volume survives container rebuilds
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
```
|
||||
Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`.
|
||||
|
||||
### server.js path fix
|
||||
```javascript
|
||||
// Changed from:
|
||||
const TOKENS_FILE = './tokens.json';
|
||||
// To:
|
||||
const TOKENS_FILE = './data/tokens.json';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist for applying to another project
|
||||
|
||||
- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub)
|
||||
- [ ] `notificationclick` handler present in service worker
|
||||
- [ ] CDN URLs NOT in `urlsToCache` in any service worker
|
||||
- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()`
|
||||
- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker`
|
||||
- [ ] `deleteToken()` is NOT called on page load
|
||||
- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'`
|
||||
- [ ] Null/empty token check before sending to server
|
||||
- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification`
|
||||
- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval`
|
||||
- [ ] Docker: data directory created with correct user ownership in Dockerfile
|
||||
- [ ] Docker: named volume mounted for data directory in docker-compose.yml
|
||||
- [ ] Duplicate foreground message handler registration is guarded
|
||||
31
Reference/FUTURE_FEATURES.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# RosterChirp — Future Feature Requests
|
||||
|
||||
---
|
||||
|
||||
## Markdown Message Rendering
|
||||
|
||||
**Request:** Render markdown-formatted text in the message window.
|
||||
|
||||
**Scope recommendation:** Start with inline-only markdown (bold, italic, inline code, strikethrough, links). Full block-level markdown (headers, lists, tables, code blocks) is a follow-on.
|
||||
|
||||
### Why inline-only first
|
||||
- Zero risk of misformatting existing plain-text messages
|
||||
- Full markdown risks rendering existing content oddly (e.g. a message starting with `1.` or `#` rendering as a list/header)
|
||||
|
||||
### Implementation notes
|
||||
- Integration point is `formatMsgContent()` in `Message.jsx` — already returns HTML for `dangerouslySetInnerHTML`; a markdown parser slots straight in
|
||||
- Add `marked` npm package (~13KB) for parsing
|
||||
- Add `DOMPurify` for XSS sanitization — **required**, markdown allows raw HTML passthrough and content comes from other users
|
||||
- `marked` config: `{ breaks: true }` to preserve single-newline-as-`<br>` behaviour, `{ mangle: false, headerIds: false }` to suppress heading anchors
|
||||
- Apply markdown parse first, then @mention substitution (so `**@[Name]**` renders correctly)
|
||||
- Remove the existing URL regex linkifier in `formatMsgContent` — markdown handles links natively
|
||||
- Strip markdown from reply previews (currently shows raw `**text**`)
|
||||
- `handleCopy` copies `msg.content` (raw markdown source) — correct behaviour, no change needed
|
||||
- Emoji-only detection runs on raw content before rendering — no change needed
|
||||
- Compose box stays plain textarea for v1; no preview toolbar required
|
||||
|
||||
### Effort estimate
|
||||
| Scope | Estimate |
|
||||
|---|---|
|
||||
| Inline-only (bold, italic, code, strikethrough) | ~1.5 hours |
|
||||
| Full markdown (+ block elements, CSS for bubbles) | ~4–5 hours |
|
||||
31
Reference/KNOWN_LIMITATIONS.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# RosterChirp — Known Limitations
|
||||
|
||||
## Android Background Push Notifications
|
||||
|
||||
**Status:** Known limitation — deferred
|
||||
**Affects:** Android Chrome browser and Android PWA installs
|
||||
**Does not affect:** Desktop browsers, iOS PWA (iOS 16.4+)
|
||||
|
||||
### Symptom
|
||||
Push notifications are not delivered when the RosterChirp PWA or Chrome browser loses focus on Android. The app also disconnects from the WebSocket (real-time chat) when backgrounded. Notifications only arrive while the app is open and in the foreground.
|
||||
|
||||
### Root Cause
|
||||
Android's battery optimization system (Doze mode + App Standby) aggressively kills background network connections for browsers. This affects two things:
|
||||
|
||||
1. **WebSocket** — the socket.io connection is dropped when Chrome/PWA loses focus, stopping real-time updates until the user returns to the app.
|
||||
2. **Web Push** — RosterChirp uses the standard Web Push API with VAPID keys. Android throttles or blocks push delivery at the system level even when battery settings are set to "No restrictions", because that setting only controls the app's own background activity — it does not exempt the browser's push service socket.
|
||||
|
||||
Setting Chrome or the PWA to "No battery restrictions" in Android settings does **not** resolve this.
|
||||
|
||||
### Why FCM Would Fix It
|
||||
Firebase Cloud Messaging (FCM) maintains a privileged persistent connection that Android explicitly exempts from Doze mode. This is the only reliable mechanism for background push delivery on Android. All major Android messaging apps (WhatsApp, Telegram, Signal) use FCM or a vendor-equivalent for this reason.
|
||||
|
||||
### Future Fix Plan
|
||||
1. Create a Firebase project (free tier is sufficient)
|
||||
2. Add Firebase config to `.env` and `sw.js`
|
||||
3. Replace `web-push` subscription flow with Firebase SDK — VAPID keys are reusable so existing subscriptions can be migrated
|
||||
4. Switch backend notification dispatch from `web-push` to `firebase-admin`
|
||||
5. Address WebSocket reconnect-on-focus separately (frontend only, no Firebase needed)
|
||||
|
||||
### Workaround for Users
|
||||
None at the app level. Users who need reliable Android notifications should keep the RosterChirp PWA pinned/open, or check their Android vendor's specific battery exemption settings (Samsung DeX, MIUI, etc. have additional per-app exemption controls beyond the standard Android settings).
|
||||
1013
Reference/fcm_details.txt
Normal file
63
Reference/minor-age-protection.txt
Normal file
@@ -0,0 +1,63 @@
|
||||
RULE: minor age is when DOB is 15 years and under.
|
||||
|
||||
User manager:
|
||||
Enable "Date of Birth" field (default optional unless "Mixed Age" is selected in the (new) as "Login Type" on Settings modal page)
|
||||
Enable "Guardian" field as optional
|
||||
|
||||
My Profile modal:
|
||||
Change: replace tab buttons with a select list labled "SELECT OPTION:" Profile - default selected (on both desktop and mobile)
|
||||
On the "Profile" tab, add two new text inputs: Date of Birth and Phone (same format field verification as used in the user manager field) - once saved, user manager is updated with data from these two fields for the given user
|
||||
Add a new select option "Add Child" (only displayed IF the new "Login Type" setting has either "Guardians Only" or "Mixed Age" selected.
|
||||
|
||||
"Add Child" Form:
|
||||
- (on user's my profile): Firstname, Lastname, Email, DOB, Profile avatar (follow avatar upload rules), number (input box), Add button, Save button (disabled until there is a child added to guardian's child list)
|
||||
- Clicking the Add button will add child to the guardians child list.)
|
||||
- Clicking save, will save the the guardian's child list and will each child entry to players user group.
|
||||
|
||||
Settings modal:
|
||||
Change: replace tab tabs buttons to a select list, labelled "SELECT OPTION" Messages is still the default option (on both desktop and mobile)
|
||||
Add new selection "Login Type" to the list, form details below:
|
||||
|
||||
An option list with brief description under:
|
||||
Option: "Guardian Only"
|
||||
Descriptions: "Parents are required to add their child's details in their profile. They will respond on behalf of the child for events with availability tracking for the "players" group".
|
||||
|
||||
- User manager DOB is optional
|
||||
- "Players" user group entry will be the child's name as an alias to the guardians account, if more than one child each entry will be treated uniquely for mentions and event availabillty responses.
|
||||
- "Players" User Group DM will be disabled/hidden and cannot be added to Multi-Group DMs
|
||||
- The event modal with Availabilty requested for the "players" user group, will have a new drop down select list (under the response buttons, above the note input box - hidden/disabled by default) and will only be unhidden/enable if the event includes the "players" user group, and only displayed for user who have a saved child list of at least one. The select list options will include the guardians child list, AND will also the guardian's name IF multiple user groups are selected for the event, with one be players, and at least one being a user group that the guardian is a member of that is not the players group. There will be a default option of "ALL", if selected, the response and note (if entered) will be the same for all "people" listed in the select list, but responses will be listed indivually in the response list for the event.
|
||||
|
||||
Scenario 1: Event: party, track availability enabled, groups selected: players + parents
|
||||
- select drop down will display option: "All" default selected, then guadians name on row 2 (parent user group), each child's name on susequent rows (listed in the players user group owned by the guardian)
|
||||
- selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each
|
||||
- selecting an idividual name will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list
|
||||
Scenario 2: Event, track availability enabled, groups selected: players
|
||||
- select drop down will display "All" on the top row, each child's name (listed in the players user group and owned by the guardian)
|
||||
- selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each
|
||||
- selecting an idividual name in the selectlist will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list
|
||||
Scenario 3: Event, track availability enabled, groups selected: parents
|
||||
- select drop down is hidden
|
||||
- availability response and note is for the guardian only
|
||||
|
||||
Option: "Mixed Age"
|
||||
Descriptions: "Parents, or user managers, are required to add the minor aged child's user account to the guardians user profile. Minor aged users cannot login until this is complete."
|
||||
- User manager DOB is required for all users
|
||||
- Minor aged user's account are automatically suspended
|
||||
on the add child form:
|
||||
- search user input field, will display only a list of minor aged users. Selecting a user fills out the "Add Child" read-only form
|
||||
- "Add Child" Form all fields are read-only: Firstname, Lastname, DOB*, user's avatar, number (input box), Add button when form is filled by selecting a user, Save button (disabled until there is a child added to guardian's child list). Requires admin approval. A message is sent to all users with the managers from from the default admin user account indicating "User Manager requires approval".
|
||||
- The minor user account name that requires gaurdina name approval is bold red in the user manager list. When the "editing" user account, the Gaurdian field will be highlighted in red. Besides the label will be two link options [approve - green] [deny - red] (same size font as the label). Approve will clear the approval required flag and unsuspend the user account, with the guardian name save to the user's profile.
|
||||
- Saving the form will update the minor aged user account with a guardian name, requires a user with manager role approval, once approved, the minor's user account is unsuspended.
|
||||
- Events are handled like they are currently
|
||||
- Guardians are not part of players user group DMs
|
||||
- Guardians do not respond to availability on the childs behalf (new select list on the event form remains hidden/diabled)
|
||||
- any private messages initiated by a user 18 years or older to a minor aged will automatically include the guardian user account as well.
|
||||
-> a modal confirmation will be provide to the 18+ user that they are messaging a user that will also include their parent/guardian.
|
||||
|
||||
Option: All Ages (default selection)
|
||||
- "Add Child" hidden/disbled Profile select option list.
|
||||
- Aliase select list is hidden/disabled
|
||||
- Events are handled like they are currently
|
||||
|
||||
Yellow warning symbol and text below: "This setting can only be set/changed when the user table in the database is empty." (form is read-only unless user manager is empty OR only has users with the admin role).
|
||||
- if the setting is changed and users exisit with admin role, on each subsequent login the user's profile modal popup until the Date of Birth is entered.
|
||||
77
Reference/sw.js.gemini.txt
Normal file
@@ -0,0 +1,77 @@
|
||||
Gemini said
|
||||
I’ve reviewed your Service Worker code. It’s well-structured, but there is a critical architectural "race condition" occurring here that is likely causing the "hit or miss" behavior on mobile.
|
||||
|
||||
On a desktop, the browser keeps the Service Worker "warm" or gives it plenty of time to boot. On mobile (especially Android), the OS is much more aggressive.
|
||||
|
||||
1. The onBackgroundMessage vs. event.waitUntil Problem
|
||||
In your Firebase Messaging block:
|
||||
|
||||
JavaScript
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
// ... logic ...
|
||||
return showRosterChirpNotification({...});
|
||||
});
|
||||
The Flaw: The Firebase onBackgroundMessage handler is a callback, but it is not being awaited by the Service Worker's underlying push event.
|
||||
|
||||
When a push arrives, the browser fires a native push event. The Firebase SDK intercepts this and triggers your onBackgroundMessage. However, because you aren't explicitly telling the browser to "stay awake" while Firebase does its thing, the mobile OS often kills the Service Worker thread before the Firebase SDK even finishes parsing the message or before showRosterChirpNotification completes.
|
||||
|
||||
2. The "Double Listener" Conflict
|
||||
You have both messaging.onBackgroundMessage and a manual self.addEventListener('push').
|
||||
|
||||
You added a check if (messaging) return; inside the push listener to prevent double-firing.
|
||||
|
||||
The Catch: In 2026, the firebase-messaging-compat SDK actually needs that native push event to stay alive. By returning early, you might be accidentally telling the browser "I'm done here," allowing it to terminate the process before the Firebase logic finishes.
|
||||
|
||||
The Fix: "Promisifying" the Background Handler
|
||||
To fix the reliability on mobile, you need to ensure the native push event wraps the Firebase logic in a Promise.
|
||||
|
||||
Modify your code to look like this:
|
||||
|
||||
JavaScript
|
||||
// 1. Remove the return showRosterChirpNotification from onBackgroundMessage
|
||||
// 2. Wrap the logic in the native 'push' event to guarantee execution.
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push received');
|
||||
|
||||
// We use event.waitUntil to tell the Mobile OS: "Don't kill me yet!"
|
||||
event.waitUntil(
|
||||
// If Firebase is ready, we let it process, but we must make sure
|
||||
// we return a promise that resolves only AFTER the notification shows.
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
// You can still use Firebase logic, but ensure it's awaited.
|
||||
// Or, for maximum mobile reliability, parse the event.data directly:
|
||||
const payload = event.data ? event.data.json() : null;
|
||||
|
||||
if (payload) {
|
||||
// Manually handle the notification display here to ensure
|
||||
// the 'waitUntil' scope covers it.
|
||||
return showRosterChirpNotification({
|
||||
title: payload.notification?.title || payload.data?.title || 'New Message',
|
||||
body: payload.notification?.body || payload.data?.body || '',
|
||||
url: payload.data?.url || '/',
|
||||
groupId: payload.data?.groupId || ''
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SW] Push processing failed', err);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
3. Why Desktop works but Mobile fails
|
||||
Desktop: The browser process is persistent. It waits for the Firebase callback to finish even if you don't use event.waitUntil.
|
||||
|
||||
Mobile: The moment the push event function finishes executing (which happens instantly if it's just a bunch of if statements and callbacks), the OS is allowed to freeze the thread.
|
||||
|
||||
4. HAProxy / PFSense Check (The "Vary" Header)
|
||||
Since you are using importScripts for Firebase:
|
||||
If HAProxy is caching your sw.js but serving it with a content-type or encoding that makes the mobile browser hesitate, it will fail to update. Ensure HAProxy isn't stripping the Service-Worker-Allowed header if you've set one, and make sure your server sends Cache-Control: no-cache for the sw.js file specifically.
|
||||
|
||||
Summary of adjustments:
|
||||
Remove the if (messaging) return; from your push listener.
|
||||
|
||||
Consolidate your logic inside the push listener using event.waitUntil().
|
||||
|
||||
Check Firebase Console: Ensure the messages you send from your server are marked as High Priority (priority: 'high'), otherwise mobile devices will "batch" them and they'll arrive 10-20 minutes late.
|
||||
880
Reference/vibecode-prompt.md
Normal file
@@ -0,0 +1,880 @@
|
||||
# RosterChirp — Complete Vibe-Code Build Prompt (v0.13.1)
|
||||
|
||||
> **How to use this document**
|
||||
> Paste the contents of any single section (or the whole document) as your opening prompt when starting a new AI coding session. The more context you give upfront, the fewer clarifying rounds you need. This document reflects the real production build of RosterChirp as of v0.12.53.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — What to Build (Product Brief)
|
||||
|
||||
Build a **self-hosted team chat Progressive Web App** called **RosterChirp**.
|
||||
|
||||
It is a full-stack, single-container application that runs entirely inside Docker. Users install it on a private server and access it via a browser or as an installed PWA on desktop/mobile. It supports two deployment modes:
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
|
||||
| `host` | Multi-tenant — one Postgres schema per tenant, provisioned at `{slug}.{HOST_DOMAIN}`. |
|
||||
|
||||
### Core philosophy
|
||||
- Simple to self-host (one `docker compose up`)
|
||||
- Works as an installed PWA on Android, iOS, and desktop Chrome/Edge
|
||||
- Instant real-time messaging via WebSockets (Socket.io)
|
||||
- Push notifications via Firebase Cloud Messaging (FCM), works when app is backgrounded
|
||||
- Multi-tenant via Postgres schema isolation (not separate databases)
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend runtime | Node.js 20 (Alpine) |
|
||||
| Backend framework | Express.js |
|
||||
| Real-time | Socket.io (server + client) |
|
||||
| Database | PostgreSQL 16 via `pg` npm package |
|
||||
| Auth | JWT in HTTP-only cookies + localStorage, `jsonwebtoken`, `bcryptjs` |
|
||||
| Push notifications | Firebase Cloud Messaging (FCM) — Firebase Admin SDK (backend) + Firebase JS SDK (frontend) |
|
||||
| Image processing | `sharp` (avatar/logo resizing) |
|
||||
| Frontend framework | React 18 + Vite (PWA) |
|
||||
| Frontend styling | Plain CSS with CSS custom properties (no Tailwind, no CSS modules) |
|
||||
| Emoji picker | `@emoji-mart/react` + `@emoji-mart/data` |
|
||||
| Markdown rendering | `marked` (for help modal) |
|
||||
| Container | Docker multi-stage build (builder stage for Vite, runtime stage for Node) |
|
||||
| Orchestration | `docker-compose.yaml` (selfhost) + `docker-compose.host.yaml` (multi-tenant) |
|
||||
| Reverse proxy | Caddy (SSL termination) |
|
||||
|
||||
### Key npm packages (backend)
|
||||
```
|
||||
express, socket.io, pg, bcryptjs, jsonwebtoken,
|
||||
cookie-parser, cors, multer, sharp,
|
||||
firebase-admin, node-fetch
|
||||
```
|
||||
|
||||
### Key npm packages (frontend)
|
||||
```
|
||||
react, react-dom, vite, socket.io-client,
|
||||
@emoji-mart/react, @emoji-mart/data, marked,
|
||||
firebase
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Project File Structure
|
||||
|
||||
```
|
||||
rosterchirp/
|
||||
├── CLAUDE.md
|
||||
├── Dockerfile
|
||||
├── build.sh # VERSION="${1:-X.Y.Z}" — bump here + both package.json files
|
||||
├── docker-compose.yaml # selfhost
|
||||
├── docker-compose.host.yaml # multi-tenant host mode
|
||||
├── Caddyfile.example
|
||||
├── .env.example
|
||||
├── about.json.example
|
||||
├── backend/
|
||||
│ ├── package.json # version bump required
|
||||
│ └── src/
|
||||
│ ├── index.js # Express app, Socket.io, tenant middleware wiring
|
||||
│ ├── middleware/
|
||||
│ │ └── auth.js # JWT auth, teamManagerMiddleware
|
||||
│ ├── models/
|
||||
│ │ ├── db.js # Postgres pool, query helpers, migrations, seeding
|
||||
│ │ └── migrations/ # 001–008 SQL files, auto-applied on startup
|
||||
│ └── routes/
|
||||
│ ├── auth.js # receives io
|
||||
│ ├── groups.js # receives io
|
||||
│ ├── messages.js # receives io
|
||||
│ ├── usergroups.js # receives io
|
||||
│ ├── schedule.js # receives io
|
||||
│ ├── users.js
|
||||
│ ├── settings.js
|
||||
│ ├── push.js
|
||||
│ ├── host.js # RosterChirp-Host control plane only
|
||||
│ ├── about.js
|
||||
│ └── help.js
|
||||
└── frontend/
|
||||
├── package.json # version bump required
|
||||
├── vite.config.js
|
||||
├── index.html # viewport: user-scalable=no (pinch handled via JS)
|
||||
├── public/
|
||||
│ ├── manifest.json
|
||||
│ ├── sw.js # service worker / FCM push
|
||||
│ └── icons/
|
||||
└── src/
|
||||
├── App.jsx
|
||||
├── main.jsx # pinch→font-scale handler, pull-to-refresh blocker, iOS keyboard fix
|
||||
├── index.css # CSS vars, dark mode, --font-scale, mobile autofill fixes
|
||||
├── contexts/
|
||||
│ ├── AuthContext.jsx
|
||||
│ ├── SocketContext.jsx # force transports: ['websocket']
|
||||
│ └── ToastContext.jsx
|
||||
├── pages/
|
||||
│ ├── Chat.jsx # main shell, page routing, all socket wiring
|
||||
│ ├── Login.jsx
|
||||
│ ├── ChangePassword.jsx
|
||||
│ ├── UserManagerPage.jsx
|
||||
│ └── GroupManagerPage.jsx
|
||||
└── components/
|
||||
├── Sidebar.jsx # groupMessagesMode prop
|
||||
├── ChatWindow.jsx
|
||||
├── MessageInput.jsx # onTextChange prop, fixed font size (no --font-scale)
|
||||
├── Message.jsx # fonts scaled via --font-scale
|
||||
├── NavDrawer.jsx
|
||||
├── SchedulePage.jsx # ~1600 lines, desktop+mobile views
|
||||
├── MobileEventForm.jsx
|
||||
├── Avatar.jsx # consistent colour algorithm — must match Sidebar + ChatWindow
|
||||
├── PasswordInput.jsx
|
||||
├── GroupInfoModal.jsx
|
||||
├── ProfileModal.jsx # appearance tab: font-scale slider (saved), pinch is session-only
|
||||
├── SettingsModal.jsx
|
||||
├── BrandingModal.jsx
|
||||
├── HostPanel.jsx
|
||||
├── NewChatModal.jsx
|
||||
├── UserFooter.jsx
|
||||
├── GlobalBar.jsx
|
||||
├── ImageLightbox.jsx
|
||||
├── UserProfilePopup.jsx
|
||||
├── AddChildAliasModal.jsx
|
||||
├── ScheduleManagerModal.jsx
|
||||
├── ColourPickerSheet.jsx
|
||||
└── SupportModal.jsx
|
||||
```
|
||||
|
||||
### Dead code (safe to delete)
|
||||
- `frontend/src/pages/HostAdmin.jsx`
|
||||
- `frontend/src/components/UserManagerModal.jsx`
|
||||
- `frontend/src/components/GroupManagerModal.jsx`
|
||||
- `frontend/src/components/MobileGroupManager.jsx`
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Database Architecture
|
||||
|
||||
### Connection pool (`db.js`)
|
||||
```javascript
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'rosterchirp',
|
||||
user: process.env.DB_USER || 'rosterchirp',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
```
|
||||
|
||||
### Query helpers
|
||||
```javascript
|
||||
query(schema, sql, params) // SET search_path then SELECT
|
||||
queryOne(schema, sql, params) // returns first row or null
|
||||
queryResult(schema, sql, params) // returns full result object
|
||||
exec(schema, sql, params) // INSERT/UPDATE/DELETE
|
||||
withTransaction(schema, async (client) => { ... })
|
||||
```
|
||||
|
||||
`SET search_path TO {schema}` is called before every query. `assertSafeSchema(name)` validates all schema names against `/^[a-z_][a-z0-9_]*$/`.
|
||||
|
||||
### Migrations
|
||||
Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` are applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
|
||||
|
||||
Current migrations: 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users) → 007 (FCM push) → 008 (rebrand)
|
||||
|
||||
### Seeding order
|
||||
`seedSettings → seedEventTypes → seedAdmin → seedUserGroups`
|
||||
|
||||
All seed functions use `ON CONFLICT DO NOTHING`.
|
||||
|
||||
### Core tables (PostgreSQL — schema-qualified at query time)
|
||||
|
||||
```sql
|
||||
users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||||
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'suspended'
|
||||
is_default_admin BOOLEAN DEFAULT FALSE,
|
||||
must_change_password BOOLEAN DEFAULT TRUE,
|
||||
avatar TEXT,
|
||||
about_me TEXT,
|
||||
display_name TEXT,
|
||||
hide_admin_tag BOOLEAN DEFAULT FALSE,
|
||||
allow_dm INTEGER DEFAULT 1,
|
||||
last_online TIMESTAMPTZ,
|
||||
help_dismissed BOOLEAN DEFAULT FALSE,
|
||||
date_of_birth DATE,
|
||||
phone TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'public',
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
is_readonly BOOLEAN DEFAULT FALSE,
|
||||
is_direct BOOLEAN DEFAULT FALSE,
|
||||
is_managed BOOLEAN DEFAULT FALSE, -- managed private groups (Group Messages mode)
|
||||
direct_peer1_id INTEGER,
|
||||
direct_peer2_id INTEGER,
|
||||
track_availability BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
group_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(group_id, user_id)
|
||||
)
|
||||
|
||||
messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT,
|
||||
type TEXT DEFAULT 'text', -- 'text' | 'system'
|
||||
image_url TEXT,
|
||||
reply_to_id INTEGER REFERENCES messages(id),
|
||||
is_deleted BOOLEAN DEFAULT FALSE,
|
||||
is_readonly BOOLEAN DEFAULT FALSE,
|
||||
link_preview JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
reactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
)
|
||||
|
||||
notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
message_id INTEGER,
|
||||
group_id INTEGER,
|
||||
from_user_id INTEGER,
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
active_sessions (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device TEXT NOT NULL DEFAULT 'desktop', -- 'mobile' | 'desktop'
|
||||
token TEXT NOT NULL,
|
||||
ua TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, device)
|
||||
)
|
||||
-- One session per device type per user. New login on same device displaces old session.
|
||||
-- Displaced socket receives 'session:displaced' event.
|
||||
|
||||
push_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device TEXT DEFAULT 'desktop',
|
||||
fcm_token TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, device)
|
||||
)
|
||||
|
||||
settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
-- Feature flag keys: feature_branding ('true'/'false'), feature_group_manager,
|
||||
-- feature_schedule_manager, app_type ('RosterChirp-Chat'/'RosterChirp-Brand'/'RosterChirp-Team')
|
||||
|
||||
user_group_names (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
)
|
||||
|
||||
user_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
colour TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
user_group_members (
|
||||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_group_id, user_id)
|
||||
)
|
||||
|
||||
group_user_groups (
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (group_id, user_group_id)
|
||||
)
|
||||
|
||||
events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
all_day BOOLEAN DEFAULT FALSE,
|
||||
is_public BOOLEAN DEFAULT TRUE,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
event_type_id INTEGER REFERENCES event_types(id),
|
||||
recurrence_rule JSONB,
|
||||
track_availability BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
event_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
colour TEXT NOT NULL DEFAULT '#1a73e8',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
event_availability (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL, -- 'going' | 'maybe' | 'not_going'
|
||||
note TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(event_id, user_id)
|
||||
)
|
||||
|
||||
tenants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
schema_name TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
custom_domain TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
plan TEXT DEFAULT 'team',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Multi-Tenant Architecture (Host Mode)
|
||||
|
||||
### Tenant resolution
|
||||
`tenantMiddleware` in `index.js` sets `req.schema` from the HTTP `Host` header before any route runs:
|
||||
|
||||
```javascript
|
||||
// Subdomain tenants: {slug}.{HOST_DOMAIN} → schema 'tenant_{slug}'
|
||||
// Custom domains: looked up in tenants table custom_domain column
|
||||
// Host admin: HOST_DOMAIN itself → schema 'public'
|
||||
const tenantDomainCache = new Map(); // in-process cache, cleared on tenant update
|
||||
```
|
||||
|
||||
### Socket room naming (tenant-isolated)
|
||||
All socket rooms are prefixed with the tenant schema:
|
||||
```javascript
|
||||
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||||
// e.g. R('tenant_acme', 'group', 42) → 'tenant_acme:group:42'
|
||||
// Room types: group:{id}, user:{id}, schema:all
|
||||
```
|
||||
|
||||
### Online user tracking
|
||||
```javascript
|
||||
const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
|
||||
// Key includes schema to prevent cross-tenant ID collisions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 6 — Auth & Session System
|
||||
|
||||
- JWT stored in **HTTP-only cookie** (`token`) AND `localStorage` (for PWA/mobile fallback)
|
||||
- `authMiddleware` in `middleware/auth.js` — verifies JWT, attaches `req.user`
|
||||
- `teamManagerMiddleware` — checks if user is a team manager (role-based feature access)
|
||||
- **Per-device sessions**: `active_sessions` PK is `(user_id, device)` — logging in on mobile doesn't kick out desktop
|
||||
- Device: `mobile` if `/Mobile|Android|iPhone/i.test(ua)`, else `desktop`
|
||||
- `must_change_password = true` redirects to `/change-password` after login
|
||||
- `ADMPW_RESET=true` env var resets default admin password on container start
|
||||
|
||||
---
|
||||
|
||||
## Part 7 — Real-time Architecture (Socket.io)
|
||||
|
||||
### Connection
|
||||
- Socket auth: JWT in `socket.handshake.auth.token`
|
||||
- On connect: user joins `R(schema, 'group', id)` for all their groups, and `R(schema, 'user', userId)` for direct notifications, and `R(schema, 'schema', 'all')` for tenant-wide broadcasts
|
||||
|
||||
### Routes that receive `io`
|
||||
```javascript
|
||||
// All of these are called as: require('./routes/foo')(io)
|
||||
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
|
||||
```
|
||||
|
||||
### Key socket events (server → client)
|
||||
| Event | When |
|
||||
|---|---|
|
||||
| `message:new` | new message in a group |
|
||||
| `message:deleted` | soft delete |
|
||||
| `reaction:updated` | reaction toggled |
|
||||
| `typing:start` / `typing:stop` | typing indicator |
|
||||
| `notification:new` | mention or private message |
|
||||
| `group:updated` | group settings changed |
|
||||
| `group:removed` | user removed from group |
|
||||
| `user:online` / `user:offline` | presence change |
|
||||
| `users:online` | full online user list (on request) |
|
||||
| `session:displaced` | same device logged in elsewhere |
|
||||
| `schedule:event-created/updated/deleted` | schedule changes |
|
||||
|
||||
### Reconnect strategy (SocketContext.jsx)
|
||||
```javascript
|
||||
const socket = io({ transports: ['websocket'] }); // websocket only — no polling
|
||||
// reconnectionDelay: 500, reconnectionDelayMax: 3000, timeout: 8000
|
||||
// visibilitychange → visible: call socket.connect() if disconnected
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 8 — FCM Push Notifications
|
||||
|
||||
### Architecture
|
||||
```
|
||||
Frontend (browser/PWA)
|
||||
└─ Chat.jsx
|
||||
├─ GET /api/push/firebase-config → fetches SDK config
|
||||
├─ Initialises Firebase JS SDK + getMessaging()
|
||||
├─ getToken(messaging, { vapidKey }) → FCM token
|
||||
└─ POST /api/push/subscribe → registers in push_subscriptions
|
||||
|
||||
Backend (push.js)
|
||||
├─ sendPushToUser(schema, userId, payload) → called from messages.js (primary)
|
||||
│ and index.js socket handler (fallback)
|
||||
└─ Firebase Admin SDK → Google FCM servers → device
|
||||
```
|
||||
|
||||
### Message payload
|
||||
```javascript
|
||||
{
|
||||
token: sub.fcm_token,
|
||||
notification: { title, body },
|
||||
data: { url: '/', groupId: '42' },
|
||||
android: { priority: 'high', notification: { sound: 'default' } },
|
||||
webpush: { headers: { Urgency: 'high' }, fcm_options: { link: url } },
|
||||
}
|
||||
```
|
||||
|
||||
### Push trigger logic (messages.js)
|
||||
- Frontend sends messages via `POST /api/messages/group/:groupId` (REST), not socket
|
||||
- **Push must be fired from messages.js**, not just socket handler
|
||||
- Private group: push to all `group_members` except sender
|
||||
- Public group: push to all `DISTINCT user_id FROM push_subscriptions WHERE user_id != sender`
|
||||
- Image messages: body `'📷 Image'`
|
||||
|
||||
### Stale token cleanup
|
||||
`sendPushToUser` catches FCM errors and deletes the `push_subscriptions` row for:
|
||||
`messaging/registration-token-not-registered`, `messaging/invalid-registration-token`, `messaging/invalid-argument`
|
||||
|
||||
### Required env vars
|
||||
```
|
||||
FIREBASE_API_KEY=
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_APP_ID=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_VAPID_KEY= # Web Push certificate public key (Cloud Messaging tab)
|
||||
FIREBASE_SERVICE_ACCOUNT= # Full service account JSON, stringified (backend only)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 9 — API Routes
|
||||
|
||||
All routes require `authMiddleware` except login/health.
|
||||
|
||||
### Auth (`/api/auth`)
|
||||
- `POST /login`, `POST /logout`, `POST /change-password`, `GET /me`
|
||||
|
||||
### Users (`/api/users`)
|
||||
- `GET /` — admin: full user list
|
||||
- `POST /` — admin: create user
|
||||
- `PATCH /:id` — admin: update role/status/password
|
||||
- `PATCH /me/profile` — own profile (displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone)
|
||||
- `POST /me/avatar` — multipart: resize to 200×200 webp
|
||||
- `GET /check-display-name?name=`
|
||||
|
||||
### Groups (`/api/groups`)
|
||||
- `GET /` — returns `{ publicGroups, privateGroups }` with last_message, peer data for DMs
|
||||
- `POST /` — create group or DM
|
||||
- `PATCH /:id`, `DELETE /:id`
|
||||
- `POST /:id/members`, `DELETE /:id/members/:userId`, `GET /:id/members`
|
||||
- `POST /:id/custom-name`, `DELETE /:id/custom-name`
|
||||
|
||||
### Messages (`/api/messages`)
|
||||
- `GET /?groupId=&before=&limit=` — 50 per page, cursor-based
|
||||
- `POST /group/:groupId` — send message (REST path, fires push)
|
||||
- `POST /image` — image upload
|
||||
|
||||
### User Groups (`/api/usergroups`)
|
||||
- CRUD for user groups (team roster groupings), member management
|
||||
|
||||
### Schedule (`/api/schedule`)
|
||||
- CRUD for events, event types, availability tracking
|
||||
- `GET /events` — with date range, keyword filter, type filter, availability filter
|
||||
- `POST /events/:id/availability` — set own availability
|
||||
- `GET /events/:id/availability` — get all availability for an event
|
||||
|
||||
### Settings (`/api/settings`)
|
||||
- `GET /`, `PATCH /`, `POST /logo`, `POST /icon/:key`
|
||||
|
||||
### Push (`/api/push`)
|
||||
- `GET /firebase-config` — returns FCM SDK config
|
||||
- `POST /subscribe` — save FCM token
|
||||
- `GET /debug` — admin: list tokens + firebase status
|
||||
- `POST /test` — send test push to own device
|
||||
|
||||
### Host (`/api/host`) — host mode only
|
||||
- Tenant provisioning, plan management, host admin panel
|
||||
|
||||
### About (`/api/about`), Help (`/api/help`)
|
||||
|
||||
---
|
||||
|
||||
## Part 10 — Frontend Architecture
|
||||
|
||||
### Page navigation (Chat.jsx)
|
||||
`page` state: `'chat'` | `'groupmessages'` | `'schedule'` | `'users'` | `'groups'` | `'hostpanel'`
|
||||
|
||||
**Rule:** Every page navigation must call `setActiveGroupId(null)` and `setChatHasText(false)`.
|
||||
|
||||
### Group Messages vs Messages (Sidebar)
|
||||
- `groupMessagesMode={false}` → public groups + non-managed private groups
|
||||
- `groupMessagesMode={true}` → only `is_managed` private groups
|
||||
|
||||
### Unsaved text guard (Chat.jsx → ChatWindow.jsx → MessageInput.jsx)
|
||||
- `MessageInput` fires `onTextChange(val)` on every keystroke and after send
|
||||
- `ChatWindow` converts to boolean: `onHasTextChange?.(!!val.trim())`
|
||||
- `Chat.jsx` stores as `chatHasText`; `selectGroup()` shows `window.confirm` if switching with unsaved text
|
||||
- `MessageInput` resets all state on `group?.id` change via `useEffect`
|
||||
|
||||
### Font scale system
|
||||
- CSS var `--font-scale` on `<html>` element (default `1`, range `0.8`–`2.0`)
|
||||
- **Message fonts** use `calc(Xrem * var(--font-scale))` — they scale
|
||||
- **MessageInput font** is fixed (`0.875rem`) — it does NOT scale
|
||||
- **Slider** (ProfileModal appearance tab) is the saved setting — persisted to `localStorage`
|
||||
- **Pinch zoom** (main.jsx touch handler) is session-only — updates `--font-scale` but does NOT write to localStorage
|
||||
- On startup, `--font-scale` is initialised from the saved localStorage value
|
||||
|
||||
### Avatar colour algorithm
|
||||
Must be **identical** across `Avatar.jsx`, `Sidebar.jsx`, `ChatWindow.jsx`:
|
||||
```javascript
|
||||
const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
|
||||
const bg = AVATAR_COLORS[(user.name || '').charCodeAt(0) % AVATAR_COLORS.length];
|
||||
```
|
||||
|
||||
### Notification rules (group member changes, usergroups.js)
|
||||
- 1 user added/removed → named system message: `"{Name} has joined/been removed from the conversation."`
|
||||
- 2+ users added/removed → generic: `"N new members have joined/been removed from the conversation."`
|
||||
|
||||
### User deletion (v0.11.11+)
|
||||
Email → `deleted_{id}@deleted`, name → `'Deleted User'`, all messages `is_deleted=TRUE`, DMs `is_readonly=TRUE`, sessions/subscriptions/availability purged.
|
||||
|
||||
---
|
||||
|
||||
## Part 11 — Schedule / Events
|
||||
|
||||
- All date/time stored as `TIMESTAMPTZ`
|
||||
- `buildISO(date, time)` — builds timezone-aware ISO string from date + HH:MM input
|
||||
- `toTimeIn(iso)` — extracts exact HH:MM (no rounding) for edit forms
|
||||
- `roundUpToHalfHour()` — default start time for new events
|
||||
- New events cannot have a start date/time in the past
|
||||
- Recurring events: `expandRecurringEvent` returns occurrences within requested range only
|
||||
- Keyword filter: unquoted = `\bterm` (prefix match), quoted = `\bterm\b` (exact word)
|
||||
- Type filter does NOT shift date window to today (unlike keyword/availability filters)
|
||||
- Clearing keyword also resets `filterFromDate`
|
||||
|
||||
Both `SchedulePage.jsx` and `MobileEventForm.jsx` maintain their own copies of the time utilities (`roundUpToHalfHour`, `parseTypedTime`, `fmt12`, `toTimeIn`, `buildISO`).
|
||||
|
||||
---
|
||||
|
||||
## Part 12 — CSS Design System
|
||||
|
||||
### Variables (`:root` — light mode)
|
||||
```css
|
||||
--primary: #1a73e8;
|
||||
--primary-dark: #1557b0;
|
||||
--primary-light: #e8f0fe;
|
||||
--surface: #ffffff;
|
||||
--surface-variant: #f8f9fa;
|
||||
--background: #f1f3f4;
|
||||
--border: #e0e0e0;
|
||||
--text-primary: #202124;
|
||||
--text-secondary: #5f6368;
|
||||
--text-tertiary: #9aa0a6;
|
||||
--error: #d93025;
|
||||
--success: #188038;
|
||||
--bubble-out: #1a73e8;
|
||||
--bubble-in: #f1f3f4;
|
||||
--radius: 8px;
|
||||
--font: 'Google Sans', 'Roboto', sans-serif;
|
||||
--font-scale: 1; /* adjusted by pinch or slider */
|
||||
```
|
||||
|
||||
### Dark mode (`[data-theme="dark"]`)
|
||||
```css
|
||||
--primary: #4d8fd4;
|
||||
--primary-light: #1a2d4a;
|
||||
--surface: #1e1e2e;
|
||||
--surface-variant: #252535;
|
||||
--background: #13131f;
|
||||
--border: #2e2e45;
|
||||
--text-primary: #e2e2f0;
|
||||
--text-secondary: #9898b8;
|
||||
--text-tertiary: #606080; /* exactly 6 hex digits — a common typo is 7 */
|
||||
--bubble-out: #4d8fd4;
|
||||
--bubble-in: #252535;
|
||||
```
|
||||
|
||||
### Mobile input fixes
|
||||
```css
|
||||
/* Prevent iOS zoom on input focus (requires font-size >= 16px) */
|
||||
@media (max-width: 768px) {
|
||||
input:focus, textarea:focus, select:focus { font-size: 16px !important; }
|
||||
}
|
||||
/* Autofill styling */
|
||||
input:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--surface) inset !important;
|
||||
-webkit-text-fill-color: var(--text-primary) !important;
|
||||
}
|
||||
```
|
||||
|
||||
### Layout
|
||||
- Desktop: sidebar (320px fixed) + chat area (flex-1)
|
||||
- Mobile (≤768px): sidebar and chat stack — one visible at a time
|
||||
- `--visual-viewport-height` and `--visual-viewport-offset` CSS vars exposed by main.jsx for iOS keyboard handling
|
||||
- `.keyboard-open` class toggled on `<html>` when iOS keyboard is visible
|
||||
|
||||
---
|
||||
|
||||
## Part 13 — Docker & Deployment
|
||||
|
||||
### Dockerfile (multi-stage)
|
||||
```dockerfile
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm install
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY backend/ ./
|
||||
COPY --from=builder /app/frontend/dist ./public
|
||||
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
||||
EXPOSE 3000
|
||||
CMD ["node", "src/index.js"]
|
||||
```
|
||||
|
||||
### Version bump — all three locations
|
||||
```
|
||||
backend/package.json "version": "X.Y.Z"
|
||||
frontend/package.json "version": "X.Y.Z"
|
||||
build.sh VERSION="${1:-X.Y.Z}"
|
||||
```
|
||||
|
||||
### Environment variables (key ones)
|
||||
```
|
||||
APP_TYPE=selfhost|host
|
||||
HOST_DOMAIN= # host mode only
|
||||
HOST_ADMIN_KEY= # host mode only
|
||||
JWT_SECRET=
|
||||
DB_HOST=db # set to 'pgbouncer' after Phase 1 scaling
|
||||
DB_NAME=rosterchirp
|
||||
DB_USER=rosterchirp
|
||||
DB_PASSWORD= # avoid ! (shell interpolation issue in docker-compose)
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_NAME=
|
||||
ADMIN_PASS=
|
||||
ADMPW_RESET=true|false
|
||||
APP_NAME=rosterchirp
|
||||
USER_PASS= # default password for bulk-created users
|
||||
DEFCHAT_NAME=General Chat
|
||||
FIREBASE_API_KEY=
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_APP_ID=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_VAPID_KEY=
|
||||
FIREBASE_SERVICE_ACCOUNT= # stringified JSON, no newlines
|
||||
VAPID_PUBLIC= # legacy, auto-generated, no longer used for push delivery
|
||||
VAPID_PRIVATE=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 14 — Scale Architecture (Planned)
|
||||
|
||||
### Phase 1 — PgBouncer (zero code changes)
|
||||
Add PgBouncer service to `docker-compose.host.yaml`. Point `DB_HOST=pgbouncer`. Increase Node pool `max` to 100. Use `POOL_MODE=transaction`. Eliminates the 20-connection bottleneck.
|
||||
|
||||
### Phase 2 — Redis (horizontal scaling)
|
||||
Required for multiple Node instances:
|
||||
1. `@socket.io/redis-adapter` — cross-instance socket fan-out
|
||||
2. Replace `onlineUsers` Map with Redis `SADD`/`SREM` presence keys (`presence:{schema}:{userId}`)
|
||||
3. Replace `tenantDomainCache` Map with Redis hash + TTL
|
||||
4. Move uploads to Cloudflare R2 (S3-compatible) — `@aws-sdk/client-s3`
|
||||
5. Force WebSocket transport only (eliminates polling sticky-session concern)
|
||||
|
||||
**Note on Phase 2:** `SET search_path` per query is safe with PgBouncer in transaction mode. Do NOT use `LISTEN/NOTIFY` or session-level state through PgBouncer.
|
||||
|
||||
---
|
||||
|
||||
## Part 15 — Known Gotchas & Decisions
|
||||
|
||||
| Gotcha | Solution |
|
||||
|---|---|
|
||||
| Multi-tenant schema isolation | Every query must go through `query(schema, ...)` — never raw `pool.query` |
|
||||
| `assertSafeSchema()` | Always validate schema names before use — injection risk |
|
||||
| Socket room names include schema | `R(schema, 'group', id)` not bare `group:{id}` — cross-tenant leakage otherwise |
|
||||
| `onlineUsers` key is `${schema}:${userId}` | Two tenants can share the same integer user ID |
|
||||
| FCM push fired from messages.js REST route | Frontend uses REST POST, not socket, for sending messages |
|
||||
| Pinch zoom is session-only | Remove `localStorage.setItem` from touchend — slider is the saved setting |
|
||||
| MessageInput font is fixed | Do not apply `--font-scale` to `.msg-input` font-size |
|
||||
| iOS keyboard layout | Use `--visual-viewport-height` CSS var, not `100vh`, for the chat layout height |
|
||||
| Avatar colour algorithm | Must be identical in Avatar.jsx, Sidebar.jsx, and ChatWindow.jsx |
|
||||
| `is_managed` groups | Managed private groups appear in Group Messages view, not regular Messages view |
|
||||
| Migrations are SQL files | Not try/catch ALTER TABLE — numbered SQL files in `migrations/` applied in order |
|
||||
| DB_PASSWORD must not contain `!` | Shell interpolation breaks docker-compose env parsing |
|
||||
| Routes accept `io` as parameter | `module.exports = (io) => router` — not default export |
|
||||
| `session:displaced` socket event | Sent to the old socket when a new login displaces a session on the same device type |
|
||||
| `help.md` is NOT in the volume path | Must be at `backend/src/data/help.md` — not in `/app/data/` which is volume-mounted |
|
||||
| Dark mode `--text-tertiary` | Exactly 6 hex digits: `#606080` not `#6060808` |
|
||||
| Web Share API for mobile file download | Use `navigator.share({ files: [...] })` on mobile; fall back to `a.click()` download on desktop |
|
||||
|
||||
---
|
||||
|
||||
## Part 16 — Features Checklist
|
||||
|
||||
### Messaging
|
||||
- [x] Text messages with URL auto-linking and @mentions
|
||||
- [x] Image upload + lightbox
|
||||
- [x] Link preview cards (og: meta, server-side fetch)
|
||||
- [x] Reply-to with quoted preview
|
||||
- [x] Emoji reactions (quick bar + full picker)
|
||||
- [x] Message soft-delete
|
||||
- [x] Typing indicator
|
||||
- [x] Date separators, consecutive-message collapsing
|
||||
- [x] System messages
|
||||
- [x] Emoji-only messages render larger
|
||||
- [x] Infinite scroll / cursor-based pagination (50 per page)
|
||||
|
||||
### Groups & DMs
|
||||
- [x] Public channels, private groups, read-only channels
|
||||
- [x] Managed private groups (Group Messages view)
|
||||
- [x] User-to-user direct messages
|
||||
- [x] Per-user custom group display name
|
||||
- [x] User groups (team roster groupings) with colour coding
|
||||
- [x] Group availability tracking (events)
|
||||
|
||||
### Users & Profiles
|
||||
- [x] Display name, avatar, about me, date of birth, phone
|
||||
- [x] Hide admin tag option
|
||||
- [x] Allow/block DMs toggle
|
||||
- [x] Child/alias user accounts (`AddChildAliasModal`)
|
||||
- [x] Bulk user import via CSV
|
||||
|
||||
### Admin
|
||||
- [x] Settings modal: app name, logo, PWA icons
|
||||
- [x] Branding modal (Brand+ plan)
|
||||
- [x] User manager (full page): create, edit, suspend, reset password, bulk import
|
||||
- [x] Group manager (full page): create groups, manage members, assign user groups
|
||||
- [x] Schedule manager modal: event types with custom colours
|
||||
- [x] Admin can delete any message
|
||||
|
||||
### Schedule
|
||||
- [x] Event creation (one-time + recurring)
|
||||
- [x] Event types with colour coding
|
||||
- [x] Availability tracking (Going / Maybe / Not Going)
|
||||
- [x] Download availability list (Web Share API on mobile, download link on desktop)
|
||||
- [x] Keyword filter (prefix and exact-word modes)
|
||||
- [x] Type filter, date range filter
|
||||
- [x] Desktop and mobile views (separate implementations)
|
||||
|
||||
### PWA / Notifications
|
||||
- [x] Installable PWA (manifest, service worker, icons)
|
||||
- [x] FCM push notifications (Android working; iOS in progress)
|
||||
- [x] App badge on home screen icon
|
||||
- [x] Page title unread count `(N) App Name`
|
||||
- [x] Per-conversation notification grouping
|
||||
|
||||
### UX
|
||||
- [x] Light / dark mode (CSS vars, saved localStorage)
|
||||
- [x] Font scale slider (saved setting) + pinch zoom (session only)
|
||||
- [x] Mobile-responsive layout
|
||||
- [x] Pull-to-refresh blocked in PWA standalone mode
|
||||
- [x] iOS keyboard layout fix (`--visual-viewport-height`)
|
||||
- [x] Getting Started help modal
|
||||
- [x] About modal, Support modal
|
||||
- [x] User profile popup (click any avatar)
|
||||
- [x] NavDrawer (hamburger menu)
|
||||
|
||||
---
|
||||
|
||||
## Part 17 — One-Shot Prompt (Copy-Paste to Start)
|
||||
|
||||
```
|
||||
Build a self-hosted team chat PWA called "RosterChirp". Single Docker container.
|
||||
Supports selfhost (single tenant) and host (multi-tenant via Postgres schema per tenant) modes.
|
||||
|
||||
STACK: Node 20 + Express + Socket.io + PostgreSQL 16 (pg npm package) + JWT
|
||||
(HTTP-only cookie + localStorage) + bcryptjs + React 18 + Vite.
|
||||
Push via Firebase Cloud Messaging (firebase-admin backend, firebase frontend SDK).
|
||||
Images via multer + sharp. Frontend: plain CSS with CSS custom properties, no Tailwind.
|
||||
|
||||
MULTI-TENANT: tenantMiddleware sets req.schema from Host header. assertSafeSchema()
|
||||
validates all schema names. Socket rooms prefixed: `${schema}:${type}:${id}`.
|
||||
onlineUsers Map key is `${schema}:${userId}` to prevent cross-tenant ID collisions.
|
||||
Every DB query calls SET search_path TO {schema} first.
|
||||
|
||||
MIGRATIONS: Numbered SQL files in backend/src/models/migrations/ (001, 002, ...).
|
||||
Auto-applied on startup via runMigrations(schema). Never edit applied migrations.
|
||||
|
||||
ROUTES accept io as parameter: module.exports = (io) => router
|
||||
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
|
||||
|
||||
KEY FEATURES:
|
||||
- Public/private/readonly channels, managed private groups (Group Messages view),
|
||||
user-to-user DMs, @mentions, emoji reactions, reply-to, image upload, link previews,
|
||||
soft-delete, typing indicator, unread badges, page title (N) count
|
||||
- User groups (team roster groupings) with colour coding
|
||||
- Schedule: events, event types, availability tracking, recurring events
|
||||
- Font scale: --font-scale CSS var on <html>. Message fonts scale with it. MessageInput
|
||||
font is FIXED (no --font-scale). Slider in ProfileModal = saved setting (localStorage).
|
||||
Pinch zoom = session only (touchend must NOT write to localStorage).
|
||||
- FCM push: fired from messages.js REST route (not socket handler). sendPushToUser helper.
|
||||
Stale token cleanup on FCM error codes.
|
||||
- Avatar colour: AVATAR_COLORS array, charCodeAt(0) % length. Must be identical in
|
||||
Avatar.jsx, Sidebar.jsx, ChatWindow.jsx.
|
||||
- User deletion: email scrubbed, messages nulled, DMs set readonly, sessions purged.
|
||||
- Web Share API for mobile file downloads; a.click() fallback for desktop.
|
||||
|
||||
GOTCHAS:
|
||||
- DB_PASSWORD must not contain '!' (shell interpolation in docker-compose)
|
||||
- dark mode --text-tertiary must be exactly 6 hex digits: #606080
|
||||
- help.md at backend/src/data/help.md (NOT /app/data — volume-mounted, shadows files)
|
||||
- Session displaced: socket receives 'session:displaced' when new login takes device slot
|
||||
- iOS keyboard: use --visual-viewport-height CSS var (not 100vh) for chat layout height
|
||||
- Routes that emit socket events receive io as first argument, not default export
|
||||
```
|
||||
7
about.json.example
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"built_with": "Node.js · Express · Socket.io · SQLite · React · Vite · Claude.ai",
|
||||
"developer": "Your Name or Organization",
|
||||
"license": "AGPL 3.0",
|
||||
"license_url": "https://www.gnu.org/licenses/agpl-3.0.html",
|
||||
"description": "Self-hosted, privacy-first team messaging."
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "teamchat-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "TeamChat backend server",
|
||||
"name": "rosterchirp-backend",
|
||||
"version": "0.13.1",
|
||||
"description": "RosterChirp backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
@@ -9,16 +9,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"firebase-admin": "^12.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nanoid": "^3.3.7",
|
||||
"node-fetch": "^2.7.0",
|
||||
"sharp": "^0.33.2",
|
||||
"socket.io": "^4.6.1",
|
||||
"csv-parse": "^5.5.6",
|
||||
"pg": "^8.11.3",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
136
backend/scripts/encrypt-db.js
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* jama DB encryption migration
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Converts an existing plain SQLite database to SQLCipher (AES-256 encrypted).
|
||||
*
|
||||
* Run ONCE before upgrading to a jama version that includes DB_KEY support.
|
||||
* The container must be STOPPED before running this script.
|
||||
*
|
||||
* Usage (run on the Docker host, not inside the container):
|
||||
*
|
||||
* node encrypt-db.js --db /path/to/jama.db --key YOUR_DB_KEY
|
||||
*
|
||||
* Or using env vars:
|
||||
*
|
||||
* DB_PATH=/path/to/jama.db DB_KEY=yourkey node encrypt-db.js
|
||||
*
|
||||
* To find your Docker volume path:
|
||||
* docker volume inspect jama_jama_db
|
||||
* (look for the "Mountpoint" field)
|
||||
*
|
||||
* The script will:
|
||||
* 1. Verify the source file is a plain (unencrypted) SQLite database
|
||||
* 2. Create an encrypted copy at <original>.encrypted
|
||||
* 3. Back up the original to <original>.plaintext-backup
|
||||
* 4. Move the encrypted copy into place as <original>
|
||||
*
|
||||
* If anything goes wrong, restore with:
|
||||
* cp jama.db.plaintext-backup jama.db
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Parse CLI args --db and --key
|
||||
const args = process.argv.slice(2);
|
||||
const argDb = args[args.indexOf('--db') + 1];
|
||||
const argKey = args[args.indexOf('--key') + 1];
|
||||
|
||||
const DB_PATH = argDb || process.env.DB_PATH || '/app/data/jama.db';
|
||||
const DB_KEY = argKey || process.env.DB_KEY || '';
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (!DB_KEY) {
|
||||
console.error('ERROR: No DB_KEY provided.');
|
||||
console.error('Usage: node encrypt-db.js --db /path/to/jama.db --key YOUR_KEY');
|
||||
console.error(' or: DB_KEY=yourkey node encrypt-db.js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(DB_PATH)) {
|
||||
console.error(`ERROR: Database file not found: ${DB_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check it looks like a plain SQLite file (magic bytes: "SQLite format 3\000")
|
||||
const MAGIC = 'SQLite format 3\0';
|
||||
const fd = fs.openSync(DB_PATH, 'r');
|
||||
const header = Buffer.alloc(16);
|
||||
fs.readSync(fd, header, 0, 16, 0);
|
||||
fs.closeSync(fd);
|
||||
|
||||
if (header.toString('ascii') !== MAGIC) {
|
||||
console.error('ERROR: The database does not appear to be a plain (unencrypted) SQLite file.');
|
||||
console.error('It may already be encrypted, or the path is wrong.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Migration ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = require('better-sqlite3-multiple-ciphers');
|
||||
} catch (e) {
|
||||
console.error('ERROR: better-sqlite3-sqlcipher is not installed.');
|
||||
console.error('Run: npm install better-sqlite3-sqlcipher');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const encPath = DB_PATH + '.encrypted';
|
||||
const backupPath = DB_PATH + '.plaintext-backup';
|
||||
|
||||
console.log(`\njama DB encryption migration`);
|
||||
console.log(`────────────────────────────`);
|
||||
console.log(`Source: ${DB_PATH}`);
|
||||
console.log(`Backup: ${backupPath}`);
|
||||
console.log(`Output: ${DB_PATH} (encrypted)\n`);
|
||||
|
||||
try {
|
||||
// Open the plain DB (no key)
|
||||
console.log('Step 1/4 Opening plain database...');
|
||||
const plain = new Database(DB_PATH);
|
||||
|
||||
// Create encrypted copy using sqlcipher_export via ATTACH
|
||||
console.log('Step 2/4 Encrypting to temporary file...');
|
||||
const safeKey = DB_KEY.replace(/'/g, "''");
|
||||
plain.exec(`ATTACH DATABASE '${encPath}' AS encrypted KEY '${safeKey}'`);
|
||||
plain.exec(`SELECT sqlcipher_export('encrypted')`);
|
||||
plain.exec(`DETACH DATABASE encrypted`);
|
||||
plain.close();
|
||||
|
||||
// Verify the encrypted file opens correctly with cipher settings
|
||||
console.log('Step 3/4 Verifying encrypted database...');
|
||||
const enc = new Database(encPath);
|
||||
enc.pragma(`cipher='sqlcipher'`);
|
||||
enc.pragma(`legacy=4`);
|
||||
enc.pragma(`key='${safeKey}'`);
|
||||
const count = enc.prepare("SELECT COUNT(*) as n FROM sqlite_master").get();
|
||||
enc.close();
|
||||
console.log(` OK — ${count.n} objects found in encrypted DB`);
|
||||
|
||||
// Swap files: backup plain, move encrypted into place
|
||||
console.log('Step 4/4 Swapping files...');
|
||||
fs.renameSync(DB_PATH, backupPath);
|
||||
fs.renameSync(encPath, DB_PATH);
|
||||
|
||||
console.log(`\n✓ Migration complete!`);
|
||||
console.log(` Encrypted DB: ${DB_PATH}`);
|
||||
console.log(` Plain backup: ${backupPath}`);
|
||||
console.log(`\nNext steps:`);
|
||||
console.log(` 1. Set DB_KEY=${DB_KEY} in your .env file`);
|
||||
console.log(` 2. Start jama — it will open the encrypted database`);
|
||||
console.log(` 3. Once confirmed working, delete the plain backup:`);
|
||||
console.log(` rm ${backupPath}\n`);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`\n✗ Migration failed: ${err.message}`);
|
||||
// Clean up any partial encrypted file
|
||||
if (fs.existsSync(encPath)) fs.unlinkSync(encPath);
|
||||
console.error('No changes were made to the original database.');
|
||||
process.exit(1);
|
||||
}
|
||||
134
backend/src/data/help.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Getting Started with JAMA
|
||||
|
||||
Welcome to **JAMA** — your private, self-hosted team messaging app.
|
||||
|
||||
**JAMA** - **J**ust **A**nother **M**essaging **A**pp
|
||||
|
||||
---
|
||||
|
||||
## What is JAMA?
|
||||
|
||||
JAMA is a private chat system that doesn’t need the internet to work—you can host it on a completely offline network. Even if you do run JAMA while you're online, it stays locked inside its own "container," so it never reaches out to other internet services.
|
||||
|
||||
We keep things private, too: the only info we ask for is a name and an email, and technically speaking they don't even have to be real. Your name just helps your team know who you are, and your email is only used as your login (it's never shares with anyone else).
|
||||
|
||||
There’s no annoying phone or email verification to deal with, so you can jump right in. If you ever get locked out, just hit the "Get Help" link on the login page. JAMA is easy and intuitive, you're going to love it.
|
||||
|
||||
----
|
||||
----
|
||||
|
||||
## Security
|
||||
|
||||
### 🛡️ Your Privacy Assured
|
||||
**Encryption**, the JAMA database is fully encrypted. Your posts are protected from prying eyes, including the JAMA administrators.
|
||||
|
||||
The only people that can read your direct messages (**person 2 person** or **group**) are the members of your message group. No one else knows, including JAMA admins, which direct message groups exist or which you are part of, well, unless they are a member of the group. With the database being encrypted there is no easy way to access your data.
|
||||
|
||||
**Every user**, at minimum, can read all public messages.
|
||||
|
||||
----
|
||||
----
|
||||
|
||||
## Navigating JAMA
|
||||
|
||||
### Message List (Left Sidebar)
|
||||
The sidebar shows all your message groups and direct conversations. Tap or click any group to open it.
|
||||
|
||||
- **#** prefix indicates a **Public** group — visible to all users
|
||||
- **Bold** group names, with a notification badge means you have unread messages
|
||||
- A message with the newest post with alway be listed at the top
|
||||
- The last message preview shows a message from a user in your group, or **You:** if you sent it
|
||||
|
||||
|
||||
## Sending Messages
|
||||
|
||||
Type your message in the input box at the bottom and press **Enter** to send.
|
||||
|
||||
- **Shift + Enter** adds a new line without sending
|
||||
- Tap the **+** button to attach a photo or emoji
|
||||
- Use the **camera** icon to take a photo directly (mobile only)
|
||||
|
||||
### Mentioning Someone
|
||||
Type **@** will bring a group user list, select a users real name to mention them. Users receive a notification.
|
||||
|
||||
Example: `@[John Smith]` will notify John Smith of the message.
|
||||
|
||||
### Replying to a Message
|
||||
Hover over any message and click the **reply arrow** in the pop-up to quote and reply to it.
|
||||
|
||||
### Reacting to a Message
|
||||
Hover over any message and select a common emoji in the pop-up to or click the **emoji** button to bring up a full list to select from.
|
||||
|
||||
---
|
||||
|
||||
## Direct Messages
|
||||
|
||||
There are two ways to start a private conversation with one person:
|
||||
|
||||
_**New Chat Button**_
|
||||
1. Click the **New Chat** icon in the sidebar
|
||||
2. Select one user from the list
|
||||
3. Click **Start Conversation**
|
||||
|
||||
_**Message Window**_
|
||||
1. Click the users avatar in a message window to bring up the profile
|
||||
2. Click **Direct Message**
|
||||
|
||||
> _Users have the ability to disable direct and private messages in their profile. If set, they will not be listed in the "New Chat" user list and the "Direct Message" button is not enabled._
|
||||
|
||||
---
|
||||
|
||||
## Group Messages
|
||||
|
||||
To create a group conversation:
|
||||
|
||||
1. Click the **new chat** icon
|
||||
2. Select two or more users from the
|
||||
3. Enter a **Message Name**
|
||||
4. Click **Create**
|
||||
|
||||
> _If a message group with the exact same members already exists, you will be redirected to it automatically. This helps to avoid duplication._
|
||||
|
||||
_**Note:** Users have the option to leave any direct message group by selecting the "Message Info" button in the top right corner in the message title._
|
||||
|
||||
---
|
||||
|
||||
## Your Profile
|
||||
|
||||
Click your name or avatar at the bottom of the sidebar to:
|
||||
|
||||
- Update your **display name** (displayed in message windows)
|
||||
- Add an **about me** note
|
||||
- Upload a **profile photo** for your avatar
|
||||
- Change your **password**
|
||||
|
||||
---
|
||||
|
||||
## Customising Group Names
|
||||
|
||||
You can set a personal display name for any group that only you will see:
|
||||
|
||||
1. Open the message
|
||||
2. Click the **message info** icon in the top right
|
||||
3. Enter your custom name under **Your custom name**
|
||||
4. Click **Save**
|
||||
|
||||
Other members still see the original group name, unless they change to customised name for themselves.
|
||||
|
||||
---
|
||||
|
||||
## Admin Options
|
||||
|
||||
Admins can access **Settings** from the user menu to configure:
|
||||
|
||||
- **Branding:** a new app name and/or logo, title colour and message list avatar background colours
|
||||
- **User Manager:** Create new user password, change passwords, suspend and delete user accounts.
|
||||
- **Settings:** Various options
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- 🌙 Toggle **dark mode** from the user menu
|
||||
- 🔔 Enable **push notifications** when prompted to receive alerts when the app is closed
|
||||
- 📱 Install JAMA as a **PWA** on your device — tap *Add to Home Screen* in your browser menu for an app-like experience
|
||||
@@ -1,43 +1,53 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { initDb, seedAdmin, getOrCreateSupportGroup, getDb } = require('./models/db');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const {
|
||||
initDb, tenantMiddleware,
|
||||
query, queryOne, queryResult, exec,
|
||||
APP_TYPE, refreshTenantCache,
|
||||
} = require('./models/db');
|
||||
|
||||
const { router: pushRouter, sendPushToUser } = require('./routes/push');
|
||||
const { getLinkPreview } = require('./utils/linkPreview');
|
||||
|
||||
const app = express();
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: { origin: '*', methods: ['GET', 'POST'] }
|
||||
});
|
||||
const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] } });
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Init DB
|
||||
initDb();
|
||||
seedAdmin();
|
||||
getOrCreateSupportGroup(); // Ensure Support group exists
|
||||
|
||||
// Middleware
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(tenantMiddleware);
|
||||
app.use('/uploads', express.static('/app/uploads'));
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
app.use('/api/users', require('./routes/users'));
|
||||
app.use('/api/groups', require('./routes/groups'));
|
||||
app.use('/api/messages', require('./routes/messages'));
|
||||
app.use('/api/settings', require('./routes/settings'));
|
||||
app.use('/api/push', pushRouter);
|
||||
// ── API Routes ────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', require('./routes/auth')(io));
|
||||
app.use('/api/users', require('./routes/users'));
|
||||
app.use('/api/groups', require('./routes/groups')(io));
|
||||
app.use('/api/messages', require('./routes/messages')(io));
|
||||
app.use('/api/usergroups', require('./routes/usergroups')(io));
|
||||
app.use('/api/schedule', require('./routes/schedule')(io));
|
||||
app.use('/api/settings', require('./routes/settings'));
|
||||
app.use('/api/about', require('./routes/about'));
|
||||
app.use('/api/help', require('./routes/help'));
|
||||
app.use('/api/push', pushRouter);
|
||||
|
||||
// Link preview proxy
|
||||
// RosterChirp-Host control plane — only registered when APP_TYPE=host
|
||||
if (APP_TYPE === 'host') {
|
||||
app.use('/api/host', require('./routes/host'));
|
||||
console.log('[Server] RosterChirp-Host control plane enabled at /api/host');
|
||||
}
|
||||
|
||||
// ── Link preview proxy ────────────────────────────────────────────────────────
|
||||
app.get('/api/link-preview', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) return res.status(400).json({ error: 'URL required' });
|
||||
@@ -45,265 +55,372 @@ app.get('/api/link-preview', async (req, res) => {
|
||||
res.json({ preview });
|
||||
});
|
||||
|
||||
// Health check
|
||||
// ── Health check ──────────────────────────────────────────────────────────────
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
// Dynamic manifest — must be before express.static so it takes precedence
|
||||
app.get('/manifest.json', (req, res) => {
|
||||
const db = getDb();
|
||||
const rows = db.prepare("SELECT key, value FROM settings WHERE key IN ('app_name', 'logo_url', 'pwa_icon_192', 'pwa_icon_512')").all();
|
||||
const s = {};
|
||||
for (const r of rows) s[r.key] = r.value;
|
||||
// ── Dynamic PWA manifest ──────────────────────────────────────────────────────
|
||||
app.get('/manifest.json', async (req, res) => {
|
||||
try {
|
||||
const rows = await query(req.schema,
|
||||
"SELECT key, value FROM settings WHERE key IN ('app_name','logo_url','pwa_icon_192','pwa_icon_512')"
|
||||
);
|
||||
const s = {};
|
||||
for (const r of rows) s[r.key] = r.value;
|
||||
|
||||
const appName = s.app_name || process.env.APP_NAME || 'TeamChat';
|
||||
const pwa192 = s.pwa_icon_192 || '';
|
||||
const pwa512 = s.pwa_icon_512 || '';
|
||||
const appName = s.app_name || process.env.APP_NAME || 'rosterchirp';
|
||||
const icon192 = s.pwa_icon_192 || '/icons/icon-192.png';
|
||||
const icon512 = s.pwa_icon_512 || '/icons/icon-512.png';
|
||||
|
||||
// Use uploaded+resized icons if they exist, else fall back to bundled PNGs.
|
||||
// Chrome requires explicit pixel sizes (not "any") to use icons for PWA shortcuts.
|
||||
const icon192 = pwa192 || '/icons/icon-192.png';
|
||||
const icon512 = pwa512 || '/icons/icon-512.png';
|
||||
const icons = [
|
||||
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
];
|
||||
|
||||
const icons = [
|
||||
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: icon192, sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: icon512, sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
];
|
||||
|
||||
const manifest = {
|
||||
name: appName,
|
||||
short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
|
||||
description: `${appName} - Team messaging`,
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#1a73e8',
|
||||
icons,
|
||||
};
|
||||
|
||||
res.setHeader('Content-Type', 'application/manifest+json');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.json(manifest);
|
||||
res.setHeader('Content-Type', 'application/manifest+json');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.json({
|
||||
name: appName,
|
||||
short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
|
||||
description: `${appName} - Team messaging`,
|
||||
start_url: '/', scope: '/', display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
background_color: '#ffffff', theme_color: '#1a73e8',
|
||||
icons,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve frontend
|
||||
// ── Frontend ──────────────────────────────────────────────────────────────────
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
// Socket.io authentication
|
||||
io.use((socket, next) => {
|
||||
// ── Socket.io authentication ──────────────────────────────────────────────────
|
||||
// Socket connections do not go through Express middleware, so we resolve
|
||||
// schema from the handshake headers manually.
|
||||
const { resolveSchema } = require('./models/db');
|
||||
|
||||
io.use(async (socket, next) => {
|
||||
const token = socket.handshake.auth.token;
|
||||
if (!token) return next(new Error('Unauthorized'));
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active');
|
||||
|
||||
// Resolve tenant schema from socket handshake headers
|
||||
const schema = resolveSchema({ headers: socket.handshake.headers });
|
||||
|
||||
const user = await queryOne(schema,
|
||||
'SELECT id, name, display_name, avatar, role, status FROM users WHERE id = $1 AND status = $2',
|
||||
[decoded.id, 'active']
|
||||
);
|
||||
if (!user) return next(new Error('User not found'));
|
||||
socket.user = user;
|
||||
|
||||
const session = await queryOne(schema,
|
||||
'SELECT * FROM active_sessions WHERE user_id = $1 AND token = $2',
|
||||
[decoded.id, token]
|
||||
);
|
||||
if (!session) return next(new Error('Session displaced'));
|
||||
|
||||
socket.user = user;
|
||||
socket.token = token;
|
||||
socket.device = session.device;
|
||||
socket.schema = schema;
|
||||
next();
|
||||
} catch (e) {
|
||||
next(new Error('Invalid token'));
|
||||
}
|
||||
});
|
||||
|
||||
// Track online users: userId -> Set of socketIds
|
||||
const onlineUsers = new Map();
|
||||
// ── Online user tracking ──────────────────────────────────────────────────────
|
||||
// Key is `${schema}:${userId}` — user IDs are per-schema integers, so two tenants
|
||||
// can have the same integer ID for completely different people. Without the schema
|
||||
// prefix, tenant A's user 5 and tenant B's user 5 would collide: push notifications
|
||||
// could be suppressed for the wrong user, and users:online would leak IDs across tenants.
|
||||
const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
io.on('connection', async (socket) => {
|
||||
const userId = socket.user.id;
|
||||
|
||||
if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set());
|
||||
onlineUsers.get(userId).add(socket.id);
|
||||
const schema = socket.schema;
|
||||
// Prefix rooms with schema so tenant rooms never collide (IDs are per-schema only)
|
||||
const R = (type, id) => `${schema}:${type}:${id}`;
|
||||
// Scoped key for the onlineUsers map — must match schema for correct tenant isolation
|
||||
const onlineKey = `${schema}:${userId}`;
|
||||
|
||||
// Broadcast online status
|
||||
io.emit('user:online', { userId });
|
||||
if (!onlineUsers.has(onlineKey)) onlineUsers.set(onlineKey, new Set());
|
||||
onlineUsers.get(onlineKey).add(socket.id);
|
||||
|
||||
// Join rooms for all user's groups
|
||||
const db = getDb();
|
||||
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
|
||||
for (const g of publicGroups) socket.join(`group:${g.id}`);
|
||||
|
||||
const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId);
|
||||
for (const g of privateGroups) socket.join(`group:${g.group_id}`);
|
||||
// Update last_online
|
||||
exec(schema, 'UPDATE users SET last_online = NOW() WHERE id = $1', [userId]).catch(() => {});
|
||||
|
||||
// Handle new message
|
||||
io.to(R('schema', 'all')).emit('user:online', { userId });
|
||||
socket.join(R('user', userId));
|
||||
socket.join(R('schema', 'all')); // tenant-scoped broadcast room for public group events
|
||||
|
||||
// Join socket rooms for all groups this user belongs to
|
||||
try {
|
||||
const publicGroups = await query(schema, "SELECT id FROM groups WHERE type = 'public'");
|
||||
for (const g of publicGroups) socket.join(R('group', g.id));
|
||||
|
||||
const privateGroups = await query(schema,
|
||||
'SELECT group_id FROM group_members WHERE user_id = $1', [userId]
|
||||
);
|
||||
for (const g of privateGroups) socket.join(R('group', g.group_id));
|
||||
} catch (e) {
|
||||
console.error('[Socket] Room join error:', e.message);
|
||||
}
|
||||
|
||||
socket.on('group:join-room', ({ groupId }) => socket.join(R('group', groupId)));
|
||||
socket.on('group:leave-room', ({ groupId }) => socket.leave(R('group', groupId)));
|
||||
|
||||
// ── New message ─────────────────────────────────────────────────────────────
|
||||
socket.on('message:send', async (data) => {
|
||||
const { groupId, content, replyToId, imageUrl, linkPreview } = data;
|
||||
const db = getDb();
|
||||
try {
|
||||
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id = $1', [groupId]);
|
||||
if (!group) return;
|
||||
if (group.is_readonly && socket.user.role !== 'admin') return;
|
||||
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||
if (!group) return;
|
||||
if (group.is_readonly && socket.user.role !== 'admin') return;
|
||||
|
||||
// Check access
|
||||
if (group.type === 'private') {
|
||||
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
|
||||
if (!member) return;
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id, link_preview)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(groupId, userId, content || null, imageUrl || null, imageUrl ? 'image' : 'text', replyToId || null, linkPreview ? JSON.stringify(linkPreview) : null);
|
||||
|
||||
const message = db.prepare(`
|
||||
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,
|
||||
rm.content as reply_content, rm.image_url as reply_image_url, rm.is_deleted as reply_is_deleted,
|
||||
ru.name as reply_user_name, ru.display_name as reply_user_display_name
|
||||
FROM messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN messages rm ON m.reply_to_id = rm.id
|
||||
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||
WHERE m.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
message.reactions = [];
|
||||
|
||||
io.to(`group:${groupId}`).emit('message:new', message);
|
||||
|
||||
// For private groups: push notify members who are offline
|
||||
// (reuse `group` already fetched above)
|
||||
if (group?.type === 'private') {
|
||||
const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId);
|
||||
const senderName = socket.user?.display_name || socket.user?.name || 'Someone';
|
||||
for (const m of members) {
|
||||
if (m.user_id === userId) continue; // don't notify sender
|
||||
if (!onlineUsers.has(m.user_id)) {
|
||||
// User is offline — send push
|
||||
sendPushToUser(m.user_id, {
|
||||
title: senderName,
|
||||
body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100),
|
||||
url: '/',
|
||||
badge: 1,
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
// User is online but not necessarily in this group — send socket notification
|
||||
const notif = { type: 'private_message', groupId, fromUser: socket.user };
|
||||
for (const sid of onlineUsers.get(m.user_id)) {
|
||||
io.to(sid).emit('notification:new', notif);
|
||||
}
|
||||
}
|
||||
if (group.type === 'private') {
|
||||
const member = await queryOne(schema,
|
||||
'SELECT id FROM group_members WHERE group_id = $1 AND user_id = $2',
|
||||
[groupId, userId]
|
||||
);
|
||||
if (!member) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Process @mentions
|
||||
if (content) {
|
||||
const mentions = content.match(/@\[([^\]]+)\]\((\d+)\)/g) || [];
|
||||
for (const mention of mentions) {
|
||||
const matchId = mention.match(/\((\d+)\)/)?.[1];
|
||||
if (matchId && parseInt(matchId) !== userId) {
|
||||
const notifResult = db.prepare(`
|
||||
INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id)
|
||||
VALUES (?, 'mention', ?, ?, ?)
|
||||
`).run(parseInt(matchId), result.lastInsertRowid, groupId, userId);
|
||||
const mr = await queryResult(schema, `
|
||||
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id, link_preview)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id
|
||||
`, [
|
||||
groupId, userId,
|
||||
content || null,
|
||||
imageUrl || null,
|
||||
imageUrl ? 'image' : 'text',
|
||||
replyToId || null,
|
||||
linkPreview ? JSON.stringify(linkPreview) : null,
|
||||
]);
|
||||
const msgId = mr.rows[0].id;
|
||||
|
||||
// Notify mentioned user — socket if online, push if not
|
||||
const mentionedUserId = parseInt(matchId);
|
||||
const notif = {
|
||||
id: notifResult.lastInsertRowid,
|
||||
type: 'mention',
|
||||
groupId,
|
||||
messageId: result.lastInsertRowid,
|
||||
fromUser: socket.user,
|
||||
};
|
||||
if (onlineUsers.has(mentionedUserId)) {
|
||||
for (const sid of onlineUsers.get(mentionedUserId)) {
|
||||
io.to(sid).emit('notification:new', notif);
|
||||
const message = 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,
|
||||
rm.content AS reply_content, rm.image_url AS reply_image_url,
|
||||
rm.is_deleted AS reply_is_deleted,
|
||||
ru.name AS reply_user_name, ru.display_name AS reply_user_display_name
|
||||
FROM messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN messages rm ON m.reply_to_id = rm.id
|
||||
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||
WHERE m.id = $1
|
||||
`, [msgId]);
|
||||
|
||||
message.reactions = [];
|
||||
io.to(R('group', groupId)).emit('message:new', message);
|
||||
|
||||
// Push notifications
|
||||
const senderName = socket.user.display_name || socket.user.name || 'Someone';
|
||||
const msgBody = (content || (imageUrl ? '📷 Image' : '')).slice(0, 100);
|
||||
|
||||
if (group.type === 'private') {
|
||||
const members = await query(schema,
|
||||
'SELECT user_id FROM group_members WHERE group_id = $1', [groupId]
|
||||
);
|
||||
for (const m of members) {
|
||||
if (m.user_id === userId) continue;
|
||||
const memberKey = `${schema}:${m.user_id}`;
|
||||
if (onlineUsers.has(memberKey)) {
|
||||
// In-app notification for connected sockets
|
||||
for (const sid of onlineUsers.get(memberKey)) {
|
||||
io.to(sid).emit('notification:new', { type: 'private_message', groupId, fromUser: socket.user });
|
||||
}
|
||||
}
|
||||
// Always send push (badge even when app is open)
|
||||
const senderName = socket.user?.display_name || socket.user?.name || 'Someone';
|
||||
sendPushToUser(mentionedUserId, {
|
||||
title: `${senderName} mentioned you`,
|
||||
body: (content || '').replace(/@\[[^\]]+\]\(\d+\)/g, (m) => '@' + m.match(/\[([^\]]+)\]/)?.[1]).slice(0, 100),
|
||||
url: '/',
|
||||
badge: 1,
|
||||
// Always send push — when the app is in the foreground FCM delivers
|
||||
// silently (no system notification); when backgrounded or offline the
|
||||
// service worker shows the system notification. This covers the common
|
||||
// Android case where the socket appears online but is silently dead
|
||||
// after the PWA was backgrounded (OS kills WebSocket before ping timeout).
|
||||
sendPushToUser(schema, m.user_id, {
|
||||
title: senderName,
|
||||
body: msgBody,
|
||||
url: '/', groupId, badge: 1,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (group.type === 'public') {
|
||||
// Push to all users who have a push subscription — everyone is implicitly
|
||||
// a member of every public group. Skip the sender.
|
||||
const subUsers = await query(schema,
|
||||
'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1',
|
||||
[userId]
|
||||
);
|
||||
for (const sub of subUsers) {
|
||||
sendPushToUser(schema, sub.user_id, {
|
||||
title: `${senderName} in ${group.name}`,
|
||||
body: msgBody,
|
||||
url: '/', groupId, badge: 1,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle reaction — one reaction per user; same emoji toggles off, different emoji replaces
|
||||
socket.on('reaction:toggle', (data) => {
|
||||
const { messageId, emoji } = data;
|
||||
const db = getDb();
|
||||
const message = db.prepare('SELECT m.*, g.id as gid FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ? AND m.is_deleted = 0').get(messageId);
|
||||
if (!message) return;
|
||||
// @mention notifications
|
||||
if (content) {
|
||||
const mentionNames = [...new Set((content.match(/@\[([^\]]+)\]/g) || []).map(m => m.slice(2, -1)))];
|
||||
for (const mentionName of mentionNames) {
|
||||
const mentioned = await queryOne(schema,
|
||||
"SELECT id FROM users WHERE status='active' AND (LOWER(display_name)=LOWER($1) OR LOWER(name)=LOWER($1))",
|
||||
[mentionName]
|
||||
);
|
||||
if (!mentioned || mentioned.id === userId) continue;
|
||||
|
||||
// Find any existing reaction by this user on this message
|
||||
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ?').get(messageId, userId);
|
||||
|
||||
if (existing) {
|
||||
if (existing.emoji === emoji) {
|
||||
// Same emoji — toggle off (remove)
|
||||
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
|
||||
} else {
|
||||
// Different emoji — replace
|
||||
db.prepare('UPDATE reactions SET emoji = ? WHERE id = ?').run(emoji, existing.id);
|
||||
const nr = await queryResult(schema,
|
||||
"INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id) VALUES ($1,'mention',$2,$3,$4) RETURNING id",
|
||||
[mentioned.id, msgId, groupId, userId]
|
||||
);
|
||||
const notif = { id: nr.rows[0].id, type: 'mention', groupId, messageId: msgId, fromUser: socket.user };
|
||||
const mentionedKey = `${schema}:${mentioned.id}`;
|
||||
if (onlineUsers.has(mentionedKey)) {
|
||||
for (const sid of onlineUsers.get(mentionedKey)) io.to(sid).emit('notification:new', notif);
|
||||
}
|
||||
const senderName = socket.user.display_name || socket.user.name || 'Someone';
|
||||
sendPushToUser(schema, mentioned.id, {
|
||||
title: `${senderName} mentioned you`,
|
||||
body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100),
|
||||
url: '/', badge: 1,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No existing reaction — insert
|
||||
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(messageId, userId, emoji);
|
||||
} catch (e) {
|
||||
console.error('[Socket] message:send error:', e.message);
|
||||
}
|
||||
|
||||
const reactions = db.prepare(`
|
||||
SELECT r.emoji, r.user_id, u.name as user_name
|
||||
FROM reactions r JOIN users u ON r.user_id = u.id
|
||||
WHERE r.message_id = ?
|
||||
`).all(messageId);
|
||||
|
||||
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId, reactions });
|
||||
});
|
||||
|
||||
// Handle message delete
|
||||
socket.on('message:delete', (data) => {
|
||||
const { messageId } = data;
|
||||
const db = getDb();
|
||||
const message = db.prepare('SELECT m.*, g.type as group_type, g.owner_id as group_owner_id FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?').get(messageId);
|
||||
if (!message) return;
|
||||
// ── Reaction toggle ─────────────────────────────────────────────────────────
|
||||
socket.on('reaction:toggle', async ({ messageId, emoji }) => {
|
||||
try {
|
||||
const message = await queryOne(schema,
|
||||
'SELECT m.*, g.id AS gid FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1 AND m.is_deleted=FALSE',
|
||||
[messageId]
|
||||
);
|
||||
if (!message) return;
|
||||
|
||||
const canDelete = message.user_id === userId ||
|
||||
(socket.user.role === 'admin' && message.group_type === 'public') ||
|
||||
(message.group_type === 'private' && message.group_owner_id === userId);
|
||||
const existing = await queryOne(schema,
|
||||
'SELECT * FROM reactions WHERE message_id=$1 AND user_id=$2',
|
||||
[messageId, userId]
|
||||
);
|
||||
|
||||
if (!canDelete) return;
|
||||
if (existing) {
|
||||
if (existing.emoji === emoji) {
|
||||
await exec(schema, 'DELETE FROM reactions WHERE id=$1', [existing.id]);
|
||||
} else {
|
||||
await exec(schema, 'UPDATE reactions SET emoji=$1 WHERE id=$2', [emoji, existing.id]);
|
||||
}
|
||||
} else {
|
||||
await exec(schema,
|
||||
'INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1,$2,$3)',
|
||||
[messageId, userId, emoji]
|
||||
);
|
||||
}
|
||||
|
||||
db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(messageId);
|
||||
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId, groupId: message.group_id });
|
||||
const reactions = await query(schema, `
|
||||
SELECT r.emoji, r.user_id, u.name AS user_name
|
||||
FROM reactions r JOIN users u ON r.user_id=u.id
|
||||
WHERE r.message_id=$1
|
||||
`, [messageId]);
|
||||
|
||||
io.to(R('group', message.group_id)).emit('reaction:updated', { messageId, reactions });
|
||||
} catch (e) {
|
||||
console.error('[Socket] reaction:toggle error:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle typing
|
||||
// ── Message delete ──────────────────────────────────────────────────────────
|
||||
socket.on('message:delete', async ({ messageId }) => {
|
||||
try {
|
||||
const message = await queryOne(schema, `
|
||||
SELECT m.*, g.type AS group_type, g.owner_id AS group_owner_id, g.is_direct
|
||||
FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1
|
||||
`, [messageId]);
|
||||
if (!message) return;
|
||||
|
||||
const isAdmin = socket.user.role === 'admin';
|
||||
const isOwner = message.group_owner_id === userId;
|
||||
const isAuthor = message.user_id === userId;
|
||||
let canDelete = isAuthor || isOwner;
|
||||
|
||||
if (!canDelete && isAdmin) {
|
||||
if (message.group_type === 'public') {
|
||||
canDelete = true;
|
||||
} else {
|
||||
const membership = await queryOne(schema,
|
||||
'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2',
|
||||
[message.group_id, userId]
|
||||
);
|
||||
if (membership) canDelete = true;
|
||||
}
|
||||
}
|
||||
if (!canDelete) return;
|
||||
|
||||
await exec(schema,
|
||||
'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE id=$1',
|
||||
[messageId]
|
||||
);
|
||||
io.to(R('group', message.group_id)).emit('message:deleted', { messageId, groupId: message.group_id });
|
||||
} catch (e) {
|
||||
console.error('[Socket] message:delete error:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Typing indicators ───────────────────────────────────────────────────────
|
||||
socket.on('typing:start', ({ groupId }) => {
|
||||
socket.to(`group:${groupId}`).emit('typing:start', { userId, groupId, user: socket.user });
|
||||
socket.to(R('group', groupId)).emit('typing:start', { userId, groupId, user: socket.user });
|
||||
});
|
||||
socket.on('typing:stop', ({ groupId }) => {
|
||||
socket.to(`group:${groupId}`).emit('typing:stop', { userId, groupId });
|
||||
socket.to(R('group', groupId)).emit('typing:stop', { userId, groupId });
|
||||
});
|
||||
|
||||
// Get online users
|
||||
socket.on('users:online', () => {
|
||||
socket.emit('users:online', { userIds: [...onlineUsers.keys()] });
|
||||
// Return only the user IDs for this tenant by filtering keys matching this schema prefix
|
||||
const prefix = `${schema}:`;
|
||||
const userIds = [...onlineUsers.keys()]
|
||||
.filter(k => k.startsWith(prefix))
|
||||
.map(k => parseInt(k.slice(prefix.length), 10));
|
||||
socket.emit('users:online', { userIds });
|
||||
});
|
||||
|
||||
// Handle disconnect
|
||||
// ── Disconnect ──────────────────────────────────────────────────────────────
|
||||
socket.on('disconnect', () => {
|
||||
if (onlineUsers.has(userId)) {
|
||||
onlineUsers.get(userId).delete(socket.id);
|
||||
if (onlineUsers.get(userId).size === 0) {
|
||||
onlineUsers.delete(userId);
|
||||
io.emit('user:offline', { userId });
|
||||
if (onlineUsers.has(onlineKey)) {
|
||||
onlineUsers.get(onlineKey).delete(socket.id);
|
||||
if (onlineUsers.get(onlineKey).size === 0) {
|
||||
onlineUsers.delete(onlineKey);
|
||||
exec(schema, 'UPDATE users SET last_online=NOW() WHERE id=$1', [userId]).catch(() => {});
|
||||
io.to(R('schema', 'all')).emit('user:offline', { userId });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`TeamChat server running on port ${PORT}`);
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
initDb().then(async () => {
|
||||
if (APP_TYPE === 'host') {
|
||||
try {
|
||||
const tenants = await query('public', "SELECT * FROM tenants WHERE status='active'");
|
||||
refreshTenantCache(tenants);
|
||||
console.log(`[Server] Loaded ${tenants.length} tenant(s) into domain cache`);
|
||||
} catch (e) {
|
||||
console.warn('[Server] Could not load tenant cache:', e.message);
|
||||
}
|
||||
}
|
||||
server.listen(PORT, () => console.log(`[Server] RosterChirp listening on port ${PORT}`));
|
||||
}).catch(err => {
|
||||
console.error('[Server] DB init failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = { io };
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { getDb } = require('../models/db');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { query, queryOne, exec } = require('../models/db');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret';
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
function getDeviceClass(ua) {
|
||||
if (!ua) return 'desktop';
|
||||
const s = ua.toLowerCase();
|
||||
if (/mobile|android(?!.*tablet)|iphone|ipod|blackberry|windows phone|opera mini|silk/.test(s)) return 'mobile';
|
||||
if (/tablet|ipad|kindle|playbook|android/.test(s)) return 'mobile';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
async function authMiddleware(req, res, next) {
|
||||
const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token;
|
||||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active');
|
||||
const user = await queryOne(req.schema,
|
||||
"SELECT * FROM users WHERE id = $1 AND status = 'active'", [decoded.id]
|
||||
);
|
||||
if (!user) return res.status(401).json({ error: 'User not found or suspended' });
|
||||
req.user = user;
|
||||
const session = await queryOne(req.schema,
|
||||
'SELECT * FROM active_sessions WHERE user_id = $1 AND token = $2', [decoded.id, token]
|
||||
);
|
||||
if (!session) return res.status(401).json({ error: 'Session expired. Please log in again.' });
|
||||
req.user = user;
|
||||
req.token = token;
|
||||
req.device = session.device;
|
||||
next();
|
||||
} catch (e) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
@@ -24,8 +38,57 @@ function adminMiddleware(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
async function teamManagerMiddleware(req, res, next) {
|
||||
if (req.user?.role === 'admin' || req.user?.role === 'manager') return next();
|
||||
try {
|
||||
const tmSetting = await queryOne(req.schema,
|
||||
"SELECT value FROM settings WHERE key = 'team_tool_managers'"
|
||||
);
|
||||
const gmSetting = await queryOne(req.schema,
|
||||
"SELECT value FROM settings WHERE key = 'team_group_managers'"
|
||||
);
|
||||
const allowedGroupIds = [
|
||||
...new Set([
|
||||
...JSON.parse(tmSetting?.value || '[]'),
|
||||
...JSON.parse(gmSetting?.value || '[]'),
|
||||
])
|
||||
];
|
||||
if (allowedGroupIds.length === 0) return res.status(403).json({ error: 'Access denied' });
|
||||
const placeholders = allowedGroupIds.map((_, i) => `$${i + 2}`).join(',');
|
||||
const member = await queryOne(req.schema,
|
||||
`SELECT 1 FROM user_group_members WHERE user_id = $1 AND user_group_id IN (${placeholders})`,
|
||||
[req.user.id, ...allowedGroupIds]
|
||||
);
|
||||
if (!member) return res.status(403).json({ error: 'Access denied' });
|
||||
next();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
function generateToken(userId) {
|
||||
return jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '30d' });
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, adminMiddleware, generateToken };
|
||||
async function setActiveSession(schema, userId, token, userAgent) {
|
||||
const device = getDeviceClass(userAgent);
|
||||
await exec(schema, `
|
||||
INSERT INTO active_sessions (user_id, device, token, ua, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (user_id, device) DO UPDATE SET token = $3, ua = $4, created_at = NOW()
|
||||
`, [userId, device, token, userAgent || null]);
|
||||
return device;
|
||||
}
|
||||
|
||||
async function clearActiveSession(schema, userId, device) {
|
||||
if (device) {
|
||||
await exec(schema, 'DELETE FROM active_sessions WHERE user_id = $1 AND device = $2', [userId, device]);
|
||||
} else {
|
||||
await exec(schema, 'DELETE FROM active_sessions WHERE user_id = $1', [userId]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authMiddleware, adminMiddleware, teamManagerMiddleware,
|
||||
generateToken, setActiveSession, clearActiveSession, getDeviceClass,
|
||||
};
|
||||
|
||||
@@ -1,242 +1,472 @@
|
||||
const Database = require('better-sqlite3');
|
||||
/**
|
||||
* db.js — Postgres database layer for rosterchirp
|
||||
*
|
||||
* APP_TYPE environment variable controls tenancy:
|
||||
* selfhost (default) → single schema 'public', one Postgres database
|
||||
* host → one schema per tenant, derived from HTTP Host header
|
||||
*
|
||||
* All routes call: query(req.schema, sql, $params)
|
||||
* req.schema is set by tenantMiddleware before any route handler runs.
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/app/data/teamchat.db';
|
||||
|
||||
let db;
|
||||
|
||||
function getDb() {
|
||||
if (!db) {
|
||||
// Ensure the data directory exists before opening the DB
|
||||
const dir = path.dirname(DB_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`[DB] Created data directory: ${dir}`);
|
||||
}
|
||||
db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
console.log(`[DB] Opened database at ${DB_PATH}`);
|
||||
// APP_TYPE validation — host mode requires APP_DOMAIN, HOST_SLUG, and HOST_ADMIN_KEY.
|
||||
// If any are missing, fall back to selfhost and warn rather than silently
|
||||
// exposing a broken or insecure host control plane.
|
||||
let APP_TYPE = (process.env.APP_TYPE || 'selfhost').toLowerCase().trim();
|
||||
if (APP_TYPE === 'host') {
|
||||
if (!process.env.APP_DOMAIN || !process.env.HOST_SLUG || !process.env.HOST_ADMIN_KEY) {
|
||||
console.warn('[DB] WARNING: APP_TYPE=host requires APP_DOMAIN, HOST_SLUG, and HOST_ADMIN_KEY to be set.');
|
||||
console.warn('[DB] WARNING: Falling back to APP_TYPE=selfhost for safety.');
|
||||
APP_TYPE = 'selfhost';
|
||||
}
|
||||
return db;
|
||||
}
|
||||
if (APP_TYPE !== 'host') APP_TYPE = 'selfhost'; // only two valid values
|
||||
|
||||
// ── Connection pool ───────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[DB] Unexpected pool error:', err.message);
|
||||
});
|
||||
|
||||
// ── Schema resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
const tenantDomainCache = new Map();
|
||||
|
||||
function resolveSchema(req) {
|
||||
if (APP_TYPE === 'selfhost') return 'public';
|
||||
|
||||
const host = (req.headers.host || '').toLowerCase().split(':')[0];
|
||||
const baseDomain = (process.env.APP_DOMAIN || 'rosterchirp.com').toLowerCase();
|
||||
const hostSlug = (process.env.HOST_SLUG || 'host').toLowerCase();
|
||||
const hostControlDomain = `${hostSlug}.${baseDomain}`;
|
||||
|
||||
// Internal requests (Docker health checks, localhost) → public schema
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
|
||||
|
||||
// Host control panel subdomain: chathost.example.com → public schema
|
||||
if (host === hostControlDomain) return 'public';
|
||||
|
||||
// Tenant subdomain: mychat.example.com → tenant_mychat
|
||||
if (host.endsWith(`.${baseDomain}`)) {
|
||||
const slug = host.slice(0, -(baseDomain.length + 1));
|
||||
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`);
|
||||
return `tenant_${slug.replace(/[^a-z0-9]/g, '_')}`;
|
||||
}
|
||||
|
||||
// Custom domain lookup (populated from host admin DB)
|
||||
if (tenantDomainCache.has(host)) return tenantDomainCache.get(host);
|
||||
|
||||
throw new Error(`Unknown tenant for host: ${host}`);
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
const db = getDb();
|
||||
function refreshTenantCache(tenants) {
|
||||
tenantDomainCache.clear();
|
||||
for (const t of tenants) {
|
||||
if (t.custom_domain) {
|
||||
tenantDomainCache.set(t.custom_domain.toLowerCase(), `tenant_${t.slug}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
is_default_admin INTEGER NOT NULL DEFAULT 0,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 1,
|
||||
avatar TEXT,
|
||||
about_me TEXT,
|
||||
display_name TEXT,
|
||||
hide_admin_tag INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
// ── Schema name safety guard ──────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'public',
|
||||
owner_id INTEGER,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
is_readonly INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id)
|
||||
);
|
||||
function assertSafeSchema(schema) {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) {
|
||||
throw new Error(`Unsafe schema name rejected: ${schema}`);
|
||||
}
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(group_id, user_id),
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
// ── Core query helpers ────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
content TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'text',
|
||||
image_url TEXT,
|
||||
reply_to_id INTEGER,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
link_preview TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (reply_to_id) REFERENCES messages(id)
|
||||
);
|
||||
async function query(schema, sql, params = []) {
|
||||
assertSafeSchema(schema);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
const result = await client.query(sql, params);
|
||||
return result.rows;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(message_id, user_id, emoji),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
async function queryOne(schema, sql, params = []) {
|
||||
const rows = await query(schema, sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
message_id INTEGER,
|
||||
group_id INTEGER,
|
||||
from_user_id INTEGER,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
async function queryResult(schema, sql, params = []) {
|
||||
assertSafeSchema(schema);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
return await client.query(sql, params);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
async function exec(schema, sql, params = []) {
|
||||
await query(schema, sql, params);
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
async function withTransaction(schema, callback) {
|
||||
assertSafeSchema(schema);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
await client.query('BEGIN');
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration runner ──────────────────────────────────────────────────────────
|
||||
|
||||
async function ensureSchema(schema) {
|
||||
assertSafeSchema(schema);
|
||||
// Use a direct client outside of search_path for schema creation
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function runMigrations(schema) {
|
||||
await ensureSchema(schema);
|
||||
|
||||
await exec(schema, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// Initialize default settings
|
||||
const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
||||
insertSetting.run('app_name', process.env.APP_NAME || 'TeamChat');
|
||||
insertSetting.run('logo_url', '');
|
||||
insertSetting.run('pw_reset_active', process.env.PW_RESET === 'true' ? 'true' : 'false');
|
||||
insertSetting.run('icon_newchat', '');
|
||||
insertSetting.run('icon_groupinfo', '');
|
||||
insertSetting.run('pwa_icon_192', '');
|
||||
insertSetting.run('pwa_icon_512', '');
|
||||
const applied = await query(schema, 'SELECT version FROM schema_migrations ORDER BY version');
|
||||
const appliedSet = new Set(applied.map(r => r.version));
|
||||
|
||||
// Migration: add hide_admin_tag if upgrading from older version
|
||||
try {
|
||||
db.exec("ALTER TABLE users ADD COLUMN hide_admin_tag INTEGER NOT NULL DEFAULT 0");
|
||||
console.log('[DB] Migration: added hide_admin_tag column');
|
||||
} catch (e) { /* column already exists */ }
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
const files = fs.readdirSync(migrationsDir)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
console.log('[DB] Schema initialized');
|
||||
return db;
|
||||
for (const file of files) {
|
||||
const m = file.match(/^(\d+)_/);
|
||||
if (!m) continue;
|
||||
const version = parseInt(m[1]);
|
||||
if (appliedSet.has(version)) continue;
|
||||
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
||||
console.log(`[DB:${schema}] Applying migration ${version}: ${file}`);
|
||||
|
||||
await withTransaction(schema, async (client) => {
|
||||
await client.query(sql);
|
||||
await client.query(
|
||||
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
|
||||
[version, file]
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`[DB:${schema}] Migration ${version} done`);
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin() {
|
||||
const db = getDb();
|
||||
// ── Seeding ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Strip any surrounding quotes from env vars (common docker-compose mistake)
|
||||
const adminEmail = (process.env.ADMIN_EMAIL || 'admin@teamchat.local').replace(/^["']|["']$/g, '').trim();
|
||||
const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim();
|
||||
const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim();
|
||||
const pwReset = process.env.PW_RESET === 'true';
|
||||
async function seedSettings(schema) {
|
||||
const defaults = [
|
||||
['app_name', process.env.APP_NAME || 'rosterchirp'],
|
||||
['logo_url', ''],
|
||||
['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'],
|
||||
['icon_newchat', ''],
|
||||
['icon_groupinfo', ''],
|
||||
['pwa_icon_192', ''],
|
||||
['pwa_icon_512', ''],
|
||||
['color_title', ''],
|
||||
['color_title_dark', ''],
|
||||
['color_avatar_public', ''],
|
||||
['color_avatar_dm', ''],
|
||||
['registration_code', ''],
|
||||
['feature_branding', 'false'],
|
||||
['feature_group_manager', 'false'],
|
||||
['feature_schedule_manager', 'false'],
|
||||
['app_type', 'RosterChirp-Chat'],
|
||||
['team_group_managers', ''],
|
||||
['team_schedule_managers', ''],
|
||||
['team_tool_managers', ''],
|
||||
];
|
||||
for (const [key, value] of defaults) {
|
||||
await exec(schema,
|
||||
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DB] Checking for default admin (${adminEmail})...`);
|
||||
async function seedEventTypes(schema) {
|
||||
await exec(schema, `
|
||||
INSERT INTO event_types (name, colour, is_default, is_protected, default_duration_hrs)
|
||||
VALUES ('Event', '#6366f1', TRUE, TRUE, 1.0)
|
||||
ON CONFLICT (name) DO UPDATE SET is_default=TRUE, is_protected=TRUE, default_duration_hrs=1.0
|
||||
`);
|
||||
await exec(schema,
|
||||
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0) ON CONFLICT (name) DO NOTHING"
|
||||
);
|
||||
await exec(schema,
|
||||
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0) ON CONFLICT (name) DO NOTHING"
|
||||
);
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT * FROM users WHERE is_default_admin = 1').get();
|
||||
async function seedUserGroups(schema) {
|
||||
// Seed three default user groups with their associated DM groups.
|
||||
// Uses ON CONFLICT DO NOTHING so re-runs on existing installs are safe.
|
||||
const defaults = ['Coaches', 'Players', 'Parents'];
|
||||
for (const name of defaults) {
|
||||
// Skip if a group with this name already exists
|
||||
const existing = await queryOne(schema,
|
||||
'SELECT id FROM user_groups WHERE name = $1', [name]
|
||||
);
|
||||
if (existing) {
|
||||
// Auto-configure feature settings if not already set
|
||||
if (name === 'Players') {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key, value) VALUES ('feature_players_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||
[existing.id.toString()]
|
||||
);
|
||||
} else if (name === 'Parents') {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key, value) VALUES ('feature_guardians_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||
[existing.id.toString()]
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the managed DM chat group first
|
||||
const gr = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, is_readonly, is_managed) VALUES ($1, 'private', FALSE, TRUE) RETURNING id",
|
||||
[name]
|
||||
);
|
||||
const dmGroupId = gr.rows[0].id;
|
||||
|
||||
// Create the user group linked to the DM group
|
||||
const ugr = await queryResult(schema,
|
||||
'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING id',
|
||||
[name, dmGroupId]
|
||||
);
|
||||
const ugId = ugr.rows[0]?.id;
|
||||
console.log(`[DB:${schema}] Default user group created: ${name}`);
|
||||
|
||||
// Auto-configure feature settings for players/parents groups
|
||||
if (ugId && name === 'Players') {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key, value) VALUES ('feature_players_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||
[ugId.toString()]
|
||||
);
|
||||
} else if (ugId && name === 'Parents') {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key, value) VALUES ('feature_guardians_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||
[ugId.toString()]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAdmin(schema) {
|
||||
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
||||
const adminEmail = (strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local').toLowerCase();
|
||||
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
|
||||
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
||||
const pwReset = process.env.ADMPW_RESET === 'true';
|
||||
|
||||
console.log(`[DB:${schema}] Checking for default admin (${adminEmail})...`);
|
||||
|
||||
const existing = await queryOne(schema,
|
||||
'SELECT * FROM users WHERE is_default_admin = TRUE'
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
const hash = bcrypt.hashSync(adminPass, 10);
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password)
|
||||
VALUES (?, ?, ?, 'admin', 'active', 1, 1)
|
||||
`).run(adminName, adminEmail, hash);
|
||||
const hash = bcrypt.hashSync(adminPass, 10);
|
||||
const ur = await queryResult(schema, `
|
||||
INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password, avatar)
|
||||
VALUES ($1, $2, $3, 'admin', 'active', TRUE, TRUE, '/avatar/admin.png') RETURNING id
|
||||
`, [adminName, adminEmail, hash]);
|
||||
const adminId = ur.rows[0].id;
|
||||
|
||||
console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`);
|
||||
const chatName = strip(process.env.DEFCHAT_NAME) || 'General Chat';
|
||||
const gr = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, is_default, owner_id) VALUES ($1, 'public', TRUE, $2) RETURNING id",
|
||||
[chatName, adminId]
|
||||
);
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[gr.rows[0].id, adminId]
|
||||
);
|
||||
|
||||
// Create default TeamChat group
|
||||
const groupResult = db.prepare(`
|
||||
INSERT INTO groups (name, type, is_default, owner_id)
|
||||
VALUES ('TeamChat', 'public', 1, ?)
|
||||
`).run(result.lastInsertRowid);
|
||||
const sr = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support', 'private', $1, FALSE) RETURNING id",
|
||||
[adminId]
|
||||
);
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[sr.rows[0].id, adminId]
|
||||
);
|
||||
|
||||
// Add admin to default group
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)')
|
||||
.run(groupResult.lastInsertRowid, result.lastInsertRowid);
|
||||
|
||||
console.log('[DB] Default TeamChat group created');
|
||||
seedSupportGroup();
|
||||
} catch (err) {
|
||||
console.error('[DB] ERROR creating default admin:', err.message);
|
||||
}
|
||||
console.log(`[DB:${schema}] Default admin + groups created`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DB] Default admin already exists (id=${existing.id})`);
|
||||
|
||||
// Handle PW_RESET
|
||||
console.log(`[DB:${schema}] Default admin exists (id=${existing.id})`);
|
||||
// Always ensure admin has the fixed avatar
|
||||
await exec(schema,
|
||||
"UPDATE users SET avatar='/avatar/admin.png', updated_at=NOW() WHERE is_default_admin=TRUE AND (avatar IS NULL OR avatar != '/avatar/admin.png')"
|
||||
);
|
||||
if (pwReset) {
|
||||
const hash = bcrypt.hashSync(adminPass, 10);
|
||||
db.prepare(`
|
||||
UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now')
|
||||
WHERE is_default_admin = 1
|
||||
`).run(hash);
|
||||
db.prepare("UPDATE settings SET value = 'true', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
|
||||
console.log('[DB] Admin password reset via PW_RESET=true');
|
||||
await exec(schema,
|
||||
"UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE is_default_admin=TRUE",
|
||||
[hash]
|
||||
);
|
||||
await exec(schema, "UPDATE settings SET value='true', updated_at=NOW() WHERE key='pw_reset_active'");
|
||||
console.log(`[DB:${schema}] Admin password reset`);
|
||||
} else {
|
||||
db.prepare("UPDATE settings SET value = 'false', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
|
||||
await exec(schema, "UPDATE settings SET value='false', updated_at=NOW() WHERE key='pw_reset_active'");
|
||||
}
|
||||
}
|
||||
|
||||
function addUserToPublicGroups(userId) {
|
||||
const db = getDb();
|
||||
const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all();
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||
for (const g of publicGroups) {
|
||||
insert.run(g.id, userId);
|
||||
// ── Main init (called on server startup) ─────────────────────────────────────
|
||||
|
||||
async function initDb() {
|
||||
// Wait for Postgres to be ready (up to 30s)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
console.log('[DB] Connected to Postgres');
|
||||
break;
|
||||
} catch (e) {
|
||||
console.log(`[DB] Waiting for Postgres... (${i + 1}/30)`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
await runMigrations('public');
|
||||
await seedSettings('public');
|
||||
await seedEventTypes('public');
|
||||
await seedAdmin('public');
|
||||
await seedUserGroups('public');
|
||||
|
||||
// Host mode: run migrations on all existing tenant schemas so new migrations
|
||||
// (e.g. 007_fcm_push) are applied to tenants that were created before the migration existed.
|
||||
if (APP_TYPE === 'host') {
|
||||
const tenantResult = await pool.query(
|
||||
"SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%'"
|
||||
);
|
||||
for (const row of tenantResult.rows) {
|
||||
console.log(`[DB] Running migrations for tenant schema: ${row.schema_name}`);
|
||||
await runMigrations(row.schema_name);
|
||||
await seedSettings(row.schema_name);
|
||||
await seedEventTypes(row.schema_name);
|
||||
await seedUserGroups(row.schema_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Host mode: the public schema is the host's own workspace — always full RosterChirp-Team plan.
|
||||
// ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too.
|
||||
if (APP_TYPE === 'host') {
|
||||
const hostPlan = [
|
||||
['app_type', 'RosterChirp-Team'],
|
||||
['feature_branding', 'true'],
|
||||
['feature_group_manager', 'true'],
|
||||
['feature_schedule_manager', 'true'],
|
||||
];
|
||||
for (const [key, value] of hostPlan) {
|
||||
await exec('public',
|
||||
'INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value=$2, updated_at=NOW()',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
console.log('[DB] Host mode: public schema upgraded to RosterChirp-Team plan');
|
||||
}
|
||||
|
||||
console.log('[DB] Initialisation complete');
|
||||
}
|
||||
|
||||
// ── Helper functions used by routes ──────────────────────────────────────────
|
||||
|
||||
async function addUserToPublicGroups(schema, userId) {
|
||||
const groups = await query(schema, "SELECT id FROM groups WHERE type = 'public'");
|
||||
for (const g of groups) {
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[g.id, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedSupportGroup() {
|
||||
const db = getDb();
|
||||
async function getOrCreateSupportGroup(schema) {
|
||||
const g = await queryOne(schema, "SELECT id FROM groups WHERE name='Support' AND type='private'");
|
||||
if (g) return g.id;
|
||||
|
||||
// Create the Support group if it doesn't exist
|
||||
const existing = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get();
|
||||
if (existing) return existing.id;
|
||||
|
||||
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
|
||||
const admin = await queryOne(schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
|
||||
if (!admin) return null;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO groups (name, type, owner_id, is_default)
|
||||
VALUES ('Support', 'private', ?, 0)
|
||||
`).run(admin.id);
|
||||
|
||||
const groupId = result.lastInsertRowid;
|
||||
|
||||
// Add all current admins to the Support group
|
||||
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||
for (const a of admins) insert.run(groupId, a.id);
|
||||
|
||||
console.log('[DB] Support group created');
|
||||
const r = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support','private',$1,FALSE) RETURNING id",
|
||||
[admin.id]
|
||||
);
|
||||
const groupId = r.rows[0].id;
|
||||
const admins = await query(schema, "SELECT id FROM users WHERE role='admin' AND status='active'");
|
||||
for (const a of admins) {
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[groupId, a.id]
|
||||
);
|
||||
}
|
||||
return groupId;
|
||||
}
|
||||
|
||||
function getOrCreateSupportGroup() {
|
||||
const db = getDb();
|
||||
const group = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get();
|
||||
if (group) return group.id;
|
||||
return seedSupportGroup();
|
||||
// ── Tenant middleware ─────────────────────────────────────────────────────────
|
||||
|
||||
function tenantMiddleware(req, res, next) {
|
||||
try {
|
||||
req.schema = resolveSchema(req);
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('[Tenant]', err.message);
|
||||
res.status(404).json({ error: 'Unknown tenant' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getDb, initDb, seedAdmin, seedSupportGroup, getOrCreateSupportGroup, addUserToPublicGroups };
|
||||
module.exports = {
|
||||
query, queryOne, queryResult, exec, withTransaction,
|
||||
initDb, runMigrations, ensureSchema,
|
||||
tenantMiddleware, resolveSchema, refreshTenantCache,
|
||||
APP_TYPE, pool,
|
||||
addUserToPublicGroups, getOrCreateSupportGroup,
|
||||
seedSettings, seedEventTypes, seedAdmin, seedUserGroups,
|
||||
};
|
||||
|
||||
213
backend/src/models/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,213 @@
|
||||
-- Migration 001: Initial schema
|
||||
-- Converts all SQLite tables to Postgres-native types.
|
||||
-- TIMESTAMPTZ replaces TEXT for dates.
|
||||
-- SERIAL replaces AUTOINCREMENT.
|
||||
-- Constraints use Postgres syntax throughout.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
is_default_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
avatar TEXT,
|
||||
about_me TEXT,
|
||||
display_name TEXT,
|
||||
hide_admin_tag BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
allow_dm BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_online TIMESTAMPTZ,
|
||||
help_dismissed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'public',
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_readonly BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_direct BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
direct_peer1_id INTEGER,
|
||||
direct_peer2_id INTEGER,
|
||||
is_managed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_multi_group BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 NOT NULL DEFAULT 'text',
|
||||
image_url TEXT,
|
||||
reply_to_id INTEGER REFERENCES messages(id),
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
link_preview TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device TEXT NOT NULL DEFAULT 'desktop',
|
||||
token TEXT NOT NULL,
|
||||
ua TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, device)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
device TEXT NOT NULL DEFAULT 'desktop',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, device)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group_names (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pinned_conversations (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dm_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS multi_group_dms (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dm_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS multi_group_dm_members (
|
||||
multi_group_dm_id INTEGER NOT NULL REFERENCES multi_group_dms(id) ON DELETE CASCADE,
|
||||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (multi_group_dm_id, user_group_id)
|
||||
);
|
||||
|
||||
-- ── Schedule Manager ──────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
colour TEXT NOT NULL DEFAULT '#6366f1',
|
||||
default_user_group_id INTEGER REFERENCES user_groups(id) ON DELETE SET NULL,
|
||||
default_duration_hrs NUMERIC,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_protected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
event_type_id INTEGER REFERENCES event_types(id) ON DELETE SET NULL,
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
location TEXT,
|
||||
description TEXT,
|
||||
is_public BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
track_availability BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recurrence_rule JSONB,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_user_groups (
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (event_id, user_group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_availability (
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (event_id, user_id)
|
||||
);
|
||||
|
||||
-- ── Indexes for common query patterns ────────────────────────────────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_group_id ON messages(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_start_at ON events(start_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_by ON events(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id);
|
||||
96
backend/src/models/migrations/002_triggers_and_indexes.sql
Normal file
@@ -0,0 +1,96 @@
|
||||
-- Migration 002: updated_at auto-trigger + additional indexes
|
||||
--
|
||||
-- Adds a reusable Postgres trigger function that automatically sets
|
||||
-- updated_at = NOW() on any UPDATE, eliminating the need to set it
|
||||
-- manually in every route. Also adds a few missing indexes.
|
||||
|
||||
-- ── Auto-updated_at trigger function ─────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Apply to all tables that have an updated_at column
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_users_updated_at') THEN
|
||||
CREATE TRIGGER trg_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_groups_updated_at') THEN
|
||||
CREATE TRIGGER trg_groups_updated_at
|
||||
BEFORE UPDATE ON groups
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_settings_updated_at') THEN
|
||||
CREATE TRIGGER trg_settings_updated_at
|
||||
BEFORE UPDATE ON settings
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_user_groups_updated_at') THEN
|
||||
CREATE TRIGGER trg_user_groups_updated_at
|
||||
BEFORE UPDATE ON user_groups
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_multi_group_dms_updated_at') THEN
|
||||
CREATE TRIGGER trg_multi_group_dms_updated_at
|
||||
BEFORE UPDATE ON multi_group_dms
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_events_updated_at') THEN
|
||||
CREATE TRIGGER trg_events_updated_at
|
||||
BEFORE UPDATE ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Additional indexes ────────────────────────────────────────────────────────
|
||||
|
||||
-- Notifications: most queries filter by user + read status
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
|
||||
ON notifications(user_id, is_read)
|
||||
WHERE is_read = FALSE;
|
||||
|
||||
-- Sessions: lookup by user is common on logout / session cleanup
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id
|
||||
ON sessions(user_id);
|
||||
|
||||
-- Active sessions: covered by PK (user_id, device) but explicit for clarity
|
||||
CREATE INDEX IF NOT EXISTS idx_active_sessions_token
|
||||
ON active_sessions(token);
|
||||
|
||||
-- Push subscriptions: lookup by user is the hot path
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user
|
||||
ON push_subscriptions(user_id);
|
||||
|
||||
-- User group members: reverse lookup (which groups is a user in?)
|
||||
CREATE INDEX IF NOT EXISTS idx_user_group_members_user
|
||||
ON user_group_members(user_id);
|
||||
|
||||
-- Event availability: reverse lookup (which events has a user responded to?)
|
||||
CREATE INDEX IF NOT EXISTS idx_event_availability_user
|
||||
ON event_availability(user_id);
|
||||
|
||||
-- Events: filter by created_by (schedule manager views)
|
||||
CREATE INDEX IF NOT EXISTS idx_events_type
|
||||
ON events(event_type_id);
|
||||
31
backend/src/models/migrations/003_tenants.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Migration 003: Tenant registry (JAMA-HOST mode)
|
||||
--
|
||||
-- This table lives in the 'public' schema and is the source of truth for
|
||||
-- all tenants in host mode. In selfhost mode this table exists but stays
|
||||
-- empty — it has no effect on anything.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE, -- used as schema name: tenant_{slug}
|
||||
name TEXT NOT NULL, -- display name
|
||||
schema_name TEXT NOT NULL UNIQUE, -- actual Postgres schema: tenant_{slug}
|
||||
custom_domain TEXT, -- optional: team1.example.com
|
||||
plan TEXT NOT NULL DEFAULT 'chat', -- chat | brand | team
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | suspended
|
||||
admin_email TEXT, -- first admin email for this tenant
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_custom_domain ON tenants(custom_domain) WHERE custom_domain IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);
|
||||
|
||||
-- Auto-update updated_at
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_tenants_updated_at') THEN
|
||||
CREATE TRIGGER trg_tenants_updated_at
|
||||
BEFORE UPDATE ON tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END $$;
|
||||
6
backend/src/models/migrations/004_host_plan.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Migration 004: Host plan feature flags placeholder
|
||||
--
|
||||
-- Feature flag enforcement for APP_TYPE=host is handled in db.js initDb()
|
||||
-- which runs on every startup and upserts the correct values.
|
||||
-- This migration exists as a version marker — no SQL changes needed.
|
||||
SELECT 1;
|
||||
30
backend/src/models/migrations/005_u2u_restrictions.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Migration 005: User-to-user DM restrictions
|
||||
--
|
||||
-- Stores which user groups are blocked from initiating 1-to-1 DMs with
|
||||
-- users in another group. This is an allowlist-by-omission model:
|
||||
-- - No rows for a group = no restrictions (can DM anyone)
|
||||
-- - A row (A, B) = users in group A cannot INITIATE a DM with users in group B
|
||||
--
|
||||
-- Enforcement rules:
|
||||
-- - Restriction is one-way (A→B does not imply B→A)
|
||||
-- - Least-restrictive-wins: if the initiating user is in any group that is
|
||||
-- NOT restricted from the target, the DM is allowed
|
||||
-- - Own group is always exempt (users can DM members of their own groups)
|
||||
-- - Admins are always exempt from all restrictions
|
||||
-- - Existing DMs are preserved when a restriction is added
|
||||
-- - Only 1-to-1 DMs are affected; group chats (3+ people) are always allowed
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_group_dm_restrictions (
|
||||
restricting_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
blocked_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (restricting_group_id, blocked_group_id),
|
||||
-- A group cannot restrict itself (own group is always exempt)
|
||||
CHECK (restricting_group_id != blocked_group_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_restrictions_restricting
|
||||
ON user_group_dm_restrictions(restricting_group_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_restrictions_blocked
|
||||
ON user_group_dm_restrictions(blocked_group_id);
|
||||
58
backend/src/models/migrations/006_scrub_deleted_users.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- Migration 006: Scrub pre-existing deleted users
|
||||
--
|
||||
-- Prior to v0.11.11, deleting a user only set status='deleted' — the original
|
||||
-- email, name, avatar, and messages were left untouched. This meant:
|
||||
-- • The email address was permanently blocked from re-use
|
||||
-- • Message content was still stored and attributable
|
||||
-- • Direct messages were left in an inconsistent half-alive state
|
||||
--
|
||||
-- v0.11.11 introduced proper anonymisation in the delete route, but that only
|
||||
-- applies to users deleted from that point forward. This migration back-fills
|
||||
-- the same treatment for any users already sitting in status='deleted'.
|
||||
--
|
||||
-- Data mutation note: the MIGRATIONS.md convention discourages data changes in
|
||||
-- migrations. This is a deliberate exception — the whole point of this migration
|
||||
-- is to correct orphaned rows that cannot be fixed any other way. The UPDATE
|
||||
-- statements are all guarded by WHERE status='deleted' so they are safe to
|
||||
-- replay against schemas that are already clean.
|
||||
|
||||
-- ── 1. Anonymise deleted user records ────────────────────────────────────────
|
||||
-- Scrub email to deleted_{id}@deleted to free the address for re-use.
|
||||
-- Only touch rows where the email hasn't already been scrubbed (idempotent).
|
||||
UPDATE users
|
||||
SET
|
||||
email = 'deleted_' || id || '@deleted',
|
||||
name = 'Deleted User',
|
||||
display_name = NULL,
|
||||
avatar = NULL,
|
||||
about_me = NULL,
|
||||
password = '',
|
||||
updated_at = NOW()
|
||||
WHERE status = 'deleted'
|
||||
AND email NOT LIKE 'deleted\_%@deleted' ESCAPE '\';
|
||||
|
||||
-- ── 2. Anonymise their messages ───────────────────────────────────────────────
|
||||
-- Mark all non-deleted messages from deleted users as deleted so they render
|
||||
-- as "This message was deleted" rather than remaining attributable.
|
||||
UPDATE messages
|
||||
SET
|
||||
is_deleted = TRUE,
|
||||
content = NULL,
|
||||
image_url = NULL
|
||||
WHERE is_deleted = FALSE
|
||||
AND user_id IN (SELECT id FROM users WHERE status = 'deleted');
|
||||
|
||||
-- ── 3. Freeze their direct messages ──────────────────────────────────────────
|
||||
-- Any 1:1 DM involving a deleted user becomes read-only. The surviving member
|
||||
-- keeps their history but can no longer send into a dead conversation.
|
||||
UPDATE groups
|
||||
SET
|
||||
is_readonly = TRUE,
|
||||
updated_at = NOW()
|
||||
WHERE is_direct = TRUE
|
||||
AND is_readonly = FALSE
|
||||
AND (
|
||||
direct_peer1_id IN (SELECT id FROM users WHERE status = 'deleted')
|
||||
OR
|
||||
direct_peer2_id IN (SELECT id FROM users WHERE status = 'deleted')
|
||||
);
|
||||
5
backend/src/models/migrations/007_fcm_push.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Migration 007: FCM push — add fcm_token column, relax NOT NULL on legacy web-push columns
|
||||
ALTER TABLE push_subscriptions ADD COLUMN IF NOT EXISTS fcm_token TEXT;
|
||||
ALTER TABLE push_subscriptions ALTER COLUMN endpoint DROP NOT NULL;
|
||||
ALTER TABLE push_subscriptions ALTER COLUMN p256dh DROP NOT NULL;
|
||||
ALTER TABLE push_subscriptions ALTER COLUMN auth DROP NOT NULL;
|
||||
4
backend/src/models/migrations/008_rebrand.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Migration 008: Rebrand — update app_type values from JAMA-* to RosterChirp-*
|
||||
UPDATE settings SET value = 'RosterChirp-Chat' WHERE key = 'app_type' AND value = 'JAMA-Chat';
|
||||
UPDATE settings SET value = 'RosterChirp-Brand' WHERE key = 'app_type' AND value = 'JAMA-Brand';
|
||||
UPDATE settings SET value = 'RosterChirp-Team' WHERE key = 'app_type' AND value = 'JAMA-Team';
|
||||
17
backend/src/models/migrations/009_user_profile_fields.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Migration 009: Extended user profile fields
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_minor BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Back-fill first_name / last_name from existing combined name for non-deleted users
|
||||
UPDATE users
|
||||
SET
|
||||
first_name = SPLIT_PART(TRIM(name), ' ', 1),
|
||||
last_name = CASE
|
||||
WHEN POSITION(' ' IN TRIM(name)) > 0
|
||||
THEN NULLIF(TRIM(SUBSTR(TRIM(name), POSITION(' ' IN TRIM(name)) + 1)), '')
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE first_name IS NULL
|
||||
AND TRIM(name) NOT IN ('Deleted User', '');
|
||||
3
backend/src/models/migrations/010_dob_guardian.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Migration 010: Date of birth and guardian fields
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS date_of_birth DATE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||
9
backend/src/models/migrations/011_webpush.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Migration 011: Add Web Push (VAPID) subscription columns for iOS PWA support
|
||||
-- iOS uses the standard W3C Web Push protocol (not FCM). A subscription consists of
|
||||
-- an endpoint URL (web.push.apple.com) plus two crypto keys (p256dh + auth).
|
||||
-- Rows will have either fcm_token set (Android/Chrome) OR the three webpush_* columns
|
||||
-- set (iOS/Firefox/Edge). Never both on the same row.
|
||||
ALTER TABLE push_subscriptions
|
||||
ADD COLUMN IF NOT EXISTS webpush_endpoint TEXT,
|
||||
ADD COLUMN IF NOT EXISTS webpush_p256dh TEXT,
|
||||
ADD COLUMN IF NOT EXISTS webpush_auth TEXT;
|
||||
5
backend/src/models/migrations/012_composite_avatar.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Migration 012: Add composite_members to groups for private group avatar composites
|
||||
-- Stores up to 4 member previews (id, name, avatar) as a JSONB snapshot.
|
||||
-- Only set for non-managed, non-direct private groups with 3+ members.
|
||||
-- Updated only when a member is added and pre-add membership count was ≤3.
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS composite_members JSONB;
|
||||
1
backend/src/models/migrations/013_availability_note.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE event_availability ADD COLUMN IF NOT EXISTS note VARCHAR(20);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Exception instances for recurring events (Google Calendar Series-Instance model)
|
||||
-- recurring_master_id: links a standalone exception instance back to its series master
|
||||
-- original_start_at: the virtual occurrence date/time this instance replaced
|
||||
ALTER TABLE events ADD COLUMN IF NOT EXISTS recurring_master_id INTEGER REFERENCES events(id) ON DELETE CASCADE;
|
||||
ALTER TABLE events ADD COLUMN IF NOT EXISTS original_start_at TIMESTAMPTZ;
|
||||
41
backend/src/models/migrations/015_minor_age_protection.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- 015_minor_age_protection.sql
|
||||
-- Adds tables and columns for Guardian Only and Mixed Age login type modes.
|
||||
|
||||
-- 1. guardian_approval_required on users (Mixed Age: minor needs approval before unsuspend)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_approval_required BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- 2. guardian_aliases — children as name aliases under a guardian (Guardian Only mode)
|
||||
CREATE TABLE IF NOT EXISTS guardian_aliases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guardian_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
date_of_birth DATE,
|
||||
avatar TEXT,
|
||||
phone TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_guardian_aliases_guardian ON guardian_aliases(guardian_id);
|
||||
|
||||
-- 3. alias_group_members — links guardian aliases to user groups (e.g. players group)
|
||||
CREATE TABLE IF NOT EXISTS alias_group_members (
|
||||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_group_id, alias_id)
|
||||
);
|
||||
|
||||
-- 4. event_alias_availability — availability responses for guardian aliases
|
||||
CREATE TABLE IF NOT EXISTS event_alias_availability (
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
|
||||
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
|
||||
note VARCHAR(20),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (event_id, alias_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_alias_availability_event ON event_alias_availability(event_id);
|
||||
16
backend/src/models/migrations/016_guardian_partners.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 016_guardian_partners.sql
|
||||
-- Partner/spouse relationship between guardians.
|
||||
-- Partners share the same child alias list (both can manage it) and can
|
||||
-- respond to events on behalf of each other within shared user groups.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS guardian_partners (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id_1 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id_2 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id_1, user_id_2),
|
||||
CHECK (user_id_1 < user_id_2)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user1 ON guardian_partners(user_id_1);
|
||||
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user2 ON guardian_partners(user_id_2);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 017_partner_respond_separately.sql
|
||||
-- Adds respond_separately flag to guardian_partners.
|
||||
-- When true, linked partners can each respond to events on behalf of children
|
||||
-- in the shared alias list, but cannot respond on behalf of each other.
|
||||
|
||||
ALTER TABLE guardian_partners ADD COLUMN IF NOT EXISTS respond_separately BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
101
backend/src/models/migrations/MIGRATIONS.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# jama Migration Guide
|
||||
|
||||
## How migrations work
|
||||
|
||||
jama uses a simple file-based migration system. On every startup, `db.js` reads
|
||||
all `.sql` files in this directory, sorted by version number, and applies any
|
||||
that haven't been recorded in the `schema_migrations` table.
|
||||
|
||||
Migrations run inside a transaction — if anything fails, the whole migration
|
||||
rolls back and the version is not recorded, so startup will retry it next time.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new migration
|
||||
|
||||
1. Create a new file in this directory named `NNN_description.sql` where `NNN`
|
||||
is the next sequential number (zero-padded to 3 digits):
|
||||
|
||||
```
|
||||
001_initial_schema.sql ← already applied
|
||||
002_add_user_preferences.sql
|
||||
003_add_tenant_table.sql
|
||||
```
|
||||
|
||||
2. Write standard Postgres SQL. Use `IF NOT EXISTS` / `IF EXISTS` guards where
|
||||
possible so migrations are safe to replay:
|
||||
|
||||
```sql
|
||||
-- Add a new column
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
|
||||
|
||||
-- Add a new table
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, key)
|
||||
);
|
||||
|
||||
-- Add an index
|
||||
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
|
||||
```
|
||||
|
||||
3. Deploy. On next startup jama will automatically detect and apply the new
|
||||
migration, logging:
|
||||
|
||||
```
|
||||
[DB:public] Applying migration 2: 002_add_user_preferences.sql
|
||||
[DB:public] Migration 2 done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never edit an applied migration.** Once `001_initial_schema.sql` has been
|
||||
applied to any database, it must not change. Add a new numbered file instead.
|
||||
|
||||
- **Always use `IF NOT EXISTS` / `IF EXISTS`.** This makes migrations safe to
|
||||
run against schemas that may be partially applied (e.g. after a failed deploy).
|
||||
|
||||
- **One logical change per file.** Easier to reason about and roll back mentally.
|
||||
|
||||
- **No data mutations in migrations unless unavoidable.** Seed data lives in
|
||||
`db.js` (`seedSettings`, `seedEventTypes`, `seedAdmin`). Migrations are for
|
||||
schema structure only.
|
||||
|
||||
- **JAMA-HOST:** When a new tenant is provisioned, `runMigrations(schema)` is
|
||||
called on their fresh schema — they get all migrations from `001` onward
|
||||
applied at creation time. Existing tenants get new migrations on the next
|
||||
startup automatically.
|
||||
|
||||
---
|
||||
|
||||
## Checking migration status
|
||||
|
||||
```bash
|
||||
# Connect to the running Postgres container
|
||||
docker compose exec db psql -U jama -d jama
|
||||
|
||||
# See which migrations have been applied
|
||||
SELECT * FROM schema_migrations ORDER BY version;
|
||||
|
||||
# In host mode, check a specific tenant schema
|
||||
SET search_path TO tenant_teamname;
|
||||
SELECT * FROM schema_migrations ORDER BY version;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Emergency rollback
|
||||
|
||||
Migrations do not include automatic down/rollback scripts. If a migration causes
|
||||
problems in production:
|
||||
|
||||
1. Stop the app container: `docker compose stop jama`
|
||||
2. Connect to Postgres and manually reverse the change
|
||||
3. Delete the migration record: `DELETE FROM schema_migrations WHERE version = NNN;`
|
||||
4. Fix the migration file
|
||||
5. Restart: `docker compose start jama`
|
||||
43
backend/src/routes/about.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
|
||||
const ABOUT_FILE = '/app/data/about.json';
|
||||
|
||||
const DEFAULTS = {
|
||||
built_with: 'Node.js · Express · Socket.io · PostgreSQL · React · Vite · Claude.ai',
|
||||
developer: 'Ricky Stretch',
|
||||
license: 'AGPL 3.0',
|
||||
license_url: 'https://www.gnu.org/licenses/agpl-3.0.html',
|
||||
description: 'Self-hosted, privacy-first team messaging.',
|
||||
};
|
||||
|
||||
// GET /api/about — public, no auth required
|
||||
router.get('/', (req, res) => {
|
||||
let overrides = {};
|
||||
try {
|
||||
if (fs.existsSync(ABOUT_FILE)) {
|
||||
const raw = fs.readFileSync(ABOUT_FILE, 'utf8');
|
||||
overrides = JSON.parse(raw);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('about.json parse error:', e.message);
|
||||
}
|
||||
|
||||
// Version always comes from the runtime env (same source as Settings window)
|
||||
const about = {
|
||||
...DEFAULTS,
|
||||
...overrides,
|
||||
version: process.env.ROSTERCHIRP_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
|
||||
// Always expose original app identity — not overrideable via about.json or settings
|
||||
default_app_name: 'rosterchirp',
|
||||
default_logo: '/icons/rosterchirp.png',
|
||||
};
|
||||
|
||||
// Never expose docker_image — removed from UI
|
||||
delete about.docker_image;
|
||||
|
||||
res.json({ about });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,102 +1,103 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const router = express.Router();
|
||||
const { getDb, getOrCreateSupportGroup } = require('../models/db');
|
||||
const { generateToken, authMiddleware } = require('../middleware/auth');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { query, queryOne, queryResult, exec, getOrCreateSupportGroup } = require('../models/db');
|
||||
const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth');
|
||||
|
||||
// Login
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password, rememberMe } = req.body;
|
||||
const db = getDb();
|
||||
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
module.exports = function(io) {
|
||||
const router = express.Router();
|
||||
|
||||
if (user.status === 'suspended') {
|
||||
const adminUser = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
|
||||
return res.status(403).json({
|
||||
error: 'suspended',
|
||||
adminEmail: adminUser?.email
|
||||
});
|
||||
}
|
||||
if (user.status === 'deleted') return res.status(403).json({ error: 'Account not found' });
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
const { email, password, rememberMe } = req.body;
|
||||
try {
|
||||
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' });
|
||||
|
||||
const valid = bcrypt.compareSync(password, user.password);
|
||||
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
if (user.status === 'suspended') {
|
||||
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
|
||||
return res.status(403).json({ error: 'suspended', adminEmail: admin?.email });
|
||||
}
|
||||
if (user.status === 'deleted') return res.status(403).json({ error: 'Account not found' });
|
||||
|
||||
const token = generateToken(user.id);
|
||||
if (!bcrypt.compareSync(password, user.password))
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
const { password: _, ...userSafe } = user;
|
||||
res.json({
|
||||
token,
|
||||
user: userSafe,
|
||||
mustChangePassword: !!user.must_change_password,
|
||||
rememberMe: !!rememberMe
|
||||
const token = generateToken(user.id);
|
||||
const ua = req.headers['user-agent'] || '';
|
||||
const device = await setActiveSession(req.schema, user.id, token, ua);
|
||||
if (io) io.to(R(req.schema,'user',user.id)).emit('session:displaced', { device });
|
||||
|
||||
const { password: _, ...userSafe } = user;
|
||||
res.json({ token, user: userSafe, mustChangePassword: !!user.must_change_password, rememberMe: !!rememberMe });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
});
|
||||
|
||||
// Change password
|
||||
router.post('/change-password', authMiddleware, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
// Change password
|
||||
router.post('/change-password', authMiddleware, async (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
try {
|
||||
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE id = $1', [req.user.id]);
|
||||
if (!bcrypt.compareSync(currentPassword, user.password))
|
||||
return res.status(400).json({ error: 'Current password is incorrect' });
|
||||
if (newPassword.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
const hash = bcrypt.hashSync(newPassword, 10);
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET password = $1, must_change_password = FALSE, updated_at = NOW() WHERE id = $2',
|
||||
[hash, req.user.id]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
if (!bcrypt.compareSync(currentPassword, user.password)) {
|
||||
return res.status(400).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
// Get current user
|
||||
router.get('/me', authMiddleware, (req, res) => {
|
||||
const { password, ...user } = req.user;
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
const hash = bcrypt.hashSync(newPassword, 10);
|
||||
db.prepare("UPDATE users SET password = ?, must_change_password = 0, updated_at = datetime('now') WHERE id = ?").run(hash, req.user.id);
|
||||
// Logout
|
||||
router.post('/logout', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await clearActiveSession(req.schema, req.user.id, req.device);
|
||||
await exec(req.schema, 'DELETE FROM push_subscriptions WHERE user_id=$1 AND device=$2', [req.user.id, req.device]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
// Support contact form
|
||||
router.post('/support', async (req, res) => {
|
||||
const { name, email, message } = req.body;
|
||||
if (!name?.trim() || !email?.trim() || !message?.trim())
|
||||
return res.status(400).json({ error: 'All fields are required' });
|
||||
if (message.trim().length > 2000)
|
||||
return res.status(400).json({ error: 'Message too long (max 2000 characters)' });
|
||||
try {
|
||||
const groupId = await getOrCreateSupportGroup(req.schema);
|
||||
if (!groupId) return res.status(500).json({ error: 'Support group unavailable' });
|
||||
|
||||
// Get current user
|
||||
router.get('/me', authMiddleware, (req, res) => {
|
||||
const { password, ...user } = req.user;
|
||||
res.json({ user });
|
||||
});
|
||||
const admin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
|
||||
if (!admin) return res.status(500).json({ error: 'No admin configured' });
|
||||
|
||||
// Logout (client-side token removal, but we can track it)
|
||||
router.post('/logout', authMiddleware, (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
const content = `📬 **Support Request**\n**Name:** ${name.trim()}\n**Email:** ${email.trim()}\n\n${message.trim()}`;
|
||||
const mr = await queryResult(req.schema,
|
||||
"INSERT INTO messages (group_id, user_id, content, type) VALUES ($1,$2,$3,'text') RETURNING id",
|
||||
[groupId, admin.id, content]
|
||||
);
|
||||
const newMsg = await queryOne(req.schema, `
|
||||
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name, u.avatar AS user_avatar
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = $1
|
||||
`, [mr.rows[0].id]);
|
||||
if (newMsg) { newMsg.reactions = []; io.to(R(req.schema,'group',groupId)).emit('message:new', newMsg); }
|
||||
|
||||
// Public support contact form — no auth required
|
||||
router.post('/support', (req, res) => {
|
||||
const { name, email, message } = req.body;
|
||||
if (!name?.trim() || !email?.trim() || !message?.trim()) {
|
||||
return res.status(400).json({ error: 'All fields are required' });
|
||||
}
|
||||
if (message.trim().length > 2000) {
|
||||
return res.status(400).json({ error: 'Message too long (max 2000 characters)' });
|
||||
}
|
||||
const admins = await query(req.schema, "SELECT id FROM users WHERE role = 'admin' AND status = 'active'");
|
||||
for (const a of admins) io.to(R(req.schema,'user',a.id)).emit('notification:new', { type: 'support', groupId });
|
||||
|
||||
const db = getDb();
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Get or create the Support group
|
||||
const groupId = getOrCreateSupportGroup();
|
||||
if (!groupId) return res.status(500).json({ error: 'Support group unavailable' });
|
||||
|
||||
// Find a system/admin user to post as (default admin)
|
||||
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
|
||||
if (!admin) return res.status(500).json({ error: 'No admin configured' });
|
||||
|
||||
// Format the support message
|
||||
const content = `📬 **Support Request**
|
||||
**Name:** ${name.trim()}
|
||||
**Email:** ${email.trim()}
|
||||
|
||||
${message.trim()}`;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO messages (group_id, user_id, content, type)
|
||||
VALUES (?, ?, ?, 'text')
|
||||
`).run(groupId, admin.id, content);
|
||||
|
||||
console.log(`[Support] Message from ${email} posted to Support group`);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,153 +1,469 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const fs = require('fs');
|
||||
const router = express.Router();
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Get all groups for current user
|
||||
router.get('/', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
async function getLoginType(schema) {
|
||||
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||
return row?.value || 'all_ages';
|
||||
}
|
||||
|
||||
// Public groups (all users are members)
|
||||
const publicGroups = db.prepare(`
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
|
||||
(SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 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 = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at
|
||||
FROM groups g
|
||||
WHERE g.type = 'public'
|
||||
ORDER BY g.is_default DESC, g.name ASC
|
||||
`).all();
|
||||
function deleteImageFile(imageUrl) {
|
||||
if (!imageUrl) return;
|
||||
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
|
||||
catch (e) { console.warn('[Groups] Could not delete image:', e.message); }
|
||||
}
|
||||
|
||||
// Private groups (user is a member)
|
||||
const privateGroups = db.prepare(`
|
||||
SELECT g.*,
|
||||
u.name as owner_name,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
|
||||
(SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 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 = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at
|
||||
FROM groups g
|
||||
JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
|
||||
LEFT JOIN users u ON g.owner_id = u.id
|
||||
WHERE g.type = 'private'
|
||||
ORDER BY last_message_at DESC NULLS LAST
|
||||
`).all(userId);
|
||||
// Schema-aware room name helper
|
||||
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||||
|
||||
res.json({ publicGroups, privateGroups });
|
||||
});
|
||||
// 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]
|
||||
);
|
||||
}
|
||||
|
||||
// Create group
|
||||
router.post('/', authMiddleware, (req, res) => {
|
||||
const { name, type, memberIds, isReadonly } = req.body;
|
||||
const db = getDb();
|
||||
module.exports = (io) => {
|
||||
|
||||
if (type === 'public' && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Only admins can create public groups' });
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO groups (name, type, owner_id, is_readonly)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
|
||||
|
||||
const groupId = result.lastInsertRowid;
|
||||
|
||||
if (type === 'public') {
|
||||
// Add all users to public group
|
||||
const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||
for (const u of allUsers) insert.run(groupId, u.id);
|
||||
async function emitGroupNew(schema, io, groupId) {
|
||||
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||
if (!group) return;
|
||||
if (group.type === 'public') {
|
||||
io.to(R(schema, 'schema', 'all')).emit('group:new', { group });
|
||||
} else {
|
||||
// Add creator
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id);
|
||||
// Add other members
|
||||
if (memberIds && memberIds.length > 0) {
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of memberIds) insert.run(groupId, uid);
|
||||
const members = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
|
||||
for (const m of members) io.to(R(schema, 'user', m.user_id)).emit('group:new', { group });
|
||||
}
|
||||
}
|
||||
|
||||
async function emitGroupUpdated(schema, io, groupId) {
|
||||
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||
if (!group) return;
|
||||
let uids;
|
||||
if (group.type === 'public') {
|
||||
uids = await query(schema, "SELECT id AS user_id FROM users WHERE status='active'");
|
||||
} else {
|
||||
uids = await query(schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [groupId]);
|
||||
}
|
||||
for (const m of uids) io.to(R(schema, 'user', m.user_id)).emit('group:updated', { group });
|
||||
}
|
||||
|
||||
// GET all groups for current user
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const publicGroups = await query(req.schema, `
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
|
||||
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
|
||||
(SELECT m.user_id FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_user_id
|
||||
FROM groups g WHERE g.type='public' ORDER BY g.is_default DESC, g.name ASC
|
||||
`);
|
||||
|
||||
const privateGroupsRaw = await query(req.schema, `
|
||||
SELECT g.*, u.name AS owner_name, ug.id AS source_user_group_id,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
|
||||
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
|
||||
(SELECT m.user_id FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_user_id,
|
||||
(SELECT json_agg(t) FROM (
|
||||
SELECT u2.id, u2.name, u2.avatar
|
||||
FROM group_members gm2
|
||||
JOIN users u2 ON gm2.user_id = u2.id
|
||||
WHERE gm2.group_id = g.id AND u2.name != 'Deleted User'
|
||||
ORDER BY u2.name LIMIT 4
|
||||
) t) AS member_previews
|
||||
FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1
|
||||
LEFT JOIN users u ON g.owner_id=u.id
|
||||
LEFT JOIN user_groups ug ON ug.dm_group_id=g.id AND g.is_managed=TRUE AND g.is_multi_group IS NOT TRUE
|
||||
WHERE g.type='private'
|
||||
ORDER BY last_message_at DESC NULLS LAST
|
||||
`, [userId]);
|
||||
|
||||
const privateGroups = await Promise.all(privateGroupsRaw.map(async g => {
|
||||
if (g.is_direct) {
|
||||
if (!g.direct_peer1_id || !g.direct_peer2_id) {
|
||||
const peers = await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 2', [g.id]);
|
||||
if (peers.length === 2) {
|
||||
await exec(req.schema, 'UPDATE groups SET direct_peer1_id=$1, direct_peer2_id=$2 WHERE id=$3', [peers[0].user_id, peers[1].user_id, g.id]);
|
||||
g.direct_peer1_id = peers[0].user_id; g.direct_peer2_id = peers[1].user_id;
|
||||
}
|
||||
}
|
||||
const otherUserId = g.direct_peer1_id === userId ? g.direct_peer2_id : g.direct_peer1_id;
|
||||
if (otherUserId) {
|
||||
const other = await queryOne(req.schema, 'SELECT display_name, name, avatar FROM users WHERE id=$1', [otherUserId]);
|
||||
if (other) {
|
||||
g.peer_id = otherUserId; g.peer_real_name = other.name;
|
||||
g.peer_display_name = other.display_name || null; g.peer_avatar = other.avatar || null;
|
||||
g.name = other.display_name || other.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
const custom = await queryOne(req.schema, 'SELECT name FROM user_group_names WHERE user_id=$1 AND group_id=$2', [userId, g.id]);
|
||||
if (custom) { g.owner_name_original = g.name; g.name = custom.name; }
|
||||
return g;
|
||||
}));
|
||||
|
||||
res.json({ publicGroups, privateGroups });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST create group
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
const { name, type, memberIds, isReadonly, isDirect } = req.body;
|
||||
try {
|
||||
if (type === 'public' && req.user.role !== 'admin')
|
||||
return res.status(403).json({ error: 'Only admins can create public groups' });
|
||||
|
||||
// Direct message
|
||||
if (isDirect && memberIds?.length === 1) {
|
||||
const otherUserId = memberIds[0], userId = req.user.id;
|
||||
|
||||
// U2U restriction check — admins always exempt
|
||||
if (req.user.role !== 'admin') {
|
||||
// Get all user groups the initiating user belongs to
|
||||
const initiatorGroups = await query(req.schema,
|
||||
'SELECT user_group_id FROM user_group_members WHERE user_id = $1', [userId]
|
||||
);
|
||||
const initiatorGroupIds = initiatorGroups.map(r => r.user_group_id);
|
||||
|
||||
// Get all user groups the target user belongs to
|
||||
const targetGroups = await query(req.schema,
|
||||
'SELECT user_group_id FROM user_group_members WHERE user_id = $1', [otherUserId]
|
||||
);
|
||||
const targetGroupIds = targetGroups.map(r => r.user_group_id);
|
||||
|
||||
// Least-restrictive-wins: the initiator needs at least ONE group
|
||||
// that has no restriction against ALL of the target's groups.
|
||||
// If initiatorGroups is empty, no restrictions apply (user not in any managed group).
|
||||
if (initiatorGroupIds.length > 0 && targetGroupIds.length > 0) {
|
||||
// For each initiator group, check if it is restricted from ANY of the target groups
|
||||
let canDm = false;
|
||||
for (const igId of initiatorGroupIds) {
|
||||
const restrictions = await query(req.schema,
|
||||
'SELECT blocked_group_id FROM user_group_dm_restrictions WHERE restricting_group_id = $1',
|
||||
[igId]
|
||||
);
|
||||
const blockedIds = new Set(restrictions.map(r => r.blocked_group_id));
|
||||
// This initiator group is unrestricted if none of the target's groups are blocked
|
||||
const isRestricted = targetGroupIds.some(tgId => blockedIds.has(tgId));
|
||||
if (!isRestricted) { canDm = true; break; }
|
||||
}
|
||||
if (!canDm) {
|
||||
return res.status(403).json({
|
||||
error: 'Direct messages with this user are not permitted.',
|
||||
code: 'DM_RESTRICTED'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await queryOne(req.schema, `
|
||||
SELECT g.id FROM groups g
|
||||
JOIN group_members gm1 ON gm1.group_id=g.id AND gm1.user_id=$1
|
||||
JOIN group_members gm2 ON gm2.group_id=g.id AND gm2.user_id=$2
|
||||
WHERE g.is_direct=TRUE LIMIT 1
|
||||
`, [userId, otherUserId]);
|
||||
if (existing) {
|
||||
await exec(req.schema, "UPDATE groups SET is_readonly=FALSE, owner_id=NULL, updated_at=NOW() WHERE id=$1", [existing.id]);
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [existing.id, userId]);
|
||||
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [existing.id]) });
|
||||
}
|
||||
const otherUser = await queryOne(req.schema, 'SELECT name, display_name FROM users WHERE id=$1', [otherUserId]);
|
||||
const dmName = (otherUser?.display_name || otherUser?.name) + ' ↔ ' + (req.user.display_name || req.user.name);
|
||||
const r = await queryResult(req.schema,
|
||||
"INSERT INTO groups (name,type,owner_id,is_readonly,is_direct,direct_peer1_id,direct_peer2_id) VALUES ($1,'private',NULL,FALSE,TRUE,$2,$3) RETURNING id",
|
||||
[dmName, userId, otherUserId]
|
||||
);
|
||||
const groupId = r.rows[0].id;
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]);
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]);
|
||||
|
||||
// Mixed Age: if initiator is not a minor and the other user is a minor, auto-add their guardian
|
||||
let guardianAdded = false, guardianName = null;
|
||||
const loginType = await getLoginType(req.schema);
|
||||
if (loginType === 'mixed_age' && !req.user.is_minor) {
|
||||
const otherUserFull = await queryOne(req.schema,
|
||||
'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [otherUserId]);
|
||||
if (otherUserFull?.is_minor && otherUserFull.guardian_user_id) {
|
||||
const guardianId = otherUserFull.guardian_user_id;
|
||||
if (guardianId !== userId) {
|
||||
await exec(req.schema,
|
||||
'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||
[groupId, guardianId]);
|
||||
const guardian = await queryOne(req.schema,
|
||||
'SELECT name, display_name FROM users WHERE id=$1', [guardianId]);
|
||||
guardianAdded = true;
|
||||
guardianName = guardian?.display_name || guardian?.name || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await emitGroupNew(req.schema, io, groupId);
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||
return res.json({ group, guardianAdded, guardianName });
|
||||
}
|
||||
}
|
||||
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||
res.json({ group });
|
||||
// Check for duplicate private group
|
||||
if ((type === 'private' || !type) && !isDirect && memberIds?.length > 0) {
|
||||
const allMemberIds = [...new Set([req.user.id, ...memberIds])].sort((a,b) => a-b);
|
||||
const candidates = await query(req.schema,
|
||||
'SELECT g.id FROM groups g JOIN group_members gm ON gm.group_id=g.id AND gm.user_id=$1 WHERE g.type=\'private\' AND g.is_direct=FALSE',
|
||||
[req.user.id]
|
||||
);
|
||||
for (const c of candidates) {
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 ORDER BY user_id', [c.id])).map(r => r.user_id);
|
||||
if (members.length === allMemberIds.length && members.every((id,i) => id === allMemberIds[i]))
|
||||
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [c.id]), duplicate: true });
|
||||
}
|
||||
}
|
||||
|
||||
const r = await queryResult(req.schema,
|
||||
'INSERT INTO groups (name,type,owner_id,is_readonly,is_direct) VALUES ($1,$2,$3,$4,FALSE) RETURNING id',
|
||||
[name, type||'private', req.user.id, !!isReadonly]
|
||||
);
|
||||
const groupId = r.rows[0].id;
|
||||
const groupGuardianNames = [];
|
||||
if (type === 'public') {
|
||||
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
||||
for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]);
|
||||
} else {
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, req.user.id]);
|
||||
if (memberIds?.length > 0) {
|
||||
const defaultAdmin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin=TRUE');
|
||||
for (const uid of memberIds) {
|
||||
if (defaultAdmin && uid === defaultAdmin.id) continue;
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, uid]);
|
||||
}
|
||||
}
|
||||
// Generate composite avatar for non-managed private groups with 3+ members
|
||||
const totalCount = await queryOne(req.schema, 'SELECT COUNT(*) AS cnt FROM group_members WHERE group_id=$1', [groupId]);
|
||||
if (parseInt(totalCount.cnt) >= 3) {
|
||||
await computeAndStoreComposite(req.schema, groupId);
|
||||
}
|
||||
|
||||
// Mixed Age: auto-add guardians for any minor members (non-minor initiators only)
|
||||
const groupLoginType = await getLoginType(req.schema);
|
||||
if (groupLoginType === 'mixed_age' && !req.user.is_minor && memberIds?.length > 0) {
|
||||
for (const uid of memberIds) {
|
||||
const memberInfo = await queryOne(req.schema,
|
||||
'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [uid]);
|
||||
if (memberInfo?.is_minor && memberInfo.guardian_user_id && memberInfo.guardian_user_id !== req.user.id) {
|
||||
await exec(req.schema,
|
||||
'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||
[groupId, memberInfo.guardian_user_id]);
|
||||
const g = await queryOne(req.schema,
|
||||
'SELECT name,display_name FROM users WHERE id=$1', [memberInfo.guardian_user_id]);
|
||||
const gName = g?.display_name || g?.name;
|
||||
if (gName && !groupGuardianNames.includes(gName)) groupGuardianNames.push(gName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await emitGroupNew(req.schema, io, groupId);
|
||||
res.json({
|
||||
group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]),
|
||||
...(groupGuardianNames.length ? { guardianAdded: true, guardianName: groupGuardianNames.join(', ') } : {}),
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Rename group
|
||||
router.patch('/:id/rename', authMiddleware, (req, res) => {
|
||||
// PATCH rename
|
||||
router.patch('/:id/rename', authMiddleware, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
|
||||
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
|
||||
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
|
||||
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Only owner can rename private group' });
|
||||
}
|
||||
|
||||
db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id);
|
||||
res.json({ success: true });
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
|
||||
if (group.is_direct) return res.status(403).json({ error: 'Cannot rename a direct message' });
|
||||
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
|
||||
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can rename' });
|
||||
await exec(req.schema, 'UPDATE groups SET name=$1, updated_at=NOW() WHERE id=$2', [name, group.id]);
|
||||
await emitGroupUpdated(req.schema, io, group.id);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Get group members
|
||||
router.get('/:id/members', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
|
||||
FROM group_members gm
|
||||
JOIN users u ON gm.user_id = u.id
|
||||
WHERE gm.group_id = ?
|
||||
ORDER BY u.name ASC
|
||||
`).all(req.params.id);
|
||||
res.json({ members });
|
||||
// GET members
|
||||
router.get('/:id/members', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const members = await query(req.schema,
|
||||
'SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status FROM group_members gm JOIN users u ON gm.user_id=u.id WHERE gm.group_id=$1 ORDER BY u.name ASC',
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({ members });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Add member to private group
|
||||
router.post('/:id/members', authMiddleware, (req, res) => {
|
||||
// POST add member
|
||||
router.post('/:id/members', authMiddleware, async (req, res) => {
|
||||
const { userId } = req.body;
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
|
||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Only owner can add members' });
|
||||
}
|
||||
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId);
|
||||
res.json({ success: true });
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
|
||||
if (group.is_direct) return res.status(400).json({ error: 'Cannot add members to a direct message' });
|
||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can add members' });
|
||||
const targetUser = await queryOne(req.schema, 'SELECT is_default_admin FROM users WHERE id=$1', [userId]);
|
||||
if (targetUser?.is_default_admin) return res.status(400).json({ error: 'Default admin cannot be added to private groups' });
|
||||
// Capture pre-add count to decide if composite should regenerate
|
||||
const preAddCount = await queryOne(req.schema, 'SELECT COUNT(*) AS cnt FROM group_members WHERE group_id=$1', [group.id]);
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [group.id, userId]);
|
||||
const addedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
|
||||
const mr = await queryResult(req.schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[group.id, userId, `${addedName} has joined the conversation.`]
|
||||
);
|
||||
const sysMsg = await queryOne(req.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 FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
|
||||
[mr.rows[0].id]
|
||||
);
|
||||
sysMsg.reactions = [];
|
||||
io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg);
|
||||
// For non-managed private groups, always notify existing members of the updated group,
|
||||
// and regenerate composite when pre-add count was ≤3 and new total reaches ≥3.
|
||||
if (!group.is_managed && !group.is_direct) {
|
||||
const preCount = parseInt(preAddCount.cnt);
|
||||
if (preCount <= 3) {
|
||||
const newTotal = preCount + 1;
|
||||
if (newTotal >= 3) {
|
||||
await computeAndStoreComposite(req.schema, group.id);
|
||||
}
|
||||
}
|
||||
await emitGroupUpdated(req.schema, io, group.id);
|
||||
}
|
||||
io.in(R(req.schema,'user',userId)).socketsJoin(R(req.schema,'group',group.id));
|
||||
io.to(R(req.schema,'user',userId)).emit('group:new', { group });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Leave private group
|
||||
router.delete('/:id/leave', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
|
||||
|
||||
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, req.user.id);
|
||||
res.json({ success: true });
|
||||
// DELETE remove member
|
||||
router.delete('/:id/members/:userId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot remove members from public groups' });
|
||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner or admin can remove members' });
|
||||
const targetId = parseInt(req.params.userId);
|
||||
// Admins can remove the owner only if the owner is a deleted user (orphan cleanup)
|
||||
const targetUser = await queryOne(req.schema, 'SELECT status FROM users WHERE id=$1', [targetId]);
|
||||
const isDeletedOrphan = targetUser?.status === 'deleted';
|
||||
if (targetId === group.owner_id && !isDeletedOrphan && req.user.role !== 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot remove the group owner' });
|
||||
}
|
||||
if (targetId === group.owner_id && !isDeletedOrphan) {
|
||||
return res.status(400).json({ error: 'Cannot remove the group owner' });
|
||||
}
|
||||
const removedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [targetId]);
|
||||
const removedName = removedUser?.display_name || removedUser?.name || 'Unknown';
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, targetId]);
|
||||
const mr = await queryResult(req.schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[group.id, targetId, `${removedName} has been removed from the conversation.`]
|
||||
);
|
||||
const sysMsg = await queryOne(req.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 FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
|
||||
[mr.rows[0].id]
|
||||
);
|
||||
sysMsg.reactions = [];
|
||||
io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg);
|
||||
io.in(R(req.schema,'user',targetId)).socketsLeave(R(req.schema,'group',group.id));
|
||||
io.to(R(req.schema,'user',targetId)).emit('group:deleted', { groupId: group.id });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Admin take ownership of private group
|
||||
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id);
|
||||
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(req.params.id, req.user.id);
|
||||
res.json({ success: true });
|
||||
// DELETE leave
|
||||
router.delete('/:id/leave', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
|
||||
if (group.is_managed && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is managed by an administrator.' });
|
||||
const userId = req.user.id;
|
||||
const leaverName = req.user.display_name || req.user.name;
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, userId]);
|
||||
const mr = await queryResult(req.schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[group.id, userId, `${leaverName} has left the conversation.`]
|
||||
);
|
||||
const sysMsg = await queryOne(req.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 FROM messages m JOIN users u ON m.user_id=u.id WHERE m.id=$1',
|
||||
[mr.rows[0].id]
|
||||
);
|
||||
sysMsg.reactions = [];
|
||||
io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg);
|
||||
io.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',group.id));
|
||||
io.to(R(req.schema,'user',userId)).emit('group:deleted', { groupId: group.id });
|
||||
if (group.is_direct) {
|
||||
const remaining = await queryOne(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1 LIMIT 1', [group.id]);
|
||||
if (remaining) await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [remaining.user_id, group.id]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Delete group (admin or private group owner)
|
||||
router.delete('/:id', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.is_default) return res.status(403).json({ error: 'Cannot delete default group' });
|
||||
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can delete public groups' });
|
||||
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Only owner or admin can delete private groups' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM groups WHERE id = ?').run(group.id);
|
||||
res.json({ success: true });
|
||||
// POST take-ownership
|
||||
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (group?.is_managed) return res.status(403).json({ error: 'Managed groups are administered via the Group Manager.' });
|
||||
await exec(req.schema, 'UPDATE groups SET owner_id=$1, updated_at=NOW() WHERE id=$2', [req.user.id, req.params.id]);
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.params.id, req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// DELETE group
|
||||
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
||||
if (group.is_default) return res.status(403).json({ error: 'Cannot delete default group' });
|
||||
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can delete public groups' });
|
||||
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner or admin can delete' });
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [group.id])).map(m => m.user_id);
|
||||
if (group.type === 'public') {
|
||||
const all = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
||||
for (const u of all) if (!members.includes(u.id)) members.push(u.id);
|
||||
}
|
||||
const imageMessages = await query(req.schema, 'SELECT image_url FROM messages WHERE group_id=$1 AND image_url IS NOT NULL', [group.id]);
|
||||
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [group.id]);
|
||||
for (const msg of imageMessages) deleteImageFile(msg.image_url);
|
||||
for (const uid of members) io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: group.id });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// PATCH custom-name
|
||||
router.patch('/:id/custom-name', authMiddleware, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
const groupId = parseInt(req.params.id), userId = req.user.id;
|
||||
try {
|
||||
if (!name?.trim()) {
|
||||
await exec(req.schema, 'DELETE FROM user_group_names WHERE user_id=$1 AND group_id=$2', [userId, groupId]);
|
||||
return res.json({ success: true, name: null });
|
||||
}
|
||||
await exec(req.schema,
|
||||
'INSERT INTO user_group_names (user_id,group_id,name) VALUES ($1,$2,$3) ON CONFLICT (user_id,group_id) DO UPDATE SET name=EXCLUDED.name',
|
||||
[userId, groupId, name.trim()]
|
||||
);
|
||||
res.json({ success: true, name: name.trim() });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
32
backend/src/routes/help.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { exec, queryOne } = require('../models/db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
const HELP_FILE = path.join(__dirname, '../data/help.md');
|
||||
|
||||
router.get('/', authMiddleware, (req, res) => {
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(HELP_FILE, 'utf8'); }
|
||||
catch (e) { content = '# Getting Started\n\nHelp content is not available yet.'; }
|
||||
res.json({ content });
|
||||
});
|
||||
|
||||
router.get('/status', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = await queryOne(req.schema, 'SELECT help_dismissed FROM users WHERE id = $1', [req.user.id]);
|
||||
res.json({ dismissed: !!user?.help_dismissed });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/dismiss', authMiddleware, async (req, res) => {
|
||||
const { dismissed } = req.body;
|
||||
try {
|
||||
await exec(req.schema, 'UPDATE users SET help_dismissed = $1 WHERE id = $2', [!!dismissed, req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
333
backend/src/routes/host.js
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* routes/host.js — RosterChirp-Host control plane
|
||||
*
|
||||
* All routes require the HOST_ADMIN_KEY header.
|
||||
* These routes operate on the 'public' schema (tenant registry).
|
||||
* They provision/deprovision per-tenant schemas.
|
||||
*
|
||||
* APP_TYPE must be 'host' for these routes to be registered.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const router = express.Router();
|
||||
const {
|
||||
query, queryOne, queryResult, exec,
|
||||
runMigrations, ensureSchema,
|
||||
seedSettings, seedEventTypes, seedAdmin, seedUserGroups,
|
||||
refreshTenantCache,
|
||||
} = require('../models/db');
|
||||
|
||||
const HOST_ADMIN_KEY = process.env.HOST_ADMIN_KEY || '';
|
||||
|
||||
// ── Host admin key guard ──────────────────────────────────────────────────────
|
||||
|
||||
function hostAdminMiddleware(req, res, next) {
|
||||
if (!HOST_ADMIN_KEY) {
|
||||
return res.status(503).json({ error: 'HOST_ADMIN_KEY is not configured' });
|
||||
}
|
||||
const key = req.headers['x-host-admin-key'] || req.headers['authorization']?.replace('Bearer ', '');
|
||||
if (!key || key !== HOST_ADMIN_KEY) {
|
||||
return res.status(401).json({ error: 'Invalid host admin key' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// All routes in this file require the host admin key
|
||||
router.use(hostAdminMiddleware);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function slugToSchema(slug) {
|
||||
return `tenant_${slug.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
|
||||
}
|
||||
|
||||
function isValidSlug(slug) {
|
||||
return /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/.test(slug);
|
||||
}
|
||||
|
||||
async function reloadTenantCache() {
|
||||
const tenants = await query('public', "SELECT * FROM tenants WHERE status = 'active'");
|
||||
refreshTenantCache(tenants);
|
||||
return tenants;
|
||||
}
|
||||
|
||||
// ── GET /api/host/tenants — list all tenants ──────────────────────────────────
|
||||
|
||||
router.get('/tenants', async (req, res) => {
|
||||
try {
|
||||
const tenants = await query('public',
|
||||
'SELECT * FROM tenants ORDER BY created_at DESC'
|
||||
);
|
||||
res.json({ tenants });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /api/host/tenants/:slug — get single tenant ───────────────────────────
|
||||
|
||||
router.get('/tenants/:slug', async (req, res) => {
|
||||
try {
|
||||
const tenant = await queryOne('public',
|
||||
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
||||
);
|
||||
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
||||
res.json({ tenant });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /api/host/tenants — provision a new tenant ───────────────────────────
|
||||
//
|
||||
// Body: { slug, name, plan, adminEmail, adminName, adminPass, customDomain? }
|
||||
//
|
||||
// This:
|
||||
// 1. Validates the slug (becomes subdomain + schema name)
|
||||
// 2. Creates the Postgres schema
|
||||
// 3. Runs all migrations in the new schema
|
||||
// 4. Seeds settings, event types, and the first admin user
|
||||
// 5. Records the tenant in the registry
|
||||
// 6. Reloads the tenant domain cache
|
||||
|
||||
router.post('/tenants', async (req, res) => {
|
||||
const { slug, name, plan, adminEmail, adminName, adminPass, customDomain } = req.body;
|
||||
|
||||
if (!slug || !name) return res.status(400).json({ error: 'slug and name are required' });
|
||||
if (!isValidSlug(slug)) {
|
||||
return res.status(400).json({
|
||||
error: 'slug must be 3-32 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric'
|
||||
});
|
||||
}
|
||||
|
||||
const schemaName = slugToSchema(slug);
|
||||
|
||||
try {
|
||||
// Check slug not already taken
|
||||
const existing = await queryOne('public',
|
||||
'SELECT id FROM tenants WHERE slug = $1', [slug]
|
||||
);
|
||||
if (existing) return res.status(400).json({ error: `Tenant '${slug}' already exists` });
|
||||
|
||||
if (customDomain) {
|
||||
const domainTaken = await queryOne('public',
|
||||
'SELECT id FROM tenants WHERE custom_domain = $1', [customDomain.toLowerCase()]
|
||||
);
|
||||
if (domainTaken) return res.status(400).json({ error: `Custom domain '${customDomain}' is already in use` });
|
||||
}
|
||||
|
||||
console.log(`[Host] Provisioning tenant: ${slug} (schema: ${schemaName})`);
|
||||
|
||||
// 1. Create schema + run migrations
|
||||
await runMigrations(schemaName);
|
||||
|
||||
// 2. Seed settings (uses env defaults unless overridden by body)
|
||||
await seedSettings(schemaName);
|
||||
|
||||
// 3. Seed event types
|
||||
await seedEventTypes(schemaName);
|
||||
|
||||
// 3b. Seed default user groups (Coaches, Players, Parents)
|
||||
await seedUserGroups(schemaName);
|
||||
|
||||
// 4. Seed admin user — temporarily override env vars for this tenant
|
||||
const origEmail = process.env.ADMIN_EMAIL;
|
||||
const origName = process.env.ADMIN_NAME;
|
||||
const origPass = process.env.ADMIN_PASS;
|
||||
if (adminEmail) process.env.ADMIN_EMAIL = adminEmail;
|
||||
if (adminName) process.env.ADMIN_NAME = adminName;
|
||||
if (adminPass) process.env.ADMIN_PASS = adminPass;
|
||||
|
||||
await seedAdmin(schemaName);
|
||||
|
||||
process.env.ADMIN_EMAIL = origEmail;
|
||||
process.env.ADMIN_NAME = origName;
|
||||
process.env.ADMIN_PASS = origPass;
|
||||
|
||||
// 5. Set app_type based on plan
|
||||
const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
|
||||
await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
||||
if (plan === 'brand' || plan === 'team') {
|
||||
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'");
|
||||
}
|
||||
if (plan === 'team') {
|
||||
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_group_manager'");
|
||||
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_schedule_manager'");
|
||||
}
|
||||
|
||||
// 6. Register in tenants table
|
||||
const tr = await queryResult('public', `
|
||||
INSERT INTO tenants (slug, name, schema_name, custom_domain, plan, admin_email)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
`, [slug, name, schemaName, customDomain?.toLowerCase() || null, plan || 'chat', adminEmail || null]);
|
||||
|
||||
// 7. Reload domain cache
|
||||
await reloadTenantCache();
|
||||
|
||||
const baseDomain = process.env.APP_DOMAIN || 'rosterchirp.com';
|
||||
const tenant = tr.rows[0];
|
||||
tenant.url = `https://${slug}.${baseDomain}`;
|
||||
|
||||
console.log(`[Host] Tenant provisioned: ${slug} → ${schemaName}`);
|
||||
res.status(201).json({ tenant });
|
||||
|
||||
} catch (e) {
|
||||
console.error(`[Host] Provisioning failed for ${slug}:`, e.message);
|
||||
// Attempt cleanup of partially-created schema
|
||||
try {
|
||||
await exec('public', `DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
||||
console.log(`[Host] Cleaned up schema ${schemaName} after failed provision`);
|
||||
} catch (cleanupErr) {
|
||||
console.error(`[Host] Cleanup failed:`, cleanupErr.message);
|
||||
}
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── PATCH /api/host/tenants/:slug — update tenant ─────────────────────────────
|
||||
//
|
||||
// Supports updating: name, plan, customDomain, status
|
||||
|
||||
router.patch('/tenants/:slug', async (req, res) => {
|
||||
const { name, plan, customDomain, status, adminPassword } = req.body;
|
||||
try {
|
||||
const tenant = await queryOne('public',
|
||||
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
||||
);
|
||||
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
||||
|
||||
if (customDomain && customDomain !== tenant.custom_domain) {
|
||||
const taken = await queryOne('public',
|
||||
'SELECT id FROM tenants WHERE custom_domain=$1 AND slug!=$2',
|
||||
[customDomain.toLowerCase(), req.params.slug]
|
||||
);
|
||||
if (taken) return res.status(400).json({ error: 'Custom domain already in use' });
|
||||
}
|
||||
|
||||
if (status && !['active','suspended'].includes(status))
|
||||
return res.status(400).json({ error: 'status must be active or suspended' });
|
||||
|
||||
await exec('public', `
|
||||
UPDATE tenants SET
|
||||
name = COALESCE($1, name),
|
||||
plan = COALESCE($2, plan),
|
||||
custom_domain = $3,
|
||||
status = COALESCE($4, status),
|
||||
updated_at = NOW()
|
||||
WHERE slug = $5
|
||||
`, [name || null, plan || null, customDomain?.toLowerCase() ?? tenant.custom_domain, status || null, req.params.slug]);
|
||||
|
||||
// If plan changed, update feature flags in tenant schema
|
||||
if (plan && plan !== tenant.plan) {
|
||||
const s = tenant.schema_name;
|
||||
await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]);
|
||||
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]);
|
||||
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]);
|
||||
const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
|
||||
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
||||
}
|
||||
|
||||
// Reset tenant admin password if provided
|
||||
if (adminPassword && adminPassword.length >= 6) {
|
||||
const hash = bcrypt.hashSync(adminPassword, 10);
|
||||
await exec(tenant.schema_name,
|
||||
"UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE is_default_admin=TRUE",
|
||||
[hash]
|
||||
);
|
||||
}
|
||||
|
||||
await reloadTenantCache();
|
||||
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
||||
res.json({ tenant: updated });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── DELETE /api/host/tenants/:slug — deprovision tenant ───────────────────────
|
||||
//
|
||||
// Permanently drops the tenant's Postgres schema and all data.
|
||||
// Requires confirmation: body must include { confirm: "DELETE {slug}" }
|
||||
|
||||
router.delete('/tenants/:slug', async (req, res) => {
|
||||
const { confirm } = req.body;
|
||||
if (confirm !== `DELETE ${req.params.slug}`) {
|
||||
return res.status(400).json({
|
||||
error: `Confirmation required. Send { "confirm": "DELETE ${req.params.slug}" } in the request body.`
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tenant = await queryOne('public',
|
||||
'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]
|
||||
);
|
||||
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
||||
|
||||
console.log(`[Host] Deprovisioning tenant: ${req.params.slug} (schema: ${tenant.schema_name})`);
|
||||
|
||||
// Drop the entire schema — CASCADE removes all tables, indexes, triggers
|
||||
await exec('public', `DROP SCHEMA IF EXISTS "${tenant.schema_name}" CASCADE`);
|
||||
|
||||
// Remove from registry
|
||||
await exec('public', 'DELETE FROM tenants WHERE slug=$1', [req.params.slug]);
|
||||
|
||||
await reloadTenantCache();
|
||||
|
||||
console.log(`[Host] Tenant deprovisioned: ${req.params.slug}`);
|
||||
res.json({ success: true, message: `Tenant '${req.params.slug}' and all its data have been permanently deleted.` });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /api/host/tenants/:slug/migrate — run pending migrations ─────────────
|
||||
//
|
||||
// Useful after deploying a new migration file to apply it to all tenants.
|
||||
|
||||
router.post('/tenants/:slug/migrate', async (req, res) => {
|
||||
try {
|
||||
const tenant = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
||||
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
||||
await runMigrations(tenant.schema_name);
|
||||
await seedSettings(tenant.schema_name);
|
||||
await seedEventTypes(tenant.schema_name);
|
||||
await seedUserGroups(tenant.schema_name);
|
||||
const applied = await query(tenant.schema_name, 'SELECT * FROM schema_migrations ORDER BY version');
|
||||
res.json({ success: true, migrations: applied });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /api/host/migrate-all — run pending migrations on every tenant ───────
|
||||
|
||||
router.post('/migrate-all', async (req, res) => {
|
||||
try {
|
||||
const tenants = await query('public', "SELECT * FROM tenants WHERE status='active'");
|
||||
const results = [];
|
||||
for (const t of tenants) {
|
||||
try {
|
||||
await runMigrations(t.schema_name);
|
||||
// Also re-run seeding so new defaults (e.g. user groups, event types)
|
||||
// are applied to existing tenants that were provisioned before they existed.
|
||||
await seedSettings(t.schema_name);
|
||||
await seedEventTypes(t.schema_name);
|
||||
await seedUserGroups(t.schema_name);
|
||||
results.push({ slug: t.slug, status: 'ok' });
|
||||
} catch (e) {
|
||||
results.push({ slug: t.slug, status: 'error', error: e.message });
|
||||
}
|
||||
}
|
||||
res.json({ results });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /api/host/status — host health check ──────────────────────────────────
|
||||
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants');
|
||||
const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'");
|
||||
const baseDomain = process.env.APP_DOMAIN || 'rosterchirp.com';
|
||||
res.json({
|
||||
ok: true,
|
||||
appType: process.env.APP_TYPE || 'selfhost',
|
||||
baseDomain,
|
||||
tenants: { total: parseInt(tenantCount.count), active: parseInt(active.count) },
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,175 +1,227 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { sendPushToUser } = require('./push');
|
||||
|
||||
const imgStorage = multer.diskStorage({
|
||||
destination: '/app/uploads/images',
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2, 6)}${ext}`);
|
||||
}
|
||||
});
|
||||
const uploadImage = multer({
|
||||
storage: imgStorage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||
else cb(new Error('Images only'));
|
||||
}
|
||||
});
|
||||
|
||||
function getUserForMessage(db, userId) {
|
||||
return db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ?').get(userId);
|
||||
function deleteImageFile(imageUrl) {
|
||||
if (!imageUrl) return;
|
||||
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
|
||||
catch (e) { console.warn('[Messages] Could not delete image:', e.message); }
|
||||
}
|
||||
|
||||
function canAccessGroup(db, groupId, userId) {
|
||||
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
|
||||
if (!group) return null;
|
||||
if (group.type === 'public') return group;
|
||||
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
|
||||
if (!member) return null;
|
||||
return group;
|
||||
}
|
||||
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||||
|
||||
// Get messages for group
|
||||
router.get('/group/:groupId', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = canAccessGroup(db, req.params.groupId, req.user.id);
|
||||
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||
module.exports = function(io) {
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
const { before, limit = 50 } = req.query;
|
||||
let query = `
|
||||
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,
|
||||
rm.content as reply_content, rm.image_url as reply_image_url,
|
||||
ru.name as reply_user_name, ru.display_name as reply_user_display_name,
|
||||
rm.is_deleted as reply_is_deleted
|
||||
FROM messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN messages rm ON m.reply_to_id = rm.id
|
||||
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||
WHERE m.group_id = ?
|
||||
`;
|
||||
const params = [req.params.groupId];
|
||||
const imgStorage = multer.diskStorage({
|
||||
destination: '/app/uploads/images',
|
||||
filename: (req, file, cb) => cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2,6)}${path.extname(file.originalname)}`),
|
||||
});
|
||||
const uploadImage = multer({ storage: imgStorage, limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
});
|
||||
|
||||
if (before) {
|
||||
query += ' AND m.id < ?';
|
||||
params.push(before);
|
||||
async function canAccessGroup(schema, groupId, userId) {
|
||||
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||
if (!group) return null;
|
||||
if (group.type === 'public') return group;
|
||||
const member = await queryOne(schema, 'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2', [groupId, userId]);
|
||||
return member ? group : null;
|
||||
}
|
||||
|
||||
query += ' ORDER BY m.created_at DESC LIMIT ?';
|
||||
params.push(parseInt(limit));
|
||||
// GET messages for group
|
||||
router.get('/group/:groupId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
|
||||
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||
|
||||
const messages = db.prepare(query).all(...params);
|
||||
const { before, limit = 50 } = req.query;
|
||||
let joinedAt = null;
|
||||
if (group.is_managed) {
|
||||
const membership = await queryOne(req.schema,
|
||||
'SELECT joined_at FROM group_members WHERE group_id=$1 AND user_id=$2',
|
||||
[group.id, req.user.id]
|
||||
);
|
||||
if (membership?.joined_at) joinedAt = new Date(membership.joined_at).toISOString().slice(0,10);
|
||||
}
|
||||
|
||||
// Get reactions for these messages
|
||||
for (const msg of messages) {
|
||||
msg.reactions = db.prepare(`
|
||||
SELECT r.emoji, r.user_id, u.name as user_name
|
||||
FROM reactions r JOIN users u ON r.user_id = u.id
|
||||
WHERE r.message_id = ?
|
||||
`).all(msg.id);
|
||||
}
|
||||
let sql = `
|
||||
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,
|
||||
rm.content AS reply_content, rm.image_url AS reply_image_url,
|
||||
ru.name AS reply_user_name, ru.display_name AS reply_user_display_name,
|
||||
rm.is_deleted AS reply_is_deleted
|
||||
FROM messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN messages rm ON m.reply_to_id = rm.id
|
||||
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||
WHERE m.group_id = $1
|
||||
`;
|
||||
const params = [req.params.groupId];
|
||||
let pi = 2;
|
||||
if (joinedAt) { sql += ` AND m.created_at::date >= $${pi++}::date`; params.push(joinedAt); }
|
||||
if (before) { sql += ` AND m.id < $${pi++}`; params.push(before); }
|
||||
sql += ` ORDER BY m.created_at DESC LIMIT $${pi}`;
|
||||
params.push(parseInt(limit));
|
||||
|
||||
res.json({ messages: messages.reverse() });
|
||||
});
|
||||
const messages = await query(req.schema, sql, params);
|
||||
for (const msg of messages) {
|
||||
msg.reactions = await query(req.schema,
|
||||
'SELECT r.emoji, r.user_id, u.name AS user_name FROM reactions r JOIN users u ON r.user_id=u.id WHERE r.message_id=$1',
|
||||
[msg.id]
|
||||
);
|
||||
}
|
||||
res.json({ messages: messages.reverse() });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Send message
|
||||
router.post('/group/:groupId', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const group = canAccessGroup(db, req.params.groupId, req.user.id);
|
||||
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is read-only' });
|
||||
// POST send message
|
||||
router.post('/group/:groupId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
|
||||
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
|
||||
const { content, replyToId, linkPreview } = req.body;
|
||||
if (!content?.trim() && !req.body.imageUrl) return res.status(400).json({ error: 'Message cannot be empty' });
|
||||
const r = await queryResult(req.schema,
|
||||
'INSERT INTO messages (group_id,user_id,content,reply_to_id,link_preview) VALUES ($1,$2,$3,$4,$5) RETURNING id',
|
||||
[req.params.groupId, req.user.id, content?.trim()||null, replyToId||null, linkPreview ? JSON.stringify(linkPreview) : null]
|
||||
);
|
||||
const message = await queryOne(req.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.allow_dm AS user_allow_dm,
|
||||
rm.content AS reply_content, ru.name AS reply_user_name, ru.display_name AS reply_user_display_name
|
||||
FROM messages m JOIN users u ON m.user_id=u.id
|
||||
LEFT JOIN messages rm ON m.reply_to_id=rm.id LEFT JOIN users ru ON rm.user_id=ru.id
|
||||
WHERE m.id=$1
|
||||
`, [r.rows[0].id]);
|
||||
message.reactions = [];
|
||||
io.to(R(req.schema,'group',req.params.groupId)).emit('message:new', message);
|
||||
|
||||
const { content, replyToId, linkPreview } = req.body;
|
||||
if (!content?.trim() && !req.body.imageUrl) return res.status(400).json({ error: 'Message cannot be empty' });
|
||||
// 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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO messages (group_id, user_id, content, reply_to_id, link_preview)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(req.params.groupId, req.user.id, content?.trim() || null, replyToId || null, linkPreview ? JSON.stringify(linkPreview) : null);
|
||||
res.json({ message });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
const message = db.prepare(`
|
||||
SELECT m.*,
|
||||
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role,
|
||||
rm.content as reply_content, ru.name as reply_user_name, ru.display_name as reply_user_display_name
|
||||
FROM messages m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN messages rm ON m.reply_to_id = rm.id
|
||||
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||
WHERE m.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
// POST image message
|
||||
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), async (req, res) => {
|
||||
try {
|
||||
const group = await canAccessGroup(req.schema, req.params.groupId, req.user.id);
|
||||
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No image' });
|
||||
const imageUrl = `/uploads/images/${req.file.filename}`;
|
||||
const { content, replyToId } = req.body;
|
||||
const r = await queryResult(req.schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,image_url,type,reply_to_id) VALUES ($1,$2,$3,$4,'image',$5) RETURNING id",
|
||||
[req.params.groupId, req.user.id, content||null, imageUrl, replyToId||null]
|
||||
);
|
||||
const message = await queryOne(req.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.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]
|
||||
);
|
||||
message.reactions = [];
|
||||
io.to(R(req.schema,'group',req.params.groupId)).emit('message:new', message);
|
||||
|
||||
message.reactions = [];
|
||||
res.json({ 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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Upload image message
|
||||
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), (req, res) => {
|
||||
const db = getDb();
|
||||
const group = canAccessGroup(db, req.params.groupId, req.user.id);
|
||||
if (!group) return res.status(403).json({ error: 'Access denied' });
|
||||
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No image' });
|
||||
res.json({ message });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
const imageUrl = `/uploads/images/${req.file.filename}`;
|
||||
const { content, replyToId } = req.body;
|
||||
// DELETE message
|
||||
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const message = await queryOne(req.schema,
|
||||
'SELECT m.*, g.type AS group_type, g.owner_id AS group_owner_id FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1',
|
||||
[req.params.id]
|
||||
);
|
||||
if (!message) return res.status(404).json({ error: 'Message not found' });
|
||||
const canDelete = message.user_id === req.user.id || req.user.role === 'admin' ||
|
||||
(message.group_type === 'private' && message.group_owner_id === req.user.id);
|
||||
if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' });
|
||||
const imageUrl = message.image_url;
|
||||
await exec(req.schema, 'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE id=$1', [message.id]);
|
||||
deleteImageFile(imageUrl);
|
||||
io.to(R(req.schema,'group',message.group_id)).emit('message:deleted', { messageId: message.id, groupId: message.group_id });
|
||||
res.json({ success: true, messageId: message.id });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id)
|
||||
VALUES (?, ?, ?, ?, 'image', ?)
|
||||
`).run(req.params.groupId, req.user.id, content || null, imageUrl, replyToId || null);
|
||||
// POST reaction
|
||||
router.post('/:id/reactions', authMiddleware, async (req, res) => {
|
||||
const { emoji } = req.body;
|
||||
try {
|
||||
const message = await queryOne(req.schema, 'SELECT * FROM messages WHERE id=$1 AND is_deleted=FALSE', [req.params.id]);
|
||||
if (!message) return res.status(404).json({ error: 'Message not found' });
|
||||
const existing = await queryOne(req.schema,
|
||||
'SELECT * FROM reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3',
|
||||
[message.id, req.user.id, emoji]
|
||||
);
|
||||
if (existing) {
|
||||
await exec(req.schema, 'DELETE FROM reactions WHERE id=$1', [existing.id]);
|
||||
} else {
|
||||
await exec(req.schema, 'INSERT INTO reactions (message_id,user_id,emoji) VALUES ($1,$2,$3)', [message.id, req.user.id, emoji]);
|
||||
}
|
||||
const reactions = await query(req.schema,
|
||||
'SELECT r.emoji, r.user_id, u.name AS user_name FROM reactions r JOIN users u ON r.user_id=u.id WHERE r.message_id=$1',
|
||||
[message.id]
|
||||
);
|
||||
io.to(R(req.schema,'group',message.group_id)).emit('reaction:updated', { messageId: message.id, reactions });
|
||||
res.json({ reactions });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
const message = db.prepare(`
|
||||
SELECT m.*,
|
||||
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role
|
||||
FROM messages m JOIN users u ON m.user_id = u.id
|
||||
WHERE m.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
message.reactions = [];
|
||||
res.json({ message });
|
||||
});
|
||||
|
||||
// Delete message
|
||||
router.delete('/:id', authMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const message = db.prepare('SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_readonly FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?').get(req.params.id);
|
||||
if (!message) return res.status(404).json({ error: 'Message not found' });
|
||||
|
||||
const canDelete = message.user_id === req.user.id ||
|
||||
(req.user.role === 'admin' && message.group_type === 'public') ||
|
||||
(message.group_type === 'private' && message.group_owner_id === req.user.id);
|
||||
|
||||
if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' });
|
||||
|
||||
db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(message.id);
|
||||
res.json({ success: true, messageId: message.id });
|
||||
});
|
||||
|
||||
// Add/toggle reaction
|
||||
router.post('/:id/reactions', authMiddleware, (req, res) => {
|
||||
const { emoji } = req.body;
|
||||
const db = getDb();
|
||||
const message = db.prepare('SELECT * FROM messages WHERE id = ? AND is_deleted = 0').get(req.params.id);
|
||||
if (!message) return res.status(404).json({ error: 'Message not found' });
|
||||
|
||||
// Check if user's message is from deleted/suspended user
|
||||
const msgUser = db.prepare('SELECT status FROM users WHERE id = ?').get(message.user_id);
|
||||
if (msgUser.status !== 'active') return res.status(400).json({ error: 'Cannot react to this message' });
|
||||
|
||||
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(message.id, req.user.id, emoji);
|
||||
|
||||
if (existing) {
|
||||
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
|
||||
res.json({ removed: true, emoji });
|
||||
} else {
|
||||
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(message.id, req.user.id, emoji);
|
||||
res.json({ added: true, emoji });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -1,90 +1,332 @@
|
||||
const express = require('express');
|
||||
const webpush = require('web-push');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const router = express.Router();
|
||||
const { query, queryOne, exec } = require('../models/db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Get or generate VAPID keys stored in settings
|
||||
function getVapidKeys() {
|
||||
const db = getDb();
|
||||
let pub = db.prepare("SELECT value FROM settings WHERE key = 'vapid_public'").get();
|
||||
let priv = db.prepare("SELECT value FROM settings WHERE key = 'vapid_private'").get();
|
||||
// ── Firebase Admin (FCM — Android/Chrome) ──────────────────────────────────────
|
||||
let firebaseAdmin = null;
|
||||
let firebaseApp = null;
|
||||
|
||||
if (!pub?.value || !priv?.value) {
|
||||
const keys = webpush.generateVAPIDKeys();
|
||||
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
|
||||
ins.run('vapid_public', keys.publicKey, keys.publicKey);
|
||||
ins.run('vapid_private', keys.privateKey, keys.privateKey);
|
||||
console.log('[Push] Generated new VAPID keys');
|
||||
return keys;
|
||||
function getMessaging() {
|
||||
if (firebaseApp) return firebaseAdmin.messaging(firebaseApp);
|
||||
const json = process.env.FIREBASE_SERVICE_ACCOUNT;
|
||||
if (!json) return null;
|
||||
try {
|
||||
firebaseAdmin = require('firebase-admin');
|
||||
const svc = JSON.parse(json);
|
||||
firebaseApp = firebaseAdmin.initializeApp({
|
||||
credential: firebaseAdmin.credential.cert(svc),
|
||||
});
|
||||
console.log('[Push] Firebase Admin initialised');
|
||||
return firebaseAdmin.messaging(firebaseApp);
|
||||
} catch (e) {
|
||||
console.error('[Push] Firebase Admin init failed:', e.message);
|
||||
return null;
|
||||
}
|
||||
return { publicKey: pub.value, privateKey: priv.value };
|
||||
}
|
||||
|
||||
function initWebPush() {
|
||||
const keys = getVapidKeys();
|
||||
webpush.setVapidDetails(
|
||||
'mailto:admin@teamchat.local',
|
||||
keys.publicKey,
|
||||
keys.privateKey
|
||||
);
|
||||
return keys.publicKey;
|
||||
// ── 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in index.js
|
||||
let vapidPublicKey = null;
|
||||
function getVapidPublicKey() {
|
||||
if (!vapidPublicKey) vapidPublicKey = initWebPush();
|
||||
return vapidPublicKey;
|
||||
}
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Send a push notification to all subscriptions for a user
|
||||
async function sendPushToUser(userId, payload) {
|
||||
const db = getDb();
|
||||
getVapidPublicKey(); // ensure webpush is configured
|
||||
const subs = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
|
||||
for (const sub of subs) {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
// Subscription expired — remove it
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
|
||||
// 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) {
|
||||
try {
|
||||
const subs = await query(schema,
|
||||
`SELECT * FROM push_subscriptions
|
||||
WHERE user_id = $1
|
||||
AND (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL)`,
|
||||
[userId]
|
||||
);
|
||||
if (subs.length === 0) {
|
||||
console.log(`[Push] No subscription for user ${userId} (schema=${schema})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const messaging = getMessaging();
|
||||
const wp = getWebPush();
|
||||
|
||||
for (const sub of subs) {
|
||||
if (sub.fcm_token) {
|
||||
// ── FCM path ──────────────────────────────────────────────────────────
|
||||
if (!messaging) continue;
|
||||
try {
|
||||
await messaging.send({
|
||||
token: sub.fcm_token,
|
||||
notification: {
|
||||
title: payload.title || 'New Message',
|
||||
body: payload.body || '',
|
||||
},
|
||||
data: {
|
||||
url: payload.url || '/',
|
||||
groupId: payload.groupId ? String(payload.groupId) : '',
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: { sound: 'default' },
|
||||
},
|
||||
apns: {
|
||||
headers: { 'apns-priority': '10' },
|
||||
payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
|
||||
},
|
||||
webpush: {
|
||||
headers: { Urgency: 'high' },
|
||||
notification: {
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192-maskable.png',
|
||||
tag: payload.groupId ? `rosterchirp-group-${payload.groupId}` : 'rosterchirp-message',
|
||||
renotify: true,
|
||||
},
|
||||
fcm_options: { link: payload.url || '/' },
|
||||
},
|
||||
});
|
||||
console.log(`[Push] FCM sent to user ${userId} device=${sub.device} schema=${schema}`);
|
||||
} catch (err) {
|
||||
const stale = [
|
||||
'messaging/registration-token-not-registered',
|
||||
'messaging/invalid-registration-token',
|
||||
'messaging/invalid-argument',
|
||||
];
|
||||
if (stale.includes(err.code)) {
|
||||
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
|
||||
console.log(`[Push] Removed stale FCM token for user ${userId} device=${sub.device}`);
|
||||
}
|
||||
}
|
||||
} else if (sub.webpush_endpoint) {
|
||||
// ── Web Push / VAPID path (iOS, Firefox, Edge) ────────────────────────
|
||||
if (!wp) continue;
|
||||
const subscription = {
|
||||
endpoint: sub.webpush_endpoint,
|
||||
keys: { p256dh: sub.webpush_p256dh, auth: sub.webpush_auth },
|
||||
};
|
||||
const body = JSON.stringify({
|
||||
notification: {
|
||||
title: payload.title || 'New Message',
|
||||
body: payload.body || '',
|
||||
},
|
||||
data: {
|
||||
url: payload.url || '/',
|
||||
groupId: payload.groupId ? String(payload.groupId) : '',
|
||||
icon: '/icons/icon-192.png',
|
||||
},
|
||||
});
|
||||
try {
|
||||
await wp.sendNotification(subscription, body, { TTL: 86400, urgency: 'high' });
|
||||
console.log(`[Push] WebPush sent to user ${userId} device=${sub.device} schema=${schema}`);
|
||||
} catch (err) {
|
||||
// 404/410 = subscription expired or user unsubscribed — remove the stale row
|
||||
if (err.statusCode === 404 || err.statusCode === 410) {
|
||||
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
|
||||
console.log(`[Push] Removed stale WebPush sub for user ${userId} device=${sub.device}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Push] sendPushToUser error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/push/vapid-public — returns VAPID public key for client subscription
|
||||
router.get('/vapid-public', (req, res) => {
|
||||
res.json({ publicKey: getVapidPublicKey() });
|
||||
// ── Routes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Public — frontend fetches this to initialise the Firebase JS SDK
|
||||
router.get('/firebase-config', (req, res) => {
|
||||
const apiKey = process.env.FIREBASE_API_KEY;
|
||||
const projectId = process.env.FIREBASE_PROJECT_ID;
|
||||
const messagingSenderId = process.env.FIREBASE_MESSAGING_SENDER_ID;
|
||||
const appId = process.env.FIREBASE_APP_ID;
|
||||
const vapidKey = process.env.FIREBASE_VAPID_KEY;
|
||||
|
||||
if (!apiKey || !projectId || !messagingSenderId || !appId || !vapidKey) {
|
||||
return res.status(503).json({ error: 'FCM not configured' });
|
||||
}
|
||||
res.json({ apiKey, projectId, messagingSenderId, appId, vapidKey });
|
||||
});
|
||||
|
||||
// POST /api/push/subscribe — save push subscription for current user
|
||||
router.post('/subscribe', authMiddleware, (req, res) => {
|
||||
// Public — iOS frontend fetches this to create a PushManager subscription
|
||||
router.get('/vapid-public-key', (req, res) => {
|
||||
const pub = process.env.VAPID_PUBLIC;
|
||||
if (!pub) return res.status(503).json({ error: 'VAPID not configured' });
|
||||
res.json({ vapidPublicKey: pub });
|
||||
});
|
||||
|
||||
// Register / refresh an FCM token for the logged-in user (Android/Chrome)
|
||||
router.post('/subscribe', authMiddleware, async (req, res) => {
|
||||
const { fcmToken } = req.body;
|
||||
if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' });
|
||||
try {
|
||||
const device = req.device || 'desktop';
|
||||
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, fcm_token) VALUES ($1, $2, $3)',
|
||||
[req.user.id, device, fcmToken]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// 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: 'Invalid subscription' });
|
||||
return res.status(400).json({ error: 'endpoint and keys.p256dh/auth required' });
|
||||
}
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET user_id = ?, p256dh = ?, auth = ?
|
||||
`).run(req.user.id, endpoint, keys.p256dh, keys.auth, req.user.id, keys.p256dh, keys.auth);
|
||||
res.json({ success: true });
|
||||
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 }); }
|
||||
});
|
||||
|
||||
// POST /api/push/unsubscribe — remove subscription
|
||||
router.post('/unsubscribe', authMiddleware, (req, res) => {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint);
|
||||
res.json({ success: true });
|
||||
// Remove the push subscription for the logged-in user / device
|
||||
router.post('/unsubscribe', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const device = req.device || 'desktop';
|
||||
await exec(req.schema,
|
||||
'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
|
||||
[req.user.id, device]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = { router, sendPushToUser, getVapidPublicKey };
|
||||
// Send a test push to the requesting user's own devices.
|
||||
// Covers both FCM tokens and Web Push subscriptions in one call.
|
||||
// mode query param only applies to FCM test messages (notification vs browser).
|
||||
router.post('/test', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const subs = await query(req.schema,
|
||||
`SELECT * FROM push_subscriptions
|
||||
WHERE user_id = $1
|
||||
AND (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL)`,
|
||||
[req.user.id]
|
||||
);
|
||||
if (subs.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'No push subscription found. Grant notification permission and reload the app first.',
|
||||
});
|
||||
}
|
||||
|
||||
const messaging = getMessaging();
|
||||
const wp = getWebPush();
|
||||
const mode = req.query.mode === 'browser' ? 'browser' : 'notification';
|
||||
const results = [];
|
||||
|
||||
for (const sub of subs) {
|
||||
if (sub.fcm_token) {
|
||||
if (!messaging) {
|
||||
results.push({ device: sub.device, type: 'fcm', status: 'failed', error: 'Firebase Admin not initialised — check FIREBASE_SERVICE_ACCOUNT in .env' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const message = {
|
||||
token: sub.fcm_token,
|
||||
android: { priority: 'high', notification: { sound: 'default' } },
|
||||
apns: {
|
||||
headers: { 'apns-priority': '10' },
|
||||
payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
|
||||
},
|
||||
webpush: {
|
||||
headers: { Urgency: 'high' },
|
||||
notification: { icon: '/icons/icon-192.png', badge: '/icons/icon-192-maskable.png', tag: 'rosterchirp-test' },
|
||||
},
|
||||
};
|
||||
if (mode === 'browser') {
|
||||
message.webpush.notification.title = 'RosterChirp Test (browser)';
|
||||
message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.';
|
||||
message.webpush.fcm_options = { link: '/' };
|
||||
} else {
|
||||
message.notification = { title: 'RosterChirp Test', body: 'Push notifications are working!' };
|
||||
message.data = { url: '/', groupId: '' };
|
||||
message.webpush.fcm_options = { link: '/' };
|
||||
}
|
||||
await messaging.send(message);
|
||||
results.push({ device: sub.device, type: 'fcm', mode, status: 'sent' });
|
||||
} catch (err) {
|
||||
results.push({ device: sub.device, type: 'fcm', mode, status: 'failed', error: err.message, code: err.code });
|
||||
}
|
||||
} else if (sub.webpush_endpoint) {
|
||||
if (!wp) {
|
||||
results.push({ device: sub.device, type: 'webpush', status: 'failed', error: 'VAPID keys not configured — check VAPID_PUBLIC/VAPID_PRIVATE in .env' });
|
||||
continue;
|
||||
}
|
||||
const subscription = {
|
||||
endpoint: sub.webpush_endpoint,
|
||||
keys: { p256dh: sub.webpush_p256dh, auth: sub.webpush_auth },
|
||||
};
|
||||
try {
|
||||
await wp.sendNotification(
|
||||
subscription,
|
||||
JSON.stringify({
|
||||
notification: { title: 'RosterChirp Test', body: 'Push notifications are working!' },
|
||||
data: { url: '/', icon: '/icons/icon-192.png' },
|
||||
}),
|
||||
{ TTL: 300, urgency: 'high' }
|
||||
);
|
||||
results.push({ device: sub.device, type: 'webpush', status: 'sent' });
|
||||
} catch (err) {
|
||||
results.push({ device: sub.device, type: 'webpush', status: 'failed', error: err.message, statusCode: err.statusCode });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Debug endpoint (admin-only) — lists all push subscriptions for this schema
|
||||
router.get('/debug', authMiddleware, async (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||
try {
|
||||
const subs = await query(req.schema, `
|
||||
SELECT ps.id, ps.user_id, ps.device,
|
||||
ps.fcm_token,
|
||||
ps.webpush_endpoint,
|
||||
u.name, u.email
|
||||
FROM push_subscriptions ps
|
||||
JOIN users u ON u.id = ps.user_id
|
||||
WHERE ps.fcm_token IS NOT NULL OR ps.webpush_endpoint IS NOT NULL
|
||||
ORDER BY u.name, ps.device
|
||||
`);
|
||||
const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT && process.env.FIREBASE_VAPID_KEY);
|
||||
const firebaseAdminReady = !!getMessaging();
|
||||
const vapidConfigured = !!(process.env.VAPID_PUBLIC && process.env.VAPID_PRIVATE);
|
||||
res.json({ subscriptions: subs, fcmConfigured, firebaseAdminReady, vapidConfigured });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = { router, sendPushToUser };
|
||||
|
||||
883
backend/src/routes/schedule.js
Normal file
@@ -0,0 +1,883 @@
|
||||
const express = require('express');
|
||||
const { query, queryOne, queryResult, exec, withTransaction } = require('../models/db');
|
||||
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||
const multer = require('multer');
|
||||
const { parse: csvParse } = require('csv-parse/sync');
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
|
||||
|
||||
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||||
|
||||
module.exports = function(io) {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ── Event notification helper ─────────────────────────────────────────────────
|
||||
// Posts a plain system message to each assigned user group's DM channel
|
||||
// when an event is created or updated.
|
||||
|
||||
async function sendEventMessage(schema, dmGroupId, actorId, content) {
|
||||
const r = await queryResult(schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[dmGroupId, actorId, content]
|
||||
);
|
||||
const msg = await queryOne(schema, `
|
||||
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name,
|
||||
u.avatar AS user_avatar, u.role AS user_role, u.status AS user_status,
|
||||
u.hide_admin_tag AS user_hide_admin_tag, u.about_me AS user_about_me, u.allow_dm AS user_allow_dm
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = $1
|
||||
`, [r.rows[0].id]);
|
||||
if (msg) { msg.reactions = []; io.to(R(schema, 'group', dmGroupId)).emit('message:new', msg); }
|
||||
}
|
||||
|
||||
async function postEventNotification(schema, eventId, actorId) {
|
||||
try {
|
||||
const event = await queryOne(schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
if (!event) return;
|
||||
const dateStr = new Date(event.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
|
||||
const groups = await query(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
|
||||
`, [eventId]);
|
||||
for (const { dm_group_id } of groups)
|
||||
await sendEventMessage(schema, dm_group_id, actorId, `📅 Event added: "${event.title}" on ${dateStr}`);
|
||||
} catch (e) {
|
||||
console.error('[Schedule] postEventNotification error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getPartnerId(schema, userId) {
|
||||
const row = await queryOne(schema,
|
||||
'SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END AS partner_id FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1',
|
||||
[userId]
|
||||
);
|
||||
return row?.partner_id || null;
|
||||
}
|
||||
|
||||
async function isToolManagerFn(schema, user) {
|
||||
if (user.role === 'admin' || user.role === 'manager') return true;
|
||||
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
|
||||
const gm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_group_managers'");
|
||||
const groupIds = [...new Set([...JSON.parse(tm?.value||'[]'), ...JSON.parse(gm?.value||'[]')])];
|
||||
if (!groupIds.length) return false;
|
||||
const ph = groupIds.map((_,i) => `$${i+2}`).join(',');
|
||||
return !!(await queryOne(schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [user.id, ...groupIds]));
|
||||
}
|
||||
|
||||
async function canViewEvent(schema, event, userId, isToolManager) {
|
||||
if (isToolManager || event.is_public) return true;
|
||||
const assigned = 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, userId]);
|
||||
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) {
|
||||
event.event_type = event.event_type_id
|
||||
? await queryOne(schema, 'SELECT * FROM event_types WHERE id=$1', [event.event_type_id])
|
||||
: null;
|
||||
// recurrence_rule is JSONB in Postgres — already parsed, no need to JSON.parse
|
||||
event.user_groups = await query(schema, `
|
||||
SELECT ug.id, ug.name FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id=eug.user_group_id WHERE eug.event_id=$1
|
||||
`, [event.id]);
|
||||
return event;
|
||||
}
|
||||
|
||||
async function applyEventUpdate(schema, eventId, fields, userGroupIds) {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent } = fields;
|
||||
await exec(schema, `
|
||||
UPDATE events SET
|
||||
title = COALESCE($1, title),
|
||||
event_type_id = $2,
|
||||
start_at = COALESCE($3, start_at),
|
||||
end_at = COALESCE($4, end_at),
|
||||
all_day = COALESCE($5, all_day),
|
||||
location = $6,
|
||||
description = $7,
|
||||
is_public = COALESCE($8, is_public),
|
||||
track_availability = COALESCE($9, track_availability),
|
||||
recurrence_rule = $10,
|
||||
updated_at = NOW()
|
||||
WHERE id = $11
|
||||
`, [
|
||||
title?.trim() || null,
|
||||
eventTypeId !== undefined ? (eventTypeId || null) : origEvent.event_type_id,
|
||||
startAt || null,
|
||||
endAt || null,
|
||||
allDay !== undefined ? allDay : null,
|
||||
location !== undefined ? (location || null) : origEvent.location,
|
||||
description !== undefined ? (description || null) : origEvent.description,
|
||||
isPublic !== undefined ? isPublic : null,
|
||||
trackAvailability !== undefined ? trackAvailability : null,
|
||||
recurrenceRule !== undefined ? recurrenceRule : origEvent.recurrence_rule,
|
||||
eventId,
|
||||
]);
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
await exec(schema, 'DELETE FROM event_user_groups WHERE event_id=$1', [eventId]);
|
||||
for (const ugId of userGroupIds)
|
||||
await exec(schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/event-types', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const eventTypes = await query(req.schema, 'SELECT * FROM event_types ORDER BY is_default DESC, name ASC');
|
||||
res.json({ eventTypes });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/event-types', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
try {
|
||||
if (await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [name.trim()]))
|
||||
return res.status(400).json({ error: 'Event type with that name already exists' });
|
||||
const r = await queryResult(req.schema,
|
||||
'INSERT INTO event_types (name,colour,default_user_group_id,default_duration_hrs) VALUES ($1,$2,$3,$4) RETURNING id',
|
||||
[name.trim(), colour||'#6366f1', defaultUserGroupId||null, defaultDurationHrs||1.0]
|
||||
);
|
||||
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [r.rows[0].id]) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
|
||||
if (!et) return res.status(404).json({ error: 'Not found' });
|
||||
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
|
||||
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
|
||||
if (name && name.trim() !== et.name) {
|
||||
if (await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1) AND id!=$2', [name.trim(), et.id]))
|
||||
return res.status(400).json({ error: 'Name already in use' });
|
||||
}
|
||||
await exec(req.schema, `
|
||||
UPDATE event_types SET
|
||||
name = COALESCE($1, name),
|
||||
colour = COALESCE($2, colour),
|
||||
default_user_group_id = $3,
|
||||
default_duration_hrs = COALESCE($4, default_duration_hrs)
|
||||
WHERE id=$5
|
||||
`, [name?.trim()||null, colour||null, defaultUserGroupId??et.default_user_group_id, defaultDurationHrs||null, et.id]);
|
||||
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [et.id]) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
|
||||
if (!et) return res.status(404).json({ error: 'Not found' });
|
||||
if (et.is_default || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected event type' });
|
||||
await exec(req.schema, 'UPDATE events SET event_type_id=NULL WHERE event_type_id=$1', [et.id]);
|
||||
await exec(req.schema, 'DELETE FROM event_types WHERE id=$1', [et.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── User's own groups (for regular users creating events) ─────────────────────
|
||||
|
||||
router.get('/my-groups', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const groups = await query(req.schema, `
|
||||
SELECT ug.id, ug.name FROM user_groups ug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id = ug.id
|
||||
WHERE ugm.user_id = $1
|
||||
ORDER BY ug.name ASC
|
||||
`, [req.user.id]);
|
||||
res.json({ groups });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const { from, to } = req.query;
|
||||
let sql = 'SELECT * FROM events WHERE 1=1';
|
||||
const params = [];
|
||||
let pi = 1;
|
||||
if (from) { sql += ` AND end_at >= $${pi++}`; params.push(from); }
|
||||
if (to) { sql += ` AND start_at <= $${pi++}`; params.push(to); }
|
||||
sql += ' ORDER BY start_at ASC';
|
||||
const rawEvents = await query(req.schema, sql, params);
|
||||
const events = [];
|
||||
for (const e of rawEvents) {
|
||||
if (!(await canViewEvent(req.schema, e, req.user.id, itm))) continue;
|
||||
await enrichEvent(req.schema, e);
|
||||
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [e.id, req.user.id]);
|
||||
e.my_response = mine?.response || null;
|
||||
events.push(e);
|
||||
}
|
||||
res.json({ events });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get('/me/pending', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const pending = await query(req.schema, `
|
||||
SELECT DISTINCT e.* FROM events e
|
||||
JOIN event_user_groups eug ON eug.event_id=e.id
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE ugm.user_id=$1 AND e.track_availability=TRUE
|
||||
AND e.end_at >= NOW()
|
||||
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id=e.id AND ea.user_id=$1)
|
||||
ORDER BY e.start_at ASC
|
||||
`, [req.user.id]);
|
||||
const result = [];
|
||||
for (const e of pending) result.push(await enrichEvent(req.schema, e));
|
||||
res.json({ events: result });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.get('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
|
||||
await enrichEvent(req.schema, event);
|
||||
const partnerId = await getPartnerId(req.schema, req.user.id);
|
||||
const isMember = !itm && !!(
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, req.user.id]))
|
||||
||
|
||||
// Guardian Only: user has an alias in one of the event's user groups
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, req.user.id]))
|
||||
||
|
||||
// Partner is assigned to this event (user group or alias)
|
||||
(partnerId && !!(
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, partnerId]))
|
||||
||
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, partnerId]))
|
||||
))
|
||||
);
|
||||
if (event.track_availability && (itm || isMember)) {
|
||||
// User responses
|
||||
const userAvail = await query(req.schema, `
|
||||
SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name, u.avatar, FALSE AS is_alias
|
||||
FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
|
||||
`, [req.params.id]);
|
||||
// Alias responses (Guardian Only mode)
|
||||
const aliasAvail = await query(req.schema, `
|
||||
SELECT eaa.response, eaa.note, eaa.updated_at, ga.id AS alias_id, ga.first_name, ga.last_name, ga.avatar, ga.guardian_id, TRUE AS is_alias
|
||||
FROM event_alias_availability eaa JOIN guardian_aliases ga ON ga.id=eaa.alias_id WHERE eaa.event_id=$1
|
||||
`, [req.params.id]);
|
||||
event.availability = [...userAvail, ...aliasAvail];
|
||||
|
||||
// For non-tool-managers: mask notes on entries that don't belong to them or their aliases
|
||||
if (!itm) {
|
||||
const myAliasIds = new Set(
|
||||
(await query(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE guardian_id=$1
|
||||
OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||
)`,
|
||||
[req.user.id])).map(r => r.id)
|
||||
);
|
||||
event.availability = event.availability.map(r => {
|
||||
const isOwn = !r.is_alias && r.user_id === req.user.id;
|
||||
const isOwnAlias = r.is_alias && myAliasIds.has(r.alias_id);
|
||||
return (isOwn || isOwnAlias) ? r : { ...r, note: null };
|
||||
});
|
||||
}
|
||||
|
||||
if (itm) {
|
||||
const assignedRows = await query(req.schema, `
|
||||
SELECT DISTINCT u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name
|
||||
FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
JOIN users u ON u.id=ugm.user_id
|
||||
WHERE eug.event_id=$1
|
||||
`, [req.params.id]);
|
||||
// Also include alias members
|
||||
const assignedAliases = await query(req.schema, `
|
||||
SELECT DISTINCT ga.id AS alias_id, ga.first_name, ga.last_name
|
||||
FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1
|
||||
`, [req.params.id]);
|
||||
const respondedUserIds = new Set(userAvail.map(r => r.user_id));
|
||||
const respondedAliasIds = new Set(aliasAvail.map(r => r.alias_id));
|
||||
const noResponseRows = [
|
||||
...assignedRows.filter(r => !respondedUserIds.has(r.user_id)),
|
||||
...assignedAliases.filter(r => !respondedAliasIds.has(r.alias_id)).map(r => ({ ...r, is_alias: true })),
|
||||
];
|
||||
event.no_response_count = noResponseRows.length;
|
||||
event.no_response_users = noResponseRows;
|
||||
}
|
||||
|
||||
// Detect if event targets the players group (for responder select dropdown)
|
||||
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||
const playersGroupId = parseInt(playersRow?.value);
|
||||
event.has_players_group = !!(playersGroupId && event.user_groups?.some(g => g.id === playersGroupId));
|
||||
|
||||
// Detect if event targets the guardians group (so guardian shows own name in select)
|
||||
const guardiansRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_guardians_group_id'");
|
||||
const guardiansGroupId = parseInt(guardiansRow?.value);
|
||||
event.in_guardians_group = !!(guardiansGroupId && event.user_groups?.some(g => g.id === guardiansGroupId) &&
|
||||
(
|
||||
(await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, req.user.id]))
|
||||
||
|
||||
(partnerId && await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, partnerId]))
|
||||
));
|
||||
|
||||
// Return current user's aliases (and partner's) for the responder dropdown (Guardian Only)
|
||||
if (event.has_players_group) {
|
||||
event.my_aliases = await query(req.schema,
|
||||
`SELECT id,first_name,last_name,avatar FROM guardian_aliases
|
||||
WHERE guardian_id=$1
|
||||
OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||
)
|
||||
ORDER BY first_name,last_name`,
|
||||
[req.user.id]
|
||||
);
|
||||
}
|
||||
|
||||
// Return partner user info if they are in one of this event's user groups
|
||||
if (partnerId) {
|
||||
const partnerInGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, partnerId]);
|
||||
if (partnerInGroup) {
|
||||
const pUser = await queryOne(req.schema, 'SELECT id,name,display_name,avatar FROM users WHERE id=$1', [partnerId]);
|
||||
const pGp = await queryOne(req.schema,
|
||||
'SELECT respond_separately FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||
[Math.min(req.user.id, partnerId), Math.max(req.user.id, partnerId)]
|
||||
);
|
||||
event.my_partner = pUser ? { ...pUser, respond_separately: pGp?.respond_separately || false } : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const mine = await queryOne(req.schema, 'SELECT response, note FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
event.my_response = mine?.response || null;
|
||||
event.my_note = mine?.note || null;
|
||||
res.json({ event });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
|
||||
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
|
||||
try {
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const groupIds = Array.isArray(userGroupIds) ? userGroupIds : [];
|
||||
if (!itm) {
|
||||
// Regular users: must select at least one group they belong to; event always private
|
||||
if (!groupIds.length) return res.status(400).json({ error: 'Select at least one group' });
|
||||
for (const ugId of groupIds) {
|
||||
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
|
||||
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
|
||||
}
|
||||
}
|
||||
const effectiveIsPublic = itm ? (isPublic !== false) : false;
|
||||
const r = await queryResult(req.schema, `
|
||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
||||
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
|
||||
effectiveIsPublic, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
||||
const eventId = r.rows[0].id;
|
||||
for (const ugId of groupIds)
|
||||
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||
if (groupIds.length > 0)
|
||||
await postEventNotification(req.schema, eventId, req.user.id);
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
res.json({ event: await enrichEvent(req.schema, event) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
let { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope, occurrenceStart } = req.body;
|
||||
if (!itm) {
|
||||
// Regular users editing their own event: force private, validate group membership
|
||||
isPublic = false;
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
if (!userGroupIds.length) return res.status(400).json({ error: 'Select at least one group' });
|
||||
for (const ugId of userGroupIds) {
|
||||
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
|
||||
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
|
||||
}
|
||||
// Preserve any existing groups on this event that the user doesn't belong to
|
||||
// (e.g. groups added by an admin) — silently merge them back into the submitted list
|
||||
const existingGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
|
||||
const submittedSet = new Set(userGroupIds.map(Number));
|
||||
for (const gid of existingGroupIds) {
|
||||
if (submittedSet.has(gid)) continue;
|
||||
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, gid]);
|
||||
if (!member) userGroupIds.push(gid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
|
||||
|
||||
// Resolve group list for new-event paths (exception instance / future split)
|
||||
// Pre-fetched before any transaction so it uses the regular pool connection
|
||||
const resolvedGroupIds = Array.isArray(userGroupIds)
|
||||
? userGroupIds
|
||||
: (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
|
||||
|
||||
// ── Capture prev group/DM mapping before any mutations ────────────────────
|
||||
const prevGroupRows = await query(req.schema, `
|
||||
SELECT eug.user_group_id, ug.dm_group_id FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ug.dm_group_id IS NOT NULL
|
||||
`, [req.params.id]);
|
||||
const prevGroupIdSet = new Set(prevGroupRows.map(r => r.user_group_id));
|
||||
|
||||
let targetId = Number(req.params.id); // ID of the event to return in the response
|
||||
|
||||
if (event.recurrence_rule && recurringScope === 'this') {
|
||||
// ── EXCEPTION INSTANCE ────────────────────────────────────────────────
|
||||
// 1. Add occurrence date to master's exceptions (hides the virtual occurrence)
|
||||
// 2. INSERT a new standalone event row for this modified occurrence
|
||||
const occDate = new Date(occurrenceStart || event.start_at);
|
||||
const occDateStr = `${occDate.getFullYear()}-${pad(occDate.getMonth()+1)}-${pad(occDate.getDate())}`;
|
||||
await withTransaction(req.schema, async (client) => {
|
||||
const rule = { ...event.recurrence_rule };
|
||||
const existing = Array.isArray(rule.exceptions) ? rule.exceptions : [];
|
||||
rule.exceptions = [...existing.filter(d => d !== occDateStr), occDateStr];
|
||||
await client.query('UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), event.id]);
|
||||
|
||||
const r2 = await client.query(`
|
||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,created_by,recurring_master_id,original_start_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id
|
||||
`, [
|
||||
title?.trim() || event.title,
|
||||
eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
|
||||
startAt || occurrenceStart || event.start_at,
|
||||
endAt || event.end_at,
|
||||
allDay !== undefined ? allDay : event.all_day,
|
||||
location !== undefined ? (location || null) : event.location,
|
||||
description !== undefined ? (description || null) : event.description,
|
||||
isPublic !== undefined ? isPublic : event.is_public,
|
||||
trackAvailability !== undefined ? trackAvailability : event.track_availability,
|
||||
event.created_by,
|
||||
event.id,
|
||||
occurrenceStart || event.start_at,
|
||||
]);
|
||||
targetId = r2.rows[0].id;
|
||||
for (const ugId of resolvedGroupIds)
|
||||
await client.query('INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [targetId, ugId]);
|
||||
});
|
||||
|
||||
// Notify: "Event updated" for the occurrence date
|
||||
try {
|
||||
const exceptionGroupRows = await query(req.schema, `
|
||||
SELECT ug.dm_group_id FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ug.dm_group_id IS NOT NULL
|
||||
`, [targetId]);
|
||||
const dateStr = new Date(startAt || occurrenceStart || event.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
|
||||
const timeChanged = startAt && new Date(startAt).getTime() !== occDate.getTime();
|
||||
const locationChanged = location !== undefined && (location || null) !== (event.location || null);
|
||||
if (timeChanged) {
|
||||
for (const { dm_group_id } of exceptionGroupRows)
|
||||
await sendEventMessage(req.schema, dm_group_id, req.user.id, `📅 Event updated: "${title?.trim() || event.title}" on ${dateStr}`);
|
||||
}
|
||||
if (locationChanged) {
|
||||
const locMsg = location ? `📍 Location updated to "${location}": "${title?.trim() || event.title}" on ${dateStr}` : `📍 Location removed: "${title?.trim() || event.title}" on ${dateStr}`;
|
||||
for (const { dm_group_id } of exceptionGroupRows)
|
||||
await sendEventMessage(req.schema, dm_group_id, req.user.id, locMsg);
|
||||
}
|
||||
} catch (e) { console.error('[Schedule] exception notification error:', e.message); }
|
||||
|
||||
} else if (event.recurrence_rule && recurringScope === 'future') {
|
||||
// ── SERIES SPLIT ──────────────────────────────────────────────────────
|
||||
// Truncate old master to end before this occurrence; INSERT new master starting here
|
||||
const occDate = new Date(occurrenceStart || event.start_at);
|
||||
if (occDate <= new Date(event.start_at)) {
|
||||
// Splitting at/before the first occurrence = effectively "edit all"
|
||||
await applyEventUpdate(req.schema, event.id, fields, userGroupIds);
|
||||
targetId = event.id;
|
||||
} else {
|
||||
await withTransaction(req.schema, async (client) => {
|
||||
// 1. Truncate old master
|
||||
const endBefore = new Date(occDate);
|
||||
endBefore.setDate(endBefore.getDate() - 1);
|
||||
const rule = { ...event.recurrence_rule };
|
||||
rule.ends = 'on';
|
||||
rule.endDate = `${endBefore.getFullYear()}-${pad(endBefore.getMonth()+1)}-${pad(endBefore.getDate())}`;
|
||||
delete rule.endCount;
|
||||
await client.query('UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), event.id]);
|
||||
|
||||
// 2. INSERT new master with submitted fields
|
||||
const newRecRule = recurrenceRule !== undefined ? recurrenceRule : event.recurrence_rule;
|
||||
const r2 = await client.query(`
|
||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
||||
`, [
|
||||
title?.trim() || event.title,
|
||||
eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
|
||||
startAt || (occurrenceStart || event.start_at),
|
||||
endAt || event.end_at,
|
||||
allDay !== undefined ? allDay : event.all_day,
|
||||
location !== undefined ? (location || null) : event.location,
|
||||
description !== undefined ? (description || null) : event.description,
|
||||
isPublic !== undefined ? isPublic : event.is_public,
|
||||
trackAvailability !== undefined ? trackAvailability : event.track_availability,
|
||||
newRecRule ? JSON.stringify(newRecRule) : null,
|
||||
event.created_by,
|
||||
]);
|
||||
targetId = r2.rows[0].id;
|
||||
for (const ugId of resolvedGroupIds)
|
||||
await client.query('INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [targetId, ugId]);
|
||||
});
|
||||
await postEventNotification(req.schema, targetId, req.user.id);
|
||||
}
|
||||
|
||||
} else {
|
||||
// ── EDIT ALL (or non-recurring direct edit) ───────────────────────────
|
||||
await applyEventUpdate(req.schema, event.id, fields, userGroupIds);
|
||||
targetId = event.id;
|
||||
|
||||
// Clean up availability for users removed from groups
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
const prevGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [event.id])).map(r => r.user_group_id);
|
||||
const newGroupSet = new Set(userGroupIds.map(Number));
|
||||
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
|
||||
for (const removedGid of removedGroupIds) {
|
||||
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id);
|
||||
for (const uid of removedUids) {
|
||||
if (newGroupSet.size > 0) {
|
||||
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
|
||||
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
|
||||
if (stillAssigned) continue;
|
||||
}
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [event.id, uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Targeted notifications — only for meaningful changes, only to relevant groups
|
||||
try {
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [event.id]);
|
||||
const finalGroupRows = await query(req.schema, `
|
||||
SELECT eug.user_group_id, ug.dm_group_id FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ug.dm_group_id IS NOT NULL
|
||||
`, [event.id]);
|
||||
const allDmIds = finalGroupRows.map(r => r.dm_group_id);
|
||||
const dateStr = new Date(updated.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
|
||||
|
||||
// Newly added groups → "Event added" only to those groups
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
for (const { user_group_id, dm_group_id } of finalGroupRows) {
|
||||
if (!prevGroupIdSet.has(user_group_id))
|
||||
await sendEventMessage(req.schema, dm_group_id, req.user.id, `📅 Event added: "${updated.title}" on ${dateStr}`);
|
||||
}
|
||||
}
|
||||
// Date/time changed → "Event updated" to all groups
|
||||
const timeChanged = (startAt && new Date(startAt).getTime() !== new Date(event.start_at).getTime())
|
||||
|| (endAt && new Date(endAt).getTime() !== new Date(event.end_at).getTime())
|
||||
|| (allDay !== undefined && !!allDay !== !!event.all_day);
|
||||
if (timeChanged) {
|
||||
for (const dmId of allDmIds)
|
||||
await sendEventMessage(req.schema, dmId, req.user.id, `📅 Event updated: "${updated.title}" on ${dateStr}`);
|
||||
}
|
||||
// Location changed → "Location updated" to all groups
|
||||
const locationChanged = location !== undefined && (location || null) !== (event.location || null);
|
||||
if (locationChanged) {
|
||||
const locContent = updated.location
|
||||
? `📍 Location updated to "${updated.location}": "${updated.title}" on ${dateStr}`
|
||||
: `📍 Location removed: "${updated.title}" on ${dateStr}`;
|
||||
for (const dmId of allDmIds)
|
||||
await sendEventMessage(req.schema, dmId, req.user.id, locContent);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Schedule] event update notification error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [targetId]);
|
||||
res.json({ event: await enrichEvent(req.schema, updated) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
const { recurringScope, occurrenceStart } = req.body || {};
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
|
||||
if (event.recurrence_rule && recurringScope === 'all') {
|
||||
// Delete the single base row — all virtual occurrences disappear with it
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
|
||||
} else if (event.recurrence_rule && recurringScope === 'future') {
|
||||
// Truncate the series so it ends before this occurrence
|
||||
const occDate = new Date(occurrenceStart || event.start_at);
|
||||
if (occDate <= new Date(event.start_at)) {
|
||||
// Occurrence is at or before the base start — delete the whole series
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
} else {
|
||||
const endBefore = new Date(occDate);
|
||||
endBefore.setDate(endBefore.getDate() - 1);
|
||||
const rule = { ...event.recurrence_rule };
|
||||
rule.ends = 'on';
|
||||
rule.endDate = `${endBefore.getFullYear()}-${pad(endBefore.getMonth()+1)}-${pad(endBefore.getDate())}`;
|
||||
delete rule.endCount;
|
||||
await exec(req.schema, 'UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), req.params.id]);
|
||||
}
|
||||
|
||||
} else if (event.recurrence_rule && recurringScope === 'this') {
|
||||
// Add occurrence date to exceptions — base row and other occurrences are untouched
|
||||
const occDate = new Date(occurrenceStart || event.start_at);
|
||||
const occDateStr = `${occDate.getFullYear()}-${pad(occDate.getMonth()+1)}-${pad(occDate.getDate())}`;
|
||||
const rule = { ...event.recurrence_rule };
|
||||
const existing = Array.isArray(rule.exceptions) ? rule.exceptions : [];
|
||||
rule.exceptions = [...existing.filter(d => d !== occDateStr), occDateStr];
|
||||
await exec(req.schema, 'UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), req.params.id]);
|
||||
|
||||
} else {
|
||||
// Non-recurring single delete
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Availability ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.put('/:id/availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
|
||||
const { response, note, aliasId, forPartnerId } = req.body;
|
||||
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
|
||||
const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
|
||||
|
||||
if (forPartnerId) {
|
||||
// Respond on behalf of partner — verify partnership and partner's group membership
|
||||
const isPartner = await queryOne(req.schema,
|
||||
'SELECT 1 FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||
[req.user.id, forPartnerId]);
|
||||
if (!isPartner) return res.status(403).json({ error: 'Not your partner' });
|
||||
const partnerInGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, forPartnerId]);
|
||||
if (!partnerInGroup) return res.status(403).json({ error: 'Partner is not assigned to this event' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||
`, [event.id, forPartnerId, response, trimmedNote]);
|
||||
return res.json({ success: true, response, note: trimmedNote });
|
||||
}
|
||||
|
||||
if (aliasId) {
|
||||
// Alias response (Guardian Only mode) — verify alias belongs to current user or their partner
|
||||
const alias = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_alias_availability (event_id,alias_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (event_id,alias_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||
`, [event.id, aliasId, response, trimmedNote]);
|
||||
} else {
|
||||
// Regular user response — also allowed if partner is in the event's group
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const avPartner = await getPartnerId(req.schema, req.user.id);
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
|
||||
`, [event.id, req.user.id, avPartner || -1]);
|
||||
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||
`, [event.id, req.user.id, response, trimmedNote]);
|
||||
}
|
||||
res.json({ success: true, response, note: trimmedNote });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/:id/availability/note', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'No availability response found' });
|
||||
const trimmedNote = req.body.note ? String(req.body.note).trim().slice(0, 20) : null;
|
||||
await exec(req.schema, 'UPDATE event_availability SET note=$1, updated_at=NOW() WHERE event_id=$2 AND user_id=$3', [trimmedNote, req.params.id, req.user.id]);
|
||||
res.json({ success: true, note: trimmedNote });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/:id/availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { aliasId, forPartnerId } = req.query;
|
||||
if (forPartnerId) {
|
||||
const isPartner = await queryOne(req.schema,
|
||||
'SELECT 1 FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||
[req.user.id, forPartnerId]);
|
||||
if (!isPartner) return res.status(403).json({ error: 'Not your partner' });
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, forPartnerId]);
|
||||
} else if (aliasId) {
|
||||
const alias = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
|
||||
await exec(req.schema, 'DELETE FROM event_alias_availability WHERE event_id=$1 AND alias_id=$2', [req.params.id, aliasId]);
|
||||
} else {
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
|
||||
const { responses } = req.body;
|
||||
if (!Array.isArray(responses)) return res.status(400).json({ error: 'responses array required' });
|
||||
try {
|
||||
let saved = 0;
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const bulkPartnerId = await getPartnerId(req.schema, req.user.id);
|
||||
for (const { eventId, response } of responses) {
|
||||
if (!['going','maybe','not_going'].includes(response)) continue;
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
if (!event || !event.track_availability) continue;
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
|
||||
`, [eventId, req.user.id, bulkPartnerId || -1]);
|
||||
if (!inGroup && !itm) continue;
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
|
||||
`, [eventId, req.user.id, response]);
|
||||
saved++;
|
||||
}
|
||||
res.json({ success: true, saved });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── CSV Import ────────────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
try {
|
||||
const rows = csvParse(req.file.buffer.toString('utf8'), { columns:true, skip_empty_lines:true, trim:true });
|
||||
const results = await Promise.all(rows.map(async (row, i) => {
|
||||
const title = row['Event Title'] || row['event_title'] || row['title'] || '';
|
||||
const startDate = row['start_date'] || row['Start Date'] || '';
|
||||
const startTime = row['start_time'] || row['Start Time'] || '09:00';
|
||||
const location = row['event_location'] || row['location'] || '';
|
||||
const typeName = row['event_type'] || row['Event Type'] || 'Default';
|
||||
const durHrs = parseFloat(row['default_duration'] || row['duration'] || '1') || 1;
|
||||
if (!title || !startDate) return { row:i+1, title, error:'Missing title or start date', duplicate:false };
|
||||
const startAt = `${startDate}T${startTime.padStart(5,'0')}:00`;
|
||||
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
|
||||
const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19);
|
||||
const dup = await queryOne(req.schema, 'SELECT id,title FROM events WHERE title=$1 AND start_at=$2', [title, startAt]);
|
||||
return { row:i+1, title, startAt, endAt, location, typeName, durHrs, duplicate:!!dup, duplicateId:dup?.id, error:null };
|
||||
}));
|
||||
res.json({ rows: results });
|
||||
} catch (e) { res.status(400).json({ error: 'CSV parse error: ' + e.message }); }
|
||||
});
|
||||
|
||||
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { rows } = req.body;
|
||||
if (!Array.isArray(rows)) return res.status(400).json({ error: 'rows array required' });
|
||||
try {
|
||||
let imported = 0;
|
||||
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
|
||||
for (const row of rows) {
|
||||
if (row.error || row.skip) continue;
|
||||
let typeId = null;
|
||||
if (row.typeName) {
|
||||
let et = await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [row.typeName]);
|
||||
if (!et) {
|
||||
const usedColours = (await query(req.schema, 'SELECT colour FROM event_types')).map(r => r.colour);
|
||||
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
|
||||
const cr = await queryResult(req.schema, 'INSERT INTO event_types (name,colour) VALUES ($1,$2) RETURNING id', [row.typeName, colour]);
|
||||
typeId = cr.rows[0].id;
|
||||
} else { typeId = et.id; }
|
||||
}
|
||||
await exec(req.schema,
|
||||
'INSERT INTO events (title,event_type_id,start_at,end_at,location,is_public,track_availability,created_by) VALUES ($1,$2,$3,$4,$5,TRUE,FALSE,$6)',
|
||||
[row.title, typeId, row.startAt, row.endAt, row.location||null, req.user.id]
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
res.json({ success: true, imported });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
}; // end module.exports
|
||||
@@ -1,125 +1,190 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
const router = express.Router();
|
||||
const { query, queryOne, exec } = require('../models/db');
|
||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Generic icon storage factory
|
||||
function makeIconStorage(prefix) {
|
||||
return multer.diskStorage({
|
||||
destination: '/app/uploads/logos',
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${prefix}_${Date.now()}${ext}`);
|
||||
}
|
||||
filename: (req, file, cb) => cb(null, `${prefix}_${Date.now()}${path.extname(file.originalname)}`),
|
||||
});
|
||||
}
|
||||
|
||||
const iconUploadOpts = {
|
||||
const iconOpts = {
|
||||
limits: { fileSize: 1 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||
else cb(new Error('Images only'));
|
||||
}
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
};
|
||||
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconOpts });
|
||||
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconOpts });
|
||||
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconOpts });
|
||||
|
||||
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconUploadOpts });
|
||||
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconUploadOpts });
|
||||
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconUploadOpts });
|
||||
// Helper: upsert a setting
|
||||
async function setSetting(schema, key, value) {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT(key) DO UPDATE SET value=$2, updated_at=NOW()",
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
|
||||
// Get public settings (accessible by all)
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
const settings = db.prepare('SELECT key, value FROM settings').all();
|
||||
const obj = {};
|
||||
for (const s of settings) obj[s.key] = s.value;
|
||||
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
|
||||
if (admin) obj.admin_email = admin.email;
|
||||
// Expose app version from Docker build arg env var
|
||||
obj.app_version = process.env.TEAMCHAT_VERSION || 'dev';
|
||||
res.json({ settings: obj });
|
||||
// GET /api/settings
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await query(req.schema, 'SELECT key, value FROM settings');
|
||||
const obj = {};
|
||||
for (const r of rows) obj[r.key] = r.value;
|
||||
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
|
||||
if (admin) obj.admin_email = admin.email;
|
||||
obj.app_version = process.env.ROSTERCHIRP_VERSION || 'dev';
|
||||
obj.user_pass = process.env.USER_PASS || 'user@1234';
|
||||
// Tell the frontend whether this request came from the host control panel subdomain.
|
||||
// Used to show/hide the Control Panel menu item — only visible on the host's own subdomain.
|
||||
const reqHost = (req.headers.host || '').toLowerCase().split(':')[0];
|
||||
const appDomain = (process.env.APP_DOMAIN || '').toLowerCase();
|
||||
const hostSlug = (process.env.HOST_SLUG || 'host').toLowerCase();
|
||||
const hostControlDomain = appDomain ? `${hostSlug}.${appDomain}` : '';
|
||||
obj.is_host_domain = (
|
||||
process.env.APP_TYPE === 'host' &&
|
||||
!!hostControlDomain &&
|
||||
(reqHost === hostControlDomain || reqHost === 'localhost')
|
||||
) ? 'true' : 'false';
|
||||
res.json({ settings: obj });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update app name (admin)
|
||||
router.patch('/app-name', authMiddleware, adminMiddleware, (req, res) => {
|
||||
router.patch('/app-name', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(name.trim());
|
||||
res.json({ success: true, name: name.trim() });
|
||||
try {
|
||||
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [name.trim()]);
|
||||
res.json({ success: true, name: name.trim() });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Upload app logo (admin) — also generates 192x192 and 512x512 PWA icons
|
||||
router.post('/logo', authMiddleware, adminMiddleware, uploadLogo.single('logo'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
|
||||
const logoUrl = `/uploads/logos/${req.file.filename}`;
|
||||
const srcPath = req.file.path;
|
||||
|
||||
try {
|
||||
// Generate PWA icons from the uploaded logo
|
||||
const icon192Path = '/app/uploads/logos/pwa-icon-192.png';
|
||||
const icon512Path = '/app/uploads/logos/pwa-icon-512.png';
|
||||
|
||||
await sharp(srcPath)
|
||||
.resize(192, 192, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
|
||||
.png()
|
||||
.toFile(icon192Path);
|
||||
|
||||
await sharp(srcPath)
|
||||
.resize(512, 512, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
|
||||
.png()
|
||||
.toFile(icon512Path);
|
||||
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
|
||||
// Store the PWA icon paths so the manifest can reference them
|
||||
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_192', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
||||
.run('/uploads/logos/pwa-icon-192.png', '/uploads/logos/pwa-icon-192.png');
|
||||
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_512', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
||||
.run('/uploads/logos/pwa-icon-512.png', '/uploads/logos/pwa-icon-512.png');
|
||||
|
||||
await sharp(req.file.path).resize(192,192,{fit:'contain',background:{r:255,g:255,b:255,alpha:0}}).png().toFile('/app/uploads/logos/pwa-icon-192.png');
|
||||
await sharp(req.file.path).resize(512,512,{fit:'contain',background:{r:255,g:255,b:255,alpha:0}}).png().toFile('/app/uploads/logos/pwa-icon-512.png');
|
||||
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='logo_url'", [logoUrl]);
|
||||
await setSetting(req.schema, 'pwa_icon_192', '/uploads/logos/pwa-icon-192.png');
|
||||
await setSetting(req.schema, 'pwa_icon_512', '/uploads/logos/pwa-icon-512.png');
|
||||
res.json({ logoUrl });
|
||||
} catch (err) {
|
||||
console.error('[Logo] Failed to generate PWA icons:', err.message);
|
||||
// Still save the logo even if icon generation fails
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
|
||||
console.error('[Logo] icon gen failed:', err.message);
|
||||
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='logo_url'", [logoUrl]);
|
||||
res.json({ logoUrl });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload New Chat icon (admin)
|
||||
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), (req, res) => {
|
||||
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
const iconUrl = `/uploads/logos/${req.file.filename}`;
|
||||
const db = getDb();
|
||||
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_newchat', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
||||
.run(iconUrl, iconUrl);
|
||||
res.json({ iconUrl });
|
||||
try { await setSetting(req.schema, 'icon_newchat', iconUrl); res.json({ iconUrl }); }
|
||||
catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Upload Group Info icon (admin)
|
||||
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), (req, res) => {
|
||||
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
const iconUrl = `/uploads/logos/${req.file.filename}`;
|
||||
const db = getDb();
|
||||
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_groupinfo', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
|
||||
.run(iconUrl, iconUrl);
|
||||
res.json({ iconUrl });
|
||||
try { await setSetting(req.schema, 'icon_groupinfo', iconUrl); res.json({ iconUrl }); }
|
||||
catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Reset all settings to defaults (admin)
|
||||
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const originalName = process.env.APP_NAME || 'TeamChat';
|
||||
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(originalName);
|
||||
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key = 'logo_url'").run();
|
||||
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key IN ('icon_newchat', 'icon_groupinfo', 'pwa_icon_192', 'pwa_icon_512')").run();
|
||||
res.json({ success: true });
|
||||
router.patch('/colors', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { colorTitle, colorTitleDark, colorAvatarPublic, colorAvatarDm } = req.body;
|
||||
try {
|
||||
if (colorTitle !== undefined) await setSetting(req.schema, 'color_title', colorTitle || '');
|
||||
if (colorTitleDark !== undefined) await setSetting(req.schema, 'color_title_dark', colorTitleDark || '');
|
||||
if (colorAvatarPublic !== undefined) await setSetting(req.schema, 'color_avatar_public', colorAvatarPublic || '');
|
||||
if (colorAvatarDm !== undefined) await setSetting(req.schema, 'color_avatar_dm', colorAvatarDm || '');
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
try {
|
||||
const originalName = process.env.APP_NAME || 'rosterchirp';
|
||||
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]);
|
||||
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'");
|
||||
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key IN ('icon_newchat','icon_groupinfo','pwa_icon_192','pwa_icon_512','color_title','color_title_dark','color_avatar_public','color_avatar_dm')");
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
const VALID_CODES = {
|
||||
'ROSTERCHIRP-TEAM-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
|
||||
'ROSTERCHIRP-BRAND-2024': { appType:'RosterChirp-Brand', branding:true, groupManager:false, scheduleManager:false },
|
||||
'ROSTERCHIRP-FULL-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
|
||||
};
|
||||
|
||||
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { code } = req.body;
|
||||
try {
|
||||
if (!code?.trim()) {
|
||||
await setSetting(req.schema, 'registration_code', '');
|
||||
await setSetting(req.schema, 'app_type', 'RosterChirp-Chat');
|
||||
await setSetting(req.schema, 'feature_branding', 'false');
|
||||
await setSetting(req.schema, 'feature_group_manager', 'false');
|
||||
await setSetting(req.schema, 'feature_schedule_manager', 'false');
|
||||
return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'RosterChirp-Chat'} });
|
||||
}
|
||||
const match = VALID_CODES[code.trim().toUpperCase()];
|
||||
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
|
||||
await setSetting(req.schema, 'registration_code', code.trim());
|
||||
await setSetting(req.schema, 'app_type', match.appType);
|
||||
await setSetting(req.schema, 'feature_branding', match.branding ? 'true' : 'false');
|
||||
await setSetting(req.schema, 'feature_group_manager', match.groupManager ? 'true' : 'false');
|
||||
await setSetting(req.schema, 'feature_schedule_manager', match.scheduleManager ? 'true' : 'false');
|
||||
res.json({ success:true, features:{ branding:match.branding, groupManager:match.groupManager, scheduleManager:match.scheduleManager, appType:match.appType } });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/messages', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { msgPublic, msgGroup, msgPrivateGroup, msgU2U } = req.body;
|
||||
try {
|
||||
if (msgPublic !== undefined) await setSetting(req.schema, 'feature_msg_public', msgPublic ? 'true' : 'false');
|
||||
if (msgGroup !== undefined) await setSetting(req.schema, 'feature_msg_group', msgGroup ? 'true' : 'false');
|
||||
if (msgPrivateGroup !== undefined) await setSetting(req.schema, 'feature_msg_private_group', msgPrivateGroup ? 'true' : 'false');
|
||||
if (msgU2U !== undefined) await setSetting(req.schema, 'feature_msg_u2u', msgU2U ? 'true' : 'false');
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
const VALID_LOGIN_TYPES = ['all_ages', 'guardian_only', 'mixed_age'];
|
||||
|
||||
router.patch('/login-type', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { loginType, playersGroupId, guardiansGroupId } = req.body;
|
||||
if (!VALID_LOGIN_TYPES.includes(loginType)) return res.status(400).json({ error: 'Invalid login type' });
|
||||
try {
|
||||
// Enforce: can only change when no non-admin users exist, UNLESS staying on same value
|
||||
const existing = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||
const current = existing?.value || 'all_ages';
|
||||
if (loginType !== current) {
|
||||
const { count } = await queryOne(req.schema, "SELECT COUNT(*)::int AS count FROM users WHERE role != 'admin' AND status != 'deleted'");
|
||||
if (count > 0) return res.status(400).json({ error: 'Login Type can only be changed when no non-admin users exist.' });
|
||||
}
|
||||
await setSetting(req.schema, 'feature_login_type', loginType);
|
||||
await setSetting(req.schema, 'feature_players_group_id', playersGroupId != null ? String(playersGroupId) : '');
|
||||
await setSetting(req.schema, 'feature_guardians_group_id', guardiansGroupId != null ? String(guardiansGroupId) : '');
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { toolManagers } = req.body;
|
||||
try {
|
||||
if (toolManagers !== undefined) {
|
||||
const val = JSON.stringify(toolManagers || []);
|
||||
await setSetting(req.schema, 'team_tool_managers', val);
|
||||
await setSetting(req.schema, 'team_group_managers', val);
|
||||
await setSetting(req.schema, 'team_schedule_managers', val);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
538
backend/src/routes/usergroups.js
Normal file
@@ -0,0 +1,538 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||
|
||||
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||||
|
||||
module.exports = function(io) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function postSysMsg(schema, groupId, actorId, content) {
|
||||
const r = await queryResult(schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[groupId, 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',groupId)).emit('message:new', msg); }
|
||||
}
|
||||
|
||||
async function addUserSilent(schema, dmGroupId, userId) {
|
||||
await exec(schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [dmGroupId, userId]);
|
||||
io.in(R(schema,'user',userId)).socketsJoin(R(schema,'group',dmGroupId));
|
||||
const dmGroup = await queryOne(schema, 'SELECT * FROM groups WHERE id=$1', [dmGroupId]);
|
||||
if (dmGroup) io.to(R(schema,'user',userId)).emit('group:new', { group: dmGroup });
|
||||
}
|
||||
|
||||
async function addUser(schema, dmGroupId, userId, actorId) {
|
||||
await addUserSilent(schema, dmGroupId, userId);
|
||||
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
|
||||
}
|
||||
|
||||
async function removeUser(schema, dmGroupId, userId, actorId) {
|
||||
await exec(schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [dmGroupId, userId]);
|
||||
io.in(R(schema,'user',userId)).socketsLeave(R(schema,'group',dmGroupId));
|
||||
io.to(R(schema,'user',userId)).emit('group:deleted', { groupId: dmGroupId });
|
||||
const u = await queryOne(schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(schema, dmGroupId, actorId, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
|
||||
}
|
||||
|
||||
async function getUserIdsForGroup(schema, userGroupId) {
|
||||
const rows = await query(schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [userGroupId]);
|
||||
return rows.map(r => r.user_id);
|
||||
}
|
||||
|
||||
// GET /me — current user's user-group memberships
|
||||
router.get('/me', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const rows = await query(req.schema, 'SELECT user_group_id FROM user_group_members WHERE user_id=$1', [req.user.id]);
|
||||
const groupIds = rows.map(r => r.user_group_id);
|
||||
if (groupIds.length === 0) return res.json({ userGroups: [] });
|
||||
const placeholders = groupIds.map((_,i) => `$${i+1}`).join(',');
|
||||
const userGroups = await query(req.schema, `SELECT * FROM user_groups WHERE id IN (${placeholders}) ORDER BY name ASC`, groupIds);
|
||||
// Also resolve multi-group DMs this user can see
|
||||
const mgDms = await query(req.schema, `
|
||||
SELECT mgd.*, (SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id=mgd.id) AS group_count
|
||||
FROM multi_group_dms mgd
|
||||
JOIN multi_group_dm_members mgdm ON mgdm.multi_group_dm_id=mgd.id
|
||||
WHERE mgdm.user_group_id IN (${placeholders})
|
||||
GROUP BY mgd.id ORDER BY mgd.name ASC
|
||||
`, groupIds);
|
||||
for (const dm of mgDms) {
|
||||
dm.memberGroupIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [dm.id])).map(r => r.user_group_id);
|
||||
}
|
||||
res.json({ userGroups, multiGroupDms: mgDms });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// GET /multigroup
|
||||
router.get('/multigroup', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const dms = await query(req.schema, `
|
||||
SELECT mgd.*, (SELECT COUNT(*) FROM multi_group_dm_members WHERE multi_group_dm_id=mgd.id) AS group_count
|
||||
FROM multi_group_dms mgd ORDER BY mgd.name ASC
|
||||
`);
|
||||
for (const dm of dms) {
|
||||
dm.memberGroupIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [dm.id])).map(r => r.user_group_id);
|
||||
}
|
||||
res.json({ dms });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST /multigroup
|
||||
router.post('/multigroup', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, userGroupIds } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
if (!Array.isArray(userGroupIds) || userGroupIds.length < 2) return res.status(400).json({ error: 'At least 2 groups required' });
|
||||
try {
|
||||
// Check for existing DM with same groups
|
||||
const existing = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE LOWER(name)=LOWER($1)', [name.trim()]);
|
||||
if (existing) {
|
||||
const existingIds = (await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [existing.id])).map(r => r.user_group_id).sort();
|
||||
const newIds = [...userGroupIds].map(Number).sort();
|
||||
if (JSON.stringify(existingIds) === JSON.stringify(newIds)) return res.status(400).json({ error: 'A DM with these groups already exists' });
|
||||
}
|
||||
// Create the chat group
|
||||
const gr = await queryResult(req.schema,
|
||||
"INSERT INTO groups (name,type,is_readonly,is_managed,is_multi_group) VALUES ($1,'private',FALSE,TRUE,TRUE) RETURNING id",
|
||||
[name.trim()]
|
||||
);
|
||||
const dmGroupId = gr.rows[0].id;
|
||||
// Create multi_group_dms record
|
||||
const mgr = await queryResult(req.schema,
|
||||
'INSERT INTO multi_group_dms (name,dm_group_id) VALUES ($1,$2) RETURNING id',
|
||||
[name.trim(), dmGroupId]
|
||||
);
|
||||
const mgId = mgr.rows[0].id;
|
||||
// Add each user group and their members
|
||||
const addedUsers = new Set();
|
||||
for (const ugId of userGroupIds) {
|
||||
await exec(req.schema, 'INSERT INTO multi_group_dm_members (multi_group_dm_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [mgId, ugId]);
|
||||
const uids = await getUserIdsForGroup(req.schema, ugId);
|
||||
for (const uid of uids) {
|
||||
if (!addedUsers.has(uid)) {
|
||||
addedUsers.add(uid);
|
||||
await addUserSilent(req.schema, dmGroupId, uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
const dmGroup = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [dmGroupId]);
|
||||
res.json({ dm: { id: mgId, name: name.trim(), dm_group_id: dmGroupId, group_count: userGroupIds.length }, group: dmGroup });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// PATCH /multigroup/:id
|
||||
router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { userGroupIds } = req.body;
|
||||
try {
|
||||
const mg = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE id=$1', [req.params.id]);
|
||||
if (!mg) return res.status(404).json({ error: 'Not found' });
|
||||
if (!Array.isArray(userGroupIds)) return res.status(400).json({ error: 'userGroupIds required' });
|
||||
const currentGroupIds = new Set((await query(req.schema, 'SELECT user_group_id FROM multi_group_dm_members WHERE multi_group_dm_id=$1', [mg.id])).map(r => r.user_group_id));
|
||||
const newGroupSet = new Set(userGroupIds.map(Number));
|
||||
for (const ugId of newGroupSet) {
|
||||
if (!currentGroupIds.has(ugId)) {
|
||||
await exec(req.schema, 'INSERT INTO multi_group_dm_members (multi_group_dm_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [mg.id, ugId]);
|
||||
const uids = await getUserIdsForGroup(req.schema, ugId);
|
||||
for (const uid of uids) await addUserSilent(req.schema, mg.dm_group_id, uid);
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A new group has joined this conversation.`);
|
||||
}
|
||||
}
|
||||
for (const ugId of currentGroupIds) {
|
||||
if (!newGroupSet.has(ugId)) {
|
||||
await exec(req.schema, 'DELETE FROM multi_group_dm_members WHERE multi_group_dm_id=$1 AND user_group_id=$2', [mg.id, ugId]);
|
||||
const uids = await getUserIdsForGroup(req.schema, ugId);
|
||||
for (const uid of uids) {
|
||||
const stillIn = await queryOne(req.schema, `
|
||||
SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id=mgdm.user_group_id
|
||||
WHERE mgdm.multi_group_dm_id=$1 AND ugm.user_id=$2
|
||||
`, [mg.id, uid]);
|
||||
if (!stillIn) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
|
||||
io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',mg.dm_group_id));
|
||||
io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
}
|
||||
}
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A group has been removed from this conversation.`);
|
||||
}
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// DELETE /multigroup/:id
|
||||
router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const mg = await queryOne(req.schema, 'SELECT * FROM multi_group_dms WHERE id=$1', [req.params.id]);
|
||||
if (!mg) return res.status(404).json({ error: 'Not found' });
|
||||
if (mg.dm_group_id) {
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [mg.dm_group_id])).map(r => r.user_id);
|
||||
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [mg.dm_group_id]);
|
||||
for (const uid of members) io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
}
|
||||
await exec(req.schema, 'DELETE FROM multi_group_dms WHERE id=$1', [mg.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// GET / — list all user groups
|
||||
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const groups = await query(req.schema, `
|
||||
SELECT ug.*, (SELECT COUNT(*) FROM user_group_members WHERE user_group_id=ug.id) AS member_count
|
||||
FROM user_groups ug ORDER BY ug.name ASC
|
||||
`);
|
||||
res.json({ groups });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// GET /byuser/:userId — user group IDs for a specific user
|
||||
router.get('/byuser/:userId', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const rows = await query(req.schema, 'SELECT user_group_id FROM user_group_members WHERE user_id=$1', [req.params.userId]);
|
||||
res.json({ groupIds: rows.map(r => r.user_group_id) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// GET /:id
|
||||
router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!group) return res.status(404).json({ error: 'Not found' });
|
||||
const members = await query(req.schema, `
|
||||
SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status
|
||||
FROM user_group_members ugm JOIN users u ON u.id=ugm.user_id
|
||||
WHERE ugm.user_group_id=$1 ORDER BY u.name ASC
|
||||
`, [req.params.id]);
|
||||
const aliasMembers = await query(req.schema, `
|
||||
SELECT ga.id, ga.first_name, ga.last_name,
|
||||
ga.first_name || ' ' || ga.last_name AS name,
|
||||
ga.guardian_id, ga.avatar, ga.date_of_birth
|
||||
FROM alias_group_members agm
|
||||
JOIN guardian_aliases ga ON ga.id = agm.alias_id
|
||||
WHERE agm.user_group_id=$1
|
||||
ORDER BY ga.first_name, ga.last_name ASC
|
||||
`, [req.params.id]);
|
||||
res.json({ group, members, aliasMembers });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST / — create user group
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, memberIds = [], noDm = false } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1)', [name.trim()]);
|
||||
if (existing) return res.status(400).json({ error: 'Name already in use' });
|
||||
|
||||
let dmGroupId = null;
|
||||
if (!noDm) {
|
||||
const gr = await queryResult(req.schema,
|
||||
"INSERT INTO groups (name,type,is_readonly,is_managed) VALUES ($1,'private',FALSE,TRUE) RETURNING id",
|
||||
[name.trim()]
|
||||
);
|
||||
dmGroupId = gr.rows[0].id;
|
||||
}
|
||||
|
||||
const ugr = await queryResult(req.schema,
|
||||
'INSERT INTO user_groups (name,dm_group_id) VALUES ($1,$2) RETURNING id',
|
||||
[name.trim(), dmGroupId]
|
||||
);
|
||||
const ugId = ugr.rows[0].id;
|
||||
const defaultAdmin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin=TRUE');
|
||||
for (const uid of memberIds) {
|
||||
if (defaultAdmin && uid === defaultAdmin.id) continue;
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ugId, uid]);
|
||||
if (dmGroupId) await addUserSilent(req.schema, dmGroupId, uid);
|
||||
}
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [ugId]);
|
||||
res.json({ userGroup: ug });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// PATCH /:id
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, memberIds, createDm = false, aliasMemberIds } = req.body;
|
||||
try {
|
||||
let ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
if (name && name.trim() !== ug.name) {
|
||||
const conflict = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1) AND id!=$2', [name.trim(), ug.id]);
|
||||
if (conflict) return res.status(400).json({ error: 'Name already in use' });
|
||||
await exec(req.schema, 'UPDATE user_groups SET name=$1, updated_at=NOW() WHERE id=$2', [name.trim(), ug.id]);
|
||||
if (ug.dm_group_id) await exec(req.schema, 'UPDATE groups SET name=$1, updated_at=NOW() WHERE id=$2', [name.trim(), ug.dm_group_id]);
|
||||
}
|
||||
|
||||
// Create DM group if requested and one doesn't exist yet
|
||||
if (createDm && !ug.dm_group_id) {
|
||||
const groupName = (name?.trim()) || ug.name;
|
||||
const gr = await queryResult(req.schema,
|
||||
"INSERT INTO groups (name,type,is_readonly,is_managed) VALUES ($1,'private',FALSE,TRUE) RETURNING id",
|
||||
[groupName]
|
||||
);
|
||||
const newDmId = gr.rows[0].id;
|
||||
await exec(req.schema, 'UPDATE user_groups SET dm_group_id=$1, updated_at=NOW() WHERE id=$2', [newDmId, ug.id]);
|
||||
ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [ug.id]);
|
||||
// Add all current members to the new DM silently (no per-user join messages for a bulk creation)
|
||||
const currentMembers = await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [ug.id]);
|
||||
for (const { user_id } of currentMembers) {
|
||||
await addUserSilent(req.schema, newDmId, user_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(memberIds)) {
|
||||
const defaultAdmin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin=TRUE');
|
||||
const newIds = new Set(memberIds.map(Number).filter(Boolean));
|
||||
if (defaultAdmin) newIds.delete(defaultAdmin.id); // default admin cannot be in user groups
|
||||
const currentSet = new Set((await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [ug.id])).map(r => r.user_id));
|
||||
const addedUids = [], removedUids = [];
|
||||
|
||||
for (const uid of newIds) {
|
||||
if (!currentSet.has(uid)) {
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, uid]);
|
||||
if (ug.dm_group_id) await addUserSilent(req.schema, ug.dm_group_id, uid);
|
||||
addedUids.push(uid);
|
||||
}
|
||||
}
|
||||
for (const uid of currentSet) {
|
||||
if (!newIds.has(uid)) {
|
||||
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, uid]);
|
||||
if (ug.dm_group_id) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, uid]);
|
||||
io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',ug.dm_group_id));
|
||||
io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id });
|
||||
}
|
||||
io.to(R(req.schema,'user',uid)).emit('schedule:refresh');
|
||||
removedUids.push(uid);
|
||||
}
|
||||
}
|
||||
|
||||
// Notification rule (only if DM exists): single user → named message; multiple → generic
|
||||
if (ug.dm_group_id) {
|
||||
if (addedUids.length === 1) {
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [addedUids[0]]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
|
||||
} else if (addedUids.length > 1) {
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${addedUids.length} new members have joined the conversation.`);
|
||||
}
|
||||
if (removedUids.length === 1) {
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [removedUids[0]]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
|
||||
} else if (removedUids.length > 1) {
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${removedUids.length} members have been removed from the conversation.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate to multi-group DMs
|
||||
const mgDms = await query(req.schema, `
|
||||
SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm
|
||||
JOIN multi_group_dms mgd ON mgd.id=mgdm.multi_group_dm_id WHERE mgdm.user_group_id=$1
|
||||
`, [ug.id]);
|
||||
for (const mg of mgDms) {
|
||||
if (!mg.dm_group_id) continue;
|
||||
for (const uid of addedUids) await addUserSilent(req.schema, mg.dm_group_id, uid);
|
||||
for (const uid of removedUids) {
|
||||
const stillIn = await queryOne(req.schema, `
|
||||
SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id=mgdm.user_group_id
|
||||
WHERE mgdm.multi_group_dm_id=$1 AND ugm.user_id=$2
|
||||
`, [mg.id, uid]);
|
||||
if (!stillIn) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
|
||||
io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',mg.dm_group_id));
|
||||
io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
}
|
||||
}
|
||||
if (addedUids.length === 1) {
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [addedUids[0]]);
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined this conversation.`);
|
||||
} else if (addedUids.length > 1) {
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${addedUids.length} new members have joined this conversation.`);
|
||||
}
|
||||
if (removedUids.length === 1) {
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [removedUids[0]]);
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from this conversation.`);
|
||||
} else if (removedUids.length > 1) {
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${removedUids.length} members have been removed from this conversation.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alias member management (Guardian Only mode — players group)
|
||||
if (Array.isArray(aliasMemberIds)) {
|
||||
const newAliasIds = new Set(aliasMemberIds.map(Number).filter(Boolean));
|
||||
const currentAliasSet = new Set(
|
||||
(await query(req.schema, 'SELECT alias_id FROM alias_group_members WHERE user_group_id=$1', [ug.id])).map(r => r.alias_id)
|
||||
);
|
||||
for (const aid of newAliasIds) {
|
||||
if (!currentAliasSet.has(aid)) {
|
||||
await exec(req.schema, 'INSERT INTO alias_group_members (user_group_id,alias_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, aid]);
|
||||
}
|
||||
}
|
||||
for (const aid of currentAliasSet) {
|
||||
if (!newAliasIds.has(aid)) {
|
||||
await exec(req.schema, 'DELETE FROM alias_group_members WHERE user_group_id=$1 AND alias_id=$2', [ug.id, aid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
res.json({ group: updated });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// DELETE /:id
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
if (ug.dm_group_id) {
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [ug.dm_group_id])).map(r => r.user_id);
|
||||
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [ug.dm_group_id]);
|
||||
for (const uid of members) { io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',ug.dm_group_id)); io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id }); }
|
||||
}
|
||||
await exec(req.schema, 'DELETE FROM user_groups WHERE id=$1', [ug.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
|
||||
// POST /:id/members/:userId — add a single user to a group (with DM + notifications)
|
||||
router.post('/:id/members/:userId', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
const userId = parseInt(req.params.userId);
|
||||
const defaultAdmin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin=TRUE');
|
||||
if (defaultAdmin && userId === defaultAdmin.id) return res.status(400).json({ error: 'Cannot add default admin to user groups' });
|
||||
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, userId]);
|
||||
|
||||
if (ug.dm_group_id) {
|
||||
await addUserSilent(req.schema, ug.dm_group_id, userId);
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
|
||||
}
|
||||
|
||||
// Propagate to multi-group DMs
|
||||
const mgDms = await query(req.schema, `
|
||||
SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm
|
||||
JOIN multi_group_dms mgd ON mgd.id=mgdm.multi_group_dm_id WHERE mgdm.user_group_id=$1
|
||||
`, [ug.id]);
|
||||
for (const mg of mgDms) {
|
||||
if (!mg.dm_group_id) continue;
|
||||
await addUserSilent(req.schema, mg.dm_group_id, userId);
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined this conversation.`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// DELETE /:id/members/:userId — remove a single user from a group (with DM + notifications)
|
||||
router.delete('/:id/members/:userId', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, userId]);
|
||||
|
||||
io.to(R(req.schema,'user',userId)).emit('schedule:refresh');
|
||||
|
||||
if (ug.dm_group_id) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, userId]);
|
||||
io.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',ug.dm_group_id));
|
||||
io.to(R(req.schema,'user',userId)).emit('group:deleted', { groupId: ug.dm_group_id });
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
|
||||
}
|
||||
|
||||
// Propagate to multi-group DMs
|
||||
const mgDms = await query(req.schema, `
|
||||
SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm
|
||||
JOIN multi_group_dms mgd ON mgd.id=mgdm.multi_group_dm_id WHERE mgdm.user_group_id=$1
|
||||
`, [ug.id]);
|
||||
for (const mg of mgDms) {
|
||||
if (!mg.dm_group_id) continue;
|
||||
const stillIn = await queryOne(req.schema, `
|
||||
SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id=mgdm.user_group_id
|
||||
WHERE mgdm.multi_group_dm_id=$1 AND ugm.user_id=$2
|
||||
`, [mg.id, userId]);
|
||||
if (!stillIn) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, userId]);
|
||||
io.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',mg.dm_group_id));
|
||||
io.to(R(req.schema,'user',userId)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from this conversation.`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── U2U DM Restrictions ───────────────────────────────────────────────────────
|
||||
|
||||
// GET /:id/restrictions — get blocked group IDs for a user group
|
||||
router.get('/:id/restrictions', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const rows = await query(req.schema,
|
||||
'SELECT blocked_group_id FROM user_group_dm_restrictions WHERE restricting_group_id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
res.json({ blockedGroupIds: rows.map(r => r.blocked_group_id) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// PUT /:id/restrictions — replace the full restriction list for a user group
|
||||
// Body: { blockedGroupIds: [id, id, ...] }
|
||||
router.put('/:id/restrictions', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { blockedGroupIds = [] } = req.body;
|
||||
const restrictingId = parseInt(req.params.id);
|
||||
try {
|
||||
const ug = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE id = $1', [restrictingId]);
|
||||
if (!ug) return res.status(404).json({ error: 'User group not found' });
|
||||
|
||||
// Clear all existing restrictions for this group then insert new ones
|
||||
await exec(req.schema,
|
||||
'DELETE FROM user_group_dm_restrictions WHERE restricting_group_id = $1',
|
||||
[restrictingId]
|
||||
);
|
||||
for (const blockedId of blockedGroupIds) {
|
||||
if (parseInt(blockedId) === restrictingId) continue; // cannot restrict own group
|
||||
await exec(req.schema,
|
||||
'INSERT INTO user_group_dm_restrictions (restricting_group_id, blocked_group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[restrictingId, parseInt(blockedId)]
|
||||
);
|
||||
}
|
||||
res.json({ success: true, blockedGroupIds });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
|
||||
// DELETE /api/usergroups/:id/members/:userId — admin force-remove (for deleted/orphaned users)
|
||||
router.delete('/:id/members/:userId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
try {
|
||||
const ugId = parseInt(req.params.id);
|
||||
const userId = parseInt(req.params.userId);
|
||||
const ug = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE id=$1', [ugId]);
|
||||
if (!ug) return res.status(404).json({ error: 'User group not found' });
|
||||
await exec(req.schema,
|
||||
'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2',
|
||||
[ugId, userId]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,177 +1,788 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const router = express.Router();
|
||||
const { getDb, addUserToPublicGroups } = require('../models/db');
|
||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { query, queryOne, queryResult, exec, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
|
||||
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||
|
||||
const avatarStorage = multer.diskStorage({
|
||||
destination: '/app/uploads/avatars',
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`);
|
||||
}
|
||||
filename: (req, file, cb) => cb(null, `avatar_${req.user.id}_${Date.now()}${path.extname(file.originalname)}`),
|
||||
});
|
||||
const uploadAvatar = multer({
|
||||
storage: avatarStorage,
|
||||
const uploadAvatar = multer({
|
||||
storage: avatarStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) cb(null, true);
|
||||
else cb(new Error('Images only'));
|
||||
}
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
});
|
||||
|
||||
// List users (admin)
|
||||
router.get('/', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const users = db.prepare(`
|
||||
SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, created_at
|
||||
FROM users WHERE status != 'deleted'
|
||||
ORDER BY created_at ASC
|
||||
`).all();
|
||||
res.json({ users });
|
||||
// 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')),
|
||||
});
|
||||
|
||||
// Get single user profile (public-ish for mentions)
|
||||
router.get('/search', authMiddleware, (req, res) => {
|
||||
const { q } = req.query;
|
||||
const db = getDb();
|
||||
const users = db.prepare(`
|
||||
SELECT id, name, display_name, avatar, role, status, hide_admin_tag FROM users
|
||||
WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?)
|
||||
LIMIT 10
|
||||
`).all(`%${q}%`, `%${q}%`);
|
||||
res.json({ users });
|
||||
async function resolveUniqueName(schema, baseName, excludeId = null) {
|
||||
const existing = await query(schema,
|
||||
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
|
||||
[excludeId ?? -1, baseName, `${baseName} (%)`]
|
||||
);
|
||||
if (existing.length === 0) return baseName;
|
||||
let max = 0;
|
||||
for (const u of existing) { const m = u.name.match(/\((\d+)\)$/); if (m) max = Math.max(max, parseInt(m[1])); else max = Math.max(max, 0); }
|
||||
return `${baseName} (${max + 1})`;
|
||||
}
|
||||
|
||||
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
||||
|
||||
// Returns true if the given date-of-birth string corresponds to age <= 15
|
||||
function isMinorFromDOB(dob) {
|
||||
if (!dob) return false;
|
||||
const birth = new Date(dob);
|
||||
if (isNaN(birth)) return false;
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||
return age <= 15;
|
||||
}
|
||||
|
||||
async function getLoginType(schema) {
|
||||
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||
return row?.value || 'all_ages';
|
||||
}
|
||||
|
||||
// List users
|
||||
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const users = await query(req.schema,
|
||||
"SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
|
||||
);
|
||||
res.json({ users });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Create user (admin)
|
||||
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const { name, email, password, role } = req.body;
|
||||
if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, password required' });
|
||||
|
||||
const db = getDb();
|
||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (name, email, password, role, status, must_change_password)
|
||||
VALUES (?, ?, ?, ?, 'active', 1)
|
||||
`).run(name, email, hash, role === 'admin' ? 'admin' : 'member');
|
||||
|
||||
addUserToPublicGroups(result.lastInsertRowid);
|
||||
const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
// Bulk create users via CSV data
|
||||
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const { users } = req.body; // array of {name, email, password, role}
|
||||
const db = getDb();
|
||||
const results = { created: [], errors: [] };
|
||||
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (name, email, password, role, status, must_change_password)
|
||||
VALUES (?, ?, ?, ?, 'active', 1)
|
||||
`);
|
||||
|
||||
const transaction = db.transaction((users) => {
|
||||
for (const u of users) {
|
||||
if (!u.name || !u.email || !u.password) {
|
||||
results.errors.push({ email: u.email, error: 'Missing required fields' });
|
||||
continue;
|
||||
// Search users
|
||||
// When q is empty (full-list load by GroupManagerPage / NewChatModal) — return ALL active users,
|
||||
// no LIMIT, so the complete roster is available for member-picker UIs.
|
||||
// When q is non-empty (typed search / mention autocomplete) — keep LIMIT 10 for performance.
|
||||
router.get('/search', authMiddleware, async (req, res) => {
|
||||
const { q, groupId } = req.query;
|
||||
const isTyped = q && q.length > 0;
|
||||
try {
|
||||
let users;
|
||||
if (groupId) {
|
||||
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
|
||||
if (group && (group.type === 'private' || group.is_direct)) {
|
||||
users = await query(req.schema,
|
||||
`SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm,u.is_minor,u.is_default_admin FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||
[parseInt(groupId), req.user.id, `%${q}%`]
|
||||
);
|
||||
} else {
|
||||
users = await query(req.schema,
|
||||
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,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}%`]
|
||||
);
|
||||
}
|
||||
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(u.email);
|
||||
if (exists) {
|
||||
results.errors.push({ email: u.email, error: 'Email already exists' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const hash = bcrypt.hashSync(u.password, 10);
|
||||
const r = insertUser.run(u.name, u.email, hash, u.role === 'admin' ? 'admin' : 'member');
|
||||
addUserToPublicGroups(r.lastInsertRowid);
|
||||
results.created.push(u.email);
|
||||
} catch (e) {
|
||||
results.errors.push({ email: u.email, error: e.message });
|
||||
} else {
|
||||
users = await query(req.schema,
|
||||
`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}%`]
|
||||
);
|
||||
}
|
||||
res.json({ users });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Check display name
|
||||
router.get('/check-display-name', authMiddleware, async (req, res) => {
|
||||
const { name } = req.query;
|
||||
if (!name) return res.json({ taken: false });
|
||||
try {
|
||||
const conflict = await queryOne(req.schema,
|
||||
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
|
||||
[name, req.user.id]
|
||||
);
|
||||
res.json({ taken: !!conflict });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Create user
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { firstName, lastName, email, password, role, phone, dateOfBirth } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim() || !email)
|
||||
return res.status(400).json({ error: 'First name, last name and email required' });
|
||||
if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' });
|
||||
const validRoles = ['member', 'admin', 'manager'];
|
||||
const assignedRole = validRoles.includes(role) ? role : 'member';
|
||||
const name = `${firstName.trim()} ${lastName.trim()}`;
|
||||
try {
|
||||
const loginType = await getLoginType(req.schema);
|
||||
const dob = dateOfBirth || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
// In mixed_age mode, minors start suspended and need guardian approval
|
||||
const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active';
|
||||
|
||||
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND status != 'deleted'", [email.trim()]);
|
||||
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
||||
const resolvedName = await resolveUniqueName(req.schema, name);
|
||||
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
|
||||
const hash = bcrypt.hashSync(pw, 10);
|
||||
const r = await queryResult(req.schema,
|
||||
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,date_of_birth,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE) RETURNING id",
|
||||
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, isMinor, dob, initStatus]
|
||||
);
|
||||
const userId = r.rows[0].id;
|
||||
if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
|
||||
if (assignedRole === 'admin') {
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
||||
}
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,created_at FROM users WHERE id=$1',
|
||||
[userId]
|
||||
);
|
||||
res.json({ user, pendingApproval: initStatus === 'suspended' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update user (general — name components, phone, DOB, is_minor, role, optional password reset)
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
const { firstName, lastName, phone, role, password, dateOfBirth, guardianUserId } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim())
|
||||
return res.status(400).json({ error: 'First and last name required' });
|
||||
const validRoles = ['member', 'admin', 'manager'];
|
||||
if (!validRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
try {
|
||||
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin && role !== 'admin')
|
||||
return res.status(403).json({ error: 'Cannot change default admin role' });
|
||||
|
||||
const dob = dateOfBirth || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
const name = `${firstName.trim()} ${lastName.trim()}`;
|
||||
const resolvedName = await resolveUniqueName(req.schema, name, id);
|
||||
|
||||
// Validate guardian if provided
|
||||
let guardianId = null;
|
||||
if (guardianUserId) {
|
||||
const gUser = await queryOne(req.schema, 'SELECT id,is_minor FROM users WHERE id=$1 AND status=$2', [parseInt(guardianUserId), 'active']);
|
||||
if (!gUser) return res.status(400).json({ error: 'Guardian user not found or inactive' });
|
||||
if (gUser.is_minor) return res.status(400).json({ error: 'A minor cannot be a guardian' });
|
||||
guardianId = gUser.id;
|
||||
}
|
||||
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,date_of_birth=$6,guardian_user_id=$7,role=$8,updated_at=NOW() WHERE id=$9',
|
||||
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, isMinor, dob, guardianId, role, id]
|
||||
);
|
||||
if (password && password.length >= 6) {
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
await exec(req.schema, 'UPDATE users SET password=$1,must_change_password=TRUE,updated_at=NOW() WHERE id=$2', [hash, id]);
|
||||
}
|
||||
if (role === 'admin' && target.role !== 'admin') {
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, id]);
|
||||
}
|
||||
// Auto-unsuspend minor in players group if both guardian and DOB are now set
|
||||
if (isMinor && guardianId && dob && target.status === 'suspended') {
|
||||
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||
const playersGroupId = parseInt(playersRow?.value);
|
||||
if (playersGroupId) {
|
||||
const inPlayers = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [id, playersGroupId]);
|
||||
if (inPlayers) {
|
||||
await exec(req.schema, "UPDATE users SET status='active',updated_at=NOW() WHERE id=$1", [id]);
|
||||
await addUserToPublicGroups(req.schema, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
transaction(users);
|
||||
res.json(results);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
|
||||
[id]
|
||||
);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update user role (admin)
|
||||
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
|
||||
// Bulk create
|
||||
router.post('/bulk', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { users } = req.body;
|
||||
const results = { created: [], skipped: [] };
|
||||
const seenEmails = new Set();
|
||||
const defaultPw = process.env.USER_PASS || 'user@1234';
|
||||
const validRoles = ['member', 'manager', 'admin'];
|
||||
try {
|
||||
for (const u of users) {
|
||||
const email = (u.email || '').trim().toLowerCase();
|
||||
const firstName = (u.firstName || '').trim();
|
||||
const lastName = (u.lastName || '').trim();
|
||||
// Support legacy name field too
|
||||
const name = (firstName && lastName) ? `${firstName} ${lastName}` : (u.name || '').trim();
|
||||
if (!email) { results.skipped.push({ email: '(blank)', reason: 'Email required' }); continue; }
|
||||
if (!isValidEmail(email)){ results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
|
||||
if (!name) { results.skipped.push({ email, reason: 'First and last name required' }); continue; }
|
||||
if (seenEmails.has(email)){ results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
|
||||
seenEmails.add(email);
|
||||
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email=$1 AND status != 'deleted'", [email]);
|
||||
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
|
||||
try {
|
||||
const resolvedName = await resolveUniqueName(req.schema, name);
|
||||
const pw = (u.password || '').trim() || defaultPw;
|
||||
const hash = bcrypt.hashSync(pw, 10);
|
||||
const newRole = validRoles.includes(u.role) ? u.role : 'member';
|
||||
const fn = firstName || name.split(' ')[0] || '';
|
||||
const ln = lastName || name.split(' ').slice(1).join(' ') || '';
|
||||
const dob = (u.dateOfBirth || u.dob || '').trim() || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
const loginType = await getLoginType(req.schema);
|
||||
const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active';
|
||||
const r = await queryResult(req.schema,
|
||||
"INSERT INTO users (name,first_name,last_name,email,password,role,date_of_birth,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE) RETURNING id",
|
||||
[resolvedName, fn, ln, email, hash, newRole, dob, isMinor, initStatus]
|
||||
);
|
||||
const userId = r.rows[0].id;
|
||||
if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
|
||||
if (newRole === 'admin') {
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
||||
}
|
||||
// Add to user group if specified (silent — user was just created, no socket needed)
|
||||
if (u.userGroupId) {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [u.userGroupId]);
|
||||
if (ug) {
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, userId]);
|
||||
if (ug.dm_group_id) {
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.dm_group_id, userId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.created.push(email);
|
||||
} catch (e) { results.skipped.push({ email, reason: e.message }); }
|
||||
}
|
||||
res.json(results);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Patch name
|
||||
router.patch('/:id/name', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
try {
|
||||
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
const resolvedName = await resolveUniqueName(req.schema, name.trim(), req.params.id);
|
||||
await exec(req.schema, 'UPDATE users SET name=$1, updated_at=NOW() WHERE id=$2', [resolvedName, target.id]);
|
||||
res.json({ success: true, name: resolvedName });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Patch role
|
||||
router.patch('/:id/role', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { role } = req.body;
|
||||
const db = getDb();
|
||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
|
||||
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
|
||||
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
|
||||
res.json({ success: true });
|
||||
if (!['member','admin','manager'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
try {
|
||||
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
|
||||
await exec(req.schema, 'UPDATE users SET role=$1, updated_at=NOW() WHERE id=$2', [role, target.id]);
|
||||
if (role === 'admin') {
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, target.id]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Reset user password (admin)
|
||||
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, (req, res) => {
|
||||
// Reset password
|
||||
router.patch('/:id/reset-password', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' });
|
||||
const db = getDb();
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
db.prepare("UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now') WHERE id = ?").run(hash, req.params.id);
|
||||
res.json({ success: true });
|
||||
try {
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
await exec(req.schema, 'UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE id=$2', [hash, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Suspend user (admin)
|
||||
router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
|
||||
|
||||
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
|
||||
res.json({ success: true });
|
||||
// Suspend / activate / delete
|
||||
router.patch('/:id/suspend', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'User not found' });
|
||||
if (t.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
|
||||
await exec(req.schema, "UPDATE users SET status='suspended', updated_at=NOW() WHERE id=$1", [t.id]);
|
||||
// Clear active sessions so suspended user is immediately kicked
|
||||
await exec(req.schema, 'DELETE FROM active_sessions WHERE user_id=$1', [t.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Activate user (admin)
|
||||
router.patch('/:id/activate', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE users SET status = 'active', updated_at = datetime('now') WHERE id = ?").run(req.params.id);
|
||||
res.json({ success: true });
|
||||
router.patch('/:id/activate', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
await exec(req.schema, "UPDATE users SET status='active', updated_at=NOW() WHERE id=$1", [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'User not found' });
|
||||
if (t.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
|
||||
|
||||
// Delete user (admin)
|
||||
router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
|
||||
const db = getDb();
|
||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
|
||||
// ── 1. Anonymise the user record ─────────────────────────────────────────
|
||||
// Scrub the email immediately so the address is free for re-use.
|
||||
// Replace name/display_name/avatar/about_me so no PII is retained.
|
||||
await exec(req.schema, `
|
||||
UPDATE users SET
|
||||
status = 'deleted',
|
||||
email = $1,
|
||||
name = 'Deleted User',
|
||||
first_name = NULL,
|
||||
last_name = NULL,
|
||||
phone = NULL,
|
||||
is_minor = FALSE,
|
||||
display_name = NULL,
|
||||
avatar = NULL,
|
||||
about_me = NULL,
|
||||
password = '',
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`, [`deleted_${t.id}@deleted`, t.id]);
|
||||
|
||||
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
|
||||
res.json({ success: true });
|
||||
// ── 2. Anonymise their messages ───────────────────────────────────────────
|
||||
// Mark all their messages as deleted so they render as "This message was
|
||||
// deleted" in conversation history — no content holes for other members.
|
||||
await exec(req.schema,
|
||||
'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE user_id=$1 AND is_deleted=FALSE',
|
||||
[t.id]
|
||||
);
|
||||
|
||||
// ── 3. Freeze any DMs that only had this user + one other person ──────────
|
||||
// The surviving peer still has their DM visible but it becomes read-only
|
||||
// (frozen) since the other party is gone. Group chats (3+ people) are
|
||||
// left intact — the other members' history and ongoing chat is unaffected.
|
||||
await exec(req.schema, `
|
||||
UPDATE groups SET is_readonly=TRUE, updated_at=NOW()
|
||||
WHERE is_direct=TRUE
|
||||
AND (direct_peer1_id=$1 OR direct_peer2_id=$1)
|
||||
`, [t.id]);
|
||||
|
||||
// ── 4. Remove memberships ────────────────────────────────────────────────
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE user_id=$1', [t.id]);
|
||||
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_id=$1', [t.id]);
|
||||
|
||||
// ── 5. Purge sessions, push subscriptions, notifications ─────────────────
|
||||
await exec(req.schema, 'DELETE FROM active_sessions WHERE user_id=$1', [t.id]);
|
||||
await exec(req.schema, 'DELETE FROM push_subscriptions WHERE user_id=$1', [t.id]);
|
||||
await exec(req.schema, 'DELETE FROM notifications WHERE user_id=$1', [t.id]);
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE user_id=$1', [t.id]);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update own profile
|
||||
router.patch('/me/profile', authMiddleware, (req, res) => {
|
||||
const { displayName, aboutMe, hideAdminTag } = req.body;
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, req.user.id);
|
||||
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag FROM users WHERE id = ?').get(req.user.id);
|
||||
res.json({ user });
|
||||
router.patch('/me/profile', authMiddleware, async (req, res) => {
|
||||
const { displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone } = req.body;
|
||||
try {
|
||||
if (displayName) {
|
||||
const conflict = await queryOne(req.schema,
|
||||
"SELECT id FROM users WHERE LOWER(display_name)=LOWER($1) AND id!=$2 AND status!='deleted'",
|
||||
[displayName, req.user.id]
|
||||
);
|
||||
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
|
||||
}
|
||||
const dob = dateOfBirth || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, date_of_birth=$5, is_minor=$6, phone=$7, updated_at=NOW() WHERE id=$8',
|
||||
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, dob, isMinor, phone?.trim() || null, req.user.id]
|
||||
);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm,date_of_birth,phone FROM users WHERE id=$1',
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Upload avatar
|
||||
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), (req, res) => {
|
||||
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (req, res) => {
|
||||
if (req.user.is_default_admin) return res.status(403).json({ error: 'Default admin avatar cannot be changed' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
const db = getDb();
|
||||
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
|
||||
res.json({ avatarUrl });
|
||||
try {
|
||||
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;
|
||||
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);
|
||||
const fs = require('fs');
|
||||
fs.unlinkSync(filePath);
|
||||
const avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
|
||||
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
|
||||
return res.json({ avatarUrl });
|
||||
}
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]);
|
||||
res.json({ avatarUrl });
|
||||
} catch (err) {
|
||||
console.error('Avatar error:', err);
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
await exec(req.schema, 'UPDATE users SET avatar=$1, updated_at=NOW() WHERE id=$2', [avatarUrl, req.user.id]).catch(()=>{});
|
||||
res.json({ avatarUrl });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
|
||||
@@ -7,7 +7,7 @@ async function getLinkPreview(url) {
|
||||
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'User-Agent': 'TeamChatBot/1.0' }
|
||||
headers: { 'User-Agent': 'RosterChirpBot/1.0' }
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
|
||||
14
build.sh
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# TeamChat — Docker build & release script
|
||||
# rosterchirp — Docker build & release script
|
||||
#
|
||||
# Usage:
|
||||
# ./build.sh # builds teamchat:latest
|
||||
# ./build.sh 1.2.0 # builds teamchat:1.2.0 AND teamchat:latest
|
||||
# ./build.sh # builds rosterchirp:latest
|
||||
# ./build.sh 1.2.0 # builds rosterchirp:1.2.0 AND rosterchirp:latest
|
||||
# ./build.sh 1.2.0 push # builds, tags, and pushes to registry
|
||||
#
|
||||
# To push to a registry, set REGISTRY env var:
|
||||
@@ -13,10 +13,10 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-latest}"
|
||||
VERSION="${1:-0.13.1}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="teamchat"
|
||||
IMAGE_NAME="rosterchirp"
|
||||
|
||||
# If a registry is set, prefix image name
|
||||
if [[ -n "$REGISTRY" ]]; then
|
||||
@@ -26,7 +26,7 @@ else
|
||||
fi
|
||||
|
||||
echo "╔══════════════════════════════════════╗"
|
||||
echo "║ TeamChat Docker Builder ║"
|
||||
echo "║ rosterchirp Docker Builder ║"
|
||||
echo "╠══════════════════════════════════════╣"
|
||||
echo "║ Image : ${FULL_IMAGE}"
|
||||
echo "║ Version : ${VERSION}"
|
||||
@@ -67,7 +67,7 @@ fi
|
||||
echo ""
|
||||
echo "─────────────────────────────────────────"
|
||||
echo "To deploy this version, set in your .env:"
|
||||
echo " TEAMCHAT_VERSION=${VERSION}"
|
||||
echo " ROSTERCHIRP_VERSION=${VERSION}"
|
||||
echo ""
|
||||
echo "Then run:"
|
||||
echo " docker compose up -d"
|
||||
|
||||
110
data/help.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Getting Started with JAMA
|
||||
|
||||
Welcome to **JAMA** — your private, self-hosted team messaging app.
|
||||
|
||||
---
|
||||
|
||||
## Navigating JAMA
|
||||
|
||||
### PRIVACY ASSURED
|
||||
The only people that can read your direct messages (person 2 person or group) are the members of the message group. No one else, including admins, know which message groups exist or which you are part of, unless an they are a member of a given group that you are.
|
||||
|
||||
Every user can, at minimum, read all public messages.
|
||||
|
||||
---
|
||||
|
||||
### Message List (Left Sidebar)
|
||||
The sidebar shows all your message groups and direct conversations. Tap or click any group to open it.
|
||||
|
||||
- **#** prefix indicates a **Public** group — visible to all users
|
||||
- **Lock** icon indicates a **Private** group — invite only
|
||||
- **Bold** group names have unread messages
|
||||
- The last message preview shows **You:** if you sent it
|
||||
|
||||
---
|
||||
|
||||
## Sending Messages
|
||||
|
||||
Type your message in the input box at the bottom and press **Enter** to send.
|
||||
|
||||
- **Shift + Enter** adds a new line without sending
|
||||
- Tap the **+** button to attach a photo or emoji
|
||||
- Use the **camera** icon to take a photo directly (mobile only)
|
||||
|
||||
### Mentioning Someone
|
||||
Type **@** followed by the person's name to mention them. Select from the dropdown that appears. Mentioned users receive a notification.
|
||||
|
||||
Example: `@[John Smith]` will notify John.
|
||||
|
||||
### Replying to a Message
|
||||
Hover over any message and click the **reply arrow** to quote and reply to it.
|
||||
|
||||
### Reacting to a Message
|
||||
Hover over any message and click the **emoji** button to react with an emoji.
|
||||
|
||||
---
|
||||
|
||||
## Direct Messages
|
||||
|
||||
Two ways to start a private conversation with one person:
|
||||
|
||||
1. Click the **New Chat** icon in the sidebar
|
||||
2. Select one user from the list
|
||||
3. Click **Start Conversation**
|
||||
4. Click the users avatar in a message to bring up the profile
|
||||
5. Click **Direct Message**
|
||||
|
||||
---
|
||||
|
||||
## Group Messages
|
||||
|
||||
To create a group conversation:
|
||||
|
||||
1. Click the **new chat** icon
|
||||
2. Select two or more users from the
|
||||
3. Enter a **Message Name**
|
||||
4. Click **Create**
|
||||
|
||||
> If a group with the exact same members already exists, you will be redirected to it automatically to help avoid duplication.
|
||||
|
||||
---
|
||||
|
||||
## Your Profile
|
||||
|
||||
Click your name or avatar at the bottom of the sidebar to:
|
||||
|
||||
- Update your **display name** (displayed in message windows)
|
||||
- Add an **about me** note
|
||||
- Upload a **profile photo**
|
||||
- Change your **password**
|
||||
|
||||
---
|
||||
|
||||
## Customising Group Names
|
||||
|
||||
You can set a personal display name for any group that only you will see:
|
||||
|
||||
1. Open the message
|
||||
2. Click the **message info** icon in the top right
|
||||
3. Enter your custom name under **Your custom name**
|
||||
4. Click **Save**
|
||||
|
||||
Other members still see the original group name, unless they change to customised name.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
Admins can access **Settings** from the user menu to configure:
|
||||
|
||||
- Branding a new app name and logo
|
||||
- Set new user password
|
||||
- Notification preferences
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- 🌙 Toggle **dark mode** from the user menu
|
||||
- 🔔 Enable **push notifications** when prompted to receive alerts when the app is closed
|
||||
- 📱 Install JAMA as a **PWA** on your device — tap *Add to Home Screen* in your browser menu for an app-like experience
|
||||
99
docker-compose.host.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
# docker-compose.host.yaml — RosterChirp-Host multi-tenant deployment
|
||||
#
|
||||
# Use this instead of docker-compose.yaml when running RosterChirp-Host.
|
||||
# Adds Caddy as the reverse proxy for automatic wildcard SSL.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.host.yaml up -d
|
||||
#
|
||||
# Required .env additions for host mode:
|
||||
# APP_TYPE=host
|
||||
# APP_DOMAIN=example.com
|
||||
# HOST_SLUG=chathost
|
||||
# HOST_ADMIN_KEY=your_secret_host_admin_key
|
||||
# CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider)
|
||||
|
||||
services:
|
||||
rosterchirp:
|
||||
image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
|
||||
container_name: ${PROJECT_NAME:-rosterchirp}
|
||||
restart: unless-stopped
|
||||
# No direct port exposure — traffic comes through Caddy
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ:-UTC}
|
||||
- APP_TYPE=host
|
||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@rosterchirp.local}
|
||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- APP_NAME=${APP_NAME:-rosterchirp}
|
||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- DB_NAME=${DB_NAME:-rosterchirp}
|
||||
- DB_USER=${DB_USER:-rosterchirp}
|
||||
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
- APP_DOMAIN=${APP_DOMAIN:?APP_DOMAIN is required in host mode}
|
||||
- HOST_SLUG=${HOST_SLUG:?HOST_SLUG is required in host mode}
|
||||
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode}
|
||||
volumes:
|
||||
- rosterchirp_uploads:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: ${PROJECT_NAME:-rosterchirp}_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_NAME:-rosterchirp}
|
||||
- POSTGRES_USER=${DB_USER:-rosterchirp}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
volumes:
|
||||
- rosterchirp_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-rosterchirp} -d ${DB_NAME:-rosterchirp}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
caddy:
|
||||
# Use a Caddy build with your DNS provider plugin.
|
||||
# Pre-built images: https://github.com/abiosoft/caddy-docker
|
||||
# Or build your own: xcaddy build --with github.com/caddy-dns/cloudflare
|
||||
image: caddy:2-alpine
|
||||
container_name: ${PROJECT_NAME:-rosterchirp}_caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # HTTP/3
|
||||
environment:
|
||||
- CF_API_TOKEN=${CF_API_TOKEN:-} # DNS provider token for wildcard certs
|
||||
volumes:
|
||||
- ./Caddyfile.example:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
- /var/log/caddy:/var/log/caddy
|
||||
depends_on:
|
||||
- rosterchirp
|
||||
|
||||
volumes:
|
||||
rosterchirp_db:
|
||||
driver: local
|
||||
rosterchirp_uploads:
|
||||
driver: local
|
||||
caddy_data:
|
||||
driver: local
|
||||
caddy_config:
|
||||
driver: local
|
||||
@@ -1,31 +1,64 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
teamchat:
|
||||
image: teamchat:${TEAMCHAT_VERSION:-latest}
|
||||
container_name: teamchat
|
||||
rosterchirp:
|
||||
image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
|
||||
container_name: ${PROJECT_NAME:-rosterchirp}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ:-UTC}
|
||||
- APP_TYPE=${APP_TYPE:-selfhost}
|
||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@teamchat.local}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@rosterchirp.local}
|
||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||
- PW_RESET=${PW_RESET:-false}
|
||||
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
||||
- APP_NAME=${APP_NAME:-TeamChat}
|
||||
- APP_NAME=${APP_NAME:-rosterchirp}
|
||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- DB_NAME=${DB_NAME:-rosterchirp}
|
||||
- DB_USER=${DB_USER:-rosterchirp}
|
||||
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
- APP_DOMAIN=${APP_DOMAIN:-}
|
||||
- HOST_SLUG=${HOST_SLUG:-}
|
||||
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-}
|
||||
- FIREBASE_API_KEY=${FIREBASE_API_KEY:-}
|
||||
- FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID:-}
|
||||
- FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID:-}
|
||||
- FIREBASE_APP_ID=${FIREBASE_APP_ID:-}
|
||||
- FIREBASE_VAPID_KEY=${FIREBASE_VAPID_KEY:-}
|
||||
- FIREBASE_SERVICE_ACCOUNT=${FIREBASE_SERVICE_ACCOUNT}
|
||||
volumes:
|
||||
- teamchat_db:/app/data
|
||||
- teamchat_uploads:/app/uploads
|
||||
- rosterchirp_uploads:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: ${PROJECT_NAME:-rosterchirp}_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_NAME:-rosterchirp}
|
||||
- POSTGRES_USER=${DB_USER:-rosterchirp}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
volumes:
|
||||
- rosterchirp_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-rosterchirp} -d ${DB_NAME:-rosterchirp}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
teamchat_db:
|
||||
rosterchirp_db:
|
||||
driver: local
|
||||
teamchat_uploads:
|
||||
rosterchirp_uploads:
|
||||
driver: local
|
||||
|
||||
87
docker-setup.md
Normal file
@@ -0,0 +1,87 @@
|
||||
## docker-compose.yaml
|
||||
|
||||
added multiple variable options, that requires a .env file (envirnment variable)
|
||||
|
||||
```
|
||||
services:
|
||||
jama:
|
||||
image: jama:${JAMA_VERSION:-latest}
|
||||
container_name: ${PROJECT_NAME:-jamachat}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ:-UTC}
|
||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||
- USER_PASS=${USER_PASS:-user@1234}
|
||||
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
||||
- DB_KEY=${DB_KEY}
|
||||
- APP_NAME=${APP_NAME:-jama}
|
||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||
volumes:
|
||||
- ${PROJECT_NAME}_db:/app/data
|
||||
- ${PROJECT_NAME}t_uploads:/app/uploads
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
${PROJECT_NAME:-jamachat}_db:
|
||||
driver: local
|
||||
${PROJECT_NAME:-jamachat}_uploads:
|
||||
driver: local
|
||||
```
|
||||
## .env file
|
||||
|
||||
these are an example of a required .env. It can usually be imported in to docker managers.
|
||||
|
||||
```
|
||||
# jama Configuration
|
||||
# just another messaging app
|
||||
|
||||
# Timezone — must match your host timezone (e.g. America/Toronto, Europe/London, Asia/Tokyo)
|
||||
# Run 'timedatectl' on your host to find the correct value
|
||||
TZ=UTC
|
||||
# Copy this file to .env and customize
|
||||
|
||||
# Image version to run (set by build.sh, or use 'latest')
|
||||
JAMA_VERSION=0.9.3
|
||||
|
||||
# Default admin credentials (used on FIRST RUN only)
|
||||
ADMIN_NAME=Admin User
|
||||
ADMIN_EMAIL=admin@jama.local
|
||||
ADMIN_PASS=Admin@1234
|
||||
|
||||
# Default password for bulk-imported users (when no password is set in CSV)
|
||||
USER_PASS=user@1234
|
||||
|
||||
# Set to true to reset admin password to ADMIN_PASS on every restart
|
||||
# WARNING: Leave false in production - shows a warning on login page when true
|
||||
ADMPW_RESET=false
|
||||
|
||||
# JWT secret - change this to a random string in production!
|
||||
JWT_SECRET=changeme_super_secret_jwt_key_change_in_production
|
||||
|
||||
# Database encryption key (SQLCipher AES-256)
|
||||
# Generate a strong random key: openssl rand -hex 32
|
||||
# IMPORTANT: If you are upgrading from an unencrypted install, run the
|
||||
# migration script first: node scripts/encrypt-db.js
|
||||
# Leave blank to run without encryption (not recommended for production)
|
||||
DB_KEY=
|
||||
|
||||
# App port (default 3000)
|
||||
PORT=3069
|
||||
|
||||
# App name (can also be changed in Settings UI)
|
||||
|
||||
# Default public group name (created on first run only)
|
||||
DEFCHAT_NAME=General Chat
|
||||
APP_NAME=jama
|
||||
|
||||
PROJECT_NAME=myjamachat ```
|
||||
15
fcm-app/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.nyc_output
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
ssl/
|
||||
icon-*.png
|
||||
18
fcm-app/.env
Normal file
@@ -0,0 +1,18 @@
|
||||
# Firebase Configuration
|
||||
FIREBASE_PROJECT_ID=fcmtest-push
|
||||
FIREBASE_PRIVATE_KEY_ID=ac38f0122d21b6db2e7cfae4ed2120d848afcb13
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7S59WBylwnzgq\nYUpbwj4vzoLa6MtC7K/ZrB2Uxj1QuqdbnMsFid9RkWs+z86FUH/DgGyABnhhuBxO\nK8yQ+f1WR6deM7v1xFLrmYVDLk/7VGNGtn/xmQ7yjJPLFLqNplPWxjz8StJDiRRh\nFjPewGFrk/afDy0garsJTP6tK1IRGIf/dvIdBiCHQ1xpmWwkNDb1xNFSWx3JpN9m\nEbsMZBo5Af2jL044Z4jLEO+y32freiRoZBG4KG6Jb4+xo2qwjxFATmychpc9xEsf\nrMyOaV7omuhqOmjK3PfSotZnYyYAat8kerATe/EZsRtlTh1UHsiN+1FNy/RPV5s8\nTFYWf7a/AgMBAAECggEAJ7Ce01wxK+yRumljmI5RH1Bj6n/qkwQVP8t5eU2JMNJd\nJMzVORc+e8qVL3paCWZFrOhKFddJK2wYk3g0oYRYazBEB3JvImW4LLUbyGDIEjqP\nzyxdcJU+1ad0qlR6NApLOfhIdC5m4GjsKKbL1yhtfJ6eZJaSuYvkltP6JDhJ69Uq\nLdtA2dA5RGr1W1I8G3Yw4tNw5ImrfxbD7sO1y7A2aI5ZRL4/fOK0QCjbu8dznqPg\n8qT4dqabIRWTdM70ixEqfojQwNmL1w4wVajX470jn8iJZau0QMpJVfm2PtBxzXcM\nuQU+kP6b7BrFvKJ4LD0UOweiDQncfnKiNamMZKQgAQKBgQDcobi+lhkYxekvztq/\nv0d3RqgpmnABg1dPvNYbFV1WPjzCy/Pv87HFROb0LA/xNQKjA+2ss+LDEZXgSRuV\n7ovEQ2Zib/TyN10ihYGpIbXlbxz9rEtsatIuynKvYFlWm/v1S5LnPkCXlkHLi+cO\n2Z6DniGjCLqB4w5ZqkYzWVnSfwKBgQDZUdh5VRAR/ge1Vi5QtpQKuaZRvxjS+GZH\nmJNuIfm/+9zKakOMXgieT1wyTFr6I7955h967BrfO/djtvAQca+7l68hlyTgS4bf\n+nEVCTd3wwAbcEXOubpgnyLzQeaztRTFkcpyTZ2eVGraoAjijsElOtbJBbu9xaqS\nOoH4Adt7wQKBgQDNppSMWV41QCx2Goq9li6oGB0hAkoKrwEQWwT7I7PncoWyUOck\nr3LxXKMlz3hgrbeyeTPt+ZKRnu+jqqFi5II0w1pIwPCBYWeXiPftzXU90Y8lSJbZ\nDMyzPpMds2Iyn5x/7RyWHOmaIj1b3CDYL7JYHmpeDAHElf7HRza+IDfgQwKBgBTQ\nfwBYAlsGzqwynesDIbjJQUHRIMqMGhe/aFeDD42wzNviQ6f9Faw8A6OZppkQtXUy\nck9ur8Az2SUGz4VzrhY0mASKmnCVK0zmitAt+s8QsUDvhvAe39gDRfCwni0WKfAm\nX5KFFpSklztrWo6Ah8VOFmZYkzvA4+5vhiU/4ErBAoGAboI2WX/JNd8A5KQgRRpT\n5RkNLbhgg1TaBBEdfCkpuCJbpghAnfpvg/2lTtbLJ7SbAijmldrT5nNbhVNxAgYM\nZgOcoZJPBGi1AB1HzlkcGO/C9/H1tnEBB6ECbQ3yaz0n8TLUuJqHGwsomJJVPACT\n2FSNbfQ0TqCs1ba+Hx9iQBQ=\n-----END PRIVATE KEY-----\n"
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-fbsvc@fcmtest-push.iam.gserviceaccount.com
|
||||
FIREBASE_CLIENT_ID=103917424542871804597
|
||||
FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
|
||||
FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
|
||||
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs
|
||||
FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40fcmtest-push.iam.gserviceaccount.com
|
||||
|
||||
# VAPID Key for Web Push
|
||||
VAPID_KEY=BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
TZ=America/Toronto
|
||||
15
fcm-app/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Firebase Configuration
|
||||
FIREBASE_PROJECT_ID=your-project-id
|
||||
FIREBASE_PRIVATE_KEY_ID=your-private-key-id
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com
|
||||
FIREBASE_CLIENT_ID=your-client-id
|
||||
FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
|
||||
FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
|
||||
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs
|
||||
FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-...%40your-project-id.iam.gserviceaccount.com
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
TZ=America/Toronto
|
||||
33
fcm-app/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# Use Node.js 18 LTS
|
||||
FROM node:18-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first (for better layer caching)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies and wget
|
||||
RUN npm install --omit=dev && apk add --no-cache wget
|
||||
|
||||
# Create non-root user and a writable data directory before copying app code
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
mkdir -p /app/data && \
|
||||
chown nodejs:nodejs /app/data
|
||||
|
||||
# Copy application code (exclude node_modules via .dockerignore)
|
||||
COPY --chown=nodejs:nodejs . .
|
||||
|
||||
# Switch to non-root user
|
||||
USER nodejs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
311
fcm-app/FCM_IMPLEMENTATION_NOTES.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# FCM PWA Implementation Notes
|
||||
_Reference for applying FCM fixes to other projects_
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Guide Key Points (fcm_details.txt)
|
||||
|
||||
### How FCM works (the correct flow)
|
||||
1. User grants notification permission
|
||||
2. Firebase generates a unique FCM token for the device
|
||||
3. Token is stored on your server for targeting
|
||||
4. Server sends push requests to Firebase
|
||||
5. Firebase delivers notifications to the device
|
||||
6. Service worker handles display and click interactions
|
||||
|
||||
### Common vibe-coding failures with FCM
|
||||
|
||||
**1. Service worker confusion**
|
||||
Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail.
|
||||
|
||||
**2. Deprecated API usage**
|
||||
Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is:
|
||||
```javascript
|
||||
const token = await messaging.getToken({
|
||||
vapidKey: VAPID_KEY,
|
||||
serviceWorkerRegistration: registration
|
||||
});
|
||||
```
|
||||
|
||||
**3. Token generation without durable storage**
|
||||
Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart.
|
||||
|
||||
**4. Poor permission flow**
|
||||
Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit.
|
||||
|
||||
**5. Missing notificationclick handler**
|
||||
Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app.
|
||||
|
||||
**6. Silent failures**
|
||||
Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback.
|
||||
|
||||
**7. iOS blind spots**
|
||||
iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop.
|
||||
|
||||
### Correct `getToken()` pattern (from guide)
|
||||
```javascript
|
||||
// Register SW first, then pass it directly to getToken
|
||||
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
|
||||
const token = await getToken(messaging, {
|
||||
vapidKey: VAPID_KEY,
|
||||
serviceWorkerRegistration: registration
|
||||
});
|
||||
if (!token) throw new Error('getToken() returned empty — check VAPID key and SW');
|
||||
```
|
||||
|
||||
### Correct `firebase-messaging-sw.js` pattern (from guide)
|
||||
```javascript
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
|
||||
|
||||
firebase.initializeApp({ /* config */ });
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
self.registration.showNotification(payload.notification.title, {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-notification',
|
||||
data: payload.data
|
||||
});
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
if (event.action === 'close') return;
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url === '/' && 'focus' in client) return client.focus();
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Code Fixes Applied to fcm-app
|
||||
|
||||
### app.js fixes
|
||||
|
||||
**Fix: `showUserInfo()` missing**
|
||||
Function was called on login and session restore but never defined — crashed immediately on login.
|
||||
```javascript
|
||||
function showUserInfo() {
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('userInfo').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `setupApp()` wrong element IDs**
|
||||
`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML.
|
||||
```javascript
|
||||
// Wrong
|
||||
document.getElementById('sendNotification').addEventListener('click', sendNotification);
|
||||
// Fixed
|
||||
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
|
||||
// Also added id="logoutBtn" to the logout button in index.html
|
||||
```
|
||||
|
||||
**Fix: `logout()` not clearing localStorage**
|
||||
Session was restored on next page load even after logout.
|
||||
```javascript
|
||||
function logout() {
|
||||
currentUser = null;
|
||||
fcmToken = null;
|
||||
localStorage.removeItem('currentUser'); // was missing
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Race condition in messaging initialization**
|
||||
`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise:
|
||||
```javascript
|
||||
let initPromise = null;
|
||||
function initializeFirebase() {
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
swRegistration = registration;
|
||||
messaging = firebase.messaging();
|
||||
})
|
||||
.catch((error) => { initPromise = null; throw error; });
|
||||
return initPromise;
|
||||
}
|
||||
// In login():
|
||||
await initializeFirebase(); // ensures messaging is ready before getToken()
|
||||
```
|
||||
|
||||
**Fix: `deleteToken()` invalidating tokens on every page load**
|
||||
`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0.
|
||||
Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`.
|
||||
|
||||
**Fix: Session restore without re-registering token**
|
||||
When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token.
|
||||
```javascript
|
||||
// In setupApp(), after restoring session:
|
||||
if (Notification.permission === 'granted') {
|
||||
initializeFirebase()
|
||||
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
|
||||
.then(token => { if (token) return registerToken(currentUser, token); })
|
||||
.catch(err => console.error('Token refresh on session restore failed:', err));
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Deprecated VAPID/SW API replaced**
|
||||
```javascript
|
||||
// Removed (deprecated):
|
||||
messaging.usePublicVapidKey(VAPID_KEY);
|
||||
messaging.useServiceWorker(registration);
|
||||
const token = await messaging.getToken();
|
||||
|
||||
// Replaced with:
|
||||
const VAPID_KEY = 'your-vapid-key';
|
||||
let swRegistration = null;
|
||||
// swRegistration set inside initializeFirebase() .then()
|
||||
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
|
||||
```
|
||||
|
||||
**Fix: Null token guard**
|
||||
`getToken()` can return null — passing null to the server produced a confusing 400 error.
|
||||
```javascript
|
||||
if (!token) {
|
||||
throw new Error('getToken() returned empty — check VAPID key and service worker');
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Error message included server response**
|
||||
```javascript
|
||||
// Before: throw new Error('Failed to register token');
|
||||
// After:
|
||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||
```
|
||||
|
||||
**Fix: Duplicate foreground message handlers**
|
||||
`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners.
|
||||
```javascript
|
||||
let foregroundHandlerSetup = false;
|
||||
function handleForegroundMessages() {
|
||||
if (foregroundHandlerSetup) return;
|
||||
foregroundHandlerSetup = true;
|
||||
messaging.onMessage(/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `login()` event.preventDefault() crash**
|
||||
Button called `login()` with no argument, so `event.preventDefault()` threw on undefined.
|
||||
```javascript
|
||||
async function login(event) {
|
||||
if (event) event.preventDefault(); // guard added
|
||||
```
|
||||
|
||||
**Fix: `firebase-messaging-sw.js` redirect stub replaced**
|
||||
File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above).
|
||||
|
||||
**Fix: `notificationclick` handler added to `sw.js`**
|
||||
Clicking a background notification did nothing. Handler added to focus existing window or open a new one.
|
||||
|
||||
**Fix: CDN URLs removed from `urlsToCache` in `sw.js`**
|
||||
External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install.
|
||||
```javascript
|
||||
// Removed from urlsToCache:
|
||||
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js',
|
||||
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'
|
||||
```
|
||||
|
||||
### server.js fixes
|
||||
|
||||
**Fix: `icon`/`badge`/`tag` in wrong notification object**
|
||||
These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`).
|
||||
```javascript
|
||||
// Wrong:
|
||||
notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }
|
||||
// Fixed:
|
||||
notification: { title, body },
|
||||
webpush: {
|
||||
notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' },
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `saveTokens()` in route handler not crash-safe**
|
||||
```javascript
|
||||
try {
|
||||
saveTokens();
|
||||
} catch (saveError) {
|
||||
console.error('Failed to persist tokens to disk:', saveError);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `setInterval(saveTokens)` uncaught exception crashed the server**
|
||||
An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state.
|
||||
```javascript
|
||||
setInterval(() => {
|
||||
try { saveTokens(); }
|
||||
catch (error) { console.error('Auto-save tokens failed:', error); }
|
||||
}, 30000);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Docker / Infrastructure Fixes
|
||||
|
||||
### Root cause of "no other users" bug
|
||||
The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain:
|
||||
1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`)
|
||||
2. This propagated out of `setInterval` as an uncaught exception
|
||||
3. Node.js exited the process
|
||||
4. Docker restarted the container with empty state
|
||||
5. Tokens were never on disk, so restart = all tokens lost
|
||||
|
||||
### Dockerfile fix
|
||||
```dockerfile
|
||||
# Create non-root user AND a writable data directory (while still root)
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
mkdir -p /app/data && \
|
||||
chown nodejs:nodejs /app/data
|
||||
```
|
||||
`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it.
|
||||
|
||||
### docker-compose.yml fix
|
||||
```yaml
|
||||
services:
|
||||
your-app:
|
||||
volumes:
|
||||
- app_data:/app/data # named volume survives container rebuilds
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
```
|
||||
Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`.
|
||||
|
||||
### server.js path fix
|
||||
```javascript
|
||||
// Changed from:
|
||||
const TOKENS_FILE = './tokens.json';
|
||||
// To:
|
||||
const TOKENS_FILE = './data/tokens.json';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist for applying to another project
|
||||
|
||||
- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub)
|
||||
- [ ] `notificationclick` handler present in service worker
|
||||
- [ ] CDN URLs NOT in `urlsToCache` in any service worker
|
||||
- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()`
|
||||
- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker`
|
||||
- [ ] `deleteToken()` is NOT called on page load
|
||||
- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'`
|
||||
- [ ] Null/empty token check before sending to server
|
||||
- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification`
|
||||
- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval`
|
||||
- [ ] Docker: data directory created with correct user ownership in Dockerfile
|
||||
- [ ] Docker: named volume mounted for data directory in docker-compose.yml
|
||||
- [ ] Duplicate foreground message handler registration is guarded
|
||||
209
fcm-app/README.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# FCM Test PWA
|
||||
|
||||
A Progressive Web App for testing Firebase Cloud Messaging (FCM) notifications across desktop and mobile devices.
|
||||
|
||||
## Features
|
||||
|
||||
- PWA with install capability
|
||||
- Firebase Cloud Messaging integration
|
||||
- Multi-user support (pwau1, pwau2, pwau3)
|
||||
- SSL/HTTPS ready
|
||||
- Docker deployment
|
||||
- Real-time notifications
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Firebase Setup
|
||||
|
||||
1. **Create Firebase Project**
|
||||
- Go to [Firebase Console](https://console.firebase.google.com/)
|
||||
- Click "Add project"
|
||||
- Enter project name (e.g., "fcm-test-pwa")
|
||||
- Enable Google Analytics (optional)
|
||||
- Click "Create project"
|
||||
|
||||
2. **Enable Cloud Messaging**
|
||||
- In your project dashboard, go to "Build" → "Cloud Messaging"
|
||||
- Click "Get started"
|
||||
- Cloud Messaging is now enabled for your project
|
||||
|
||||
3. **Get Firebase Configuration**
|
||||
- Go to Project Settings (⚙️ icon)
|
||||
- Under "Your apps", click "Web app" (</> icon)
|
||||
- Register app with nickname "FCM Test PWA"
|
||||
- Copy the Firebase config object (you'll need this later)
|
||||
|
||||
4. **Generate Service Account Key**
|
||||
- In Project Settings, go to "Service accounts"
|
||||
- Click "Generate new private key"
|
||||
- Save the JSON file (you'll need this for the server)
|
||||
|
||||
5. **Get Web Push Certificate**
|
||||
- In Cloud Messaging settings, click "Web Push certificates"
|
||||
- Generate and save the key pair
|
||||
|
||||
### 2. Server Configuration
|
||||
|
||||
1. **Copy environment template**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Update .env file** with your Firebase credentials:
|
||||
```env
|
||||
FIREBASE_PROJECT_ID=your-project-id
|
||||
FIREBASE_PRIVATE_KEY_ID=your-private-key-id
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com
|
||||
FIREBASE_CLIENT_ID=your-client-id
|
||||
# ... other fields from service account JSON
|
||||
```
|
||||
|
||||
3. **Update Firebase config in client files**:
|
||||
- Edit `public/app.js` - replace Firebase config
|
||||
- Edit `public/sw.js` - replace Firebase config
|
||||
|
||||
### 3. Local Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 in your browser.
|
||||
|
||||
### 4. Docker Deployment
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## User Accounts
|
||||
|
||||
| Username | Password | Purpose |
|
||||
|----------|----------|---------|
|
||||
| pwau1 | test123 | Desktop user |
|
||||
| pwau2 | test123 | Mobile user 1 |
|
||||
| pwau3 | test123 | Mobile user 2 |
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Install as PWA**
|
||||
- Open the app in Chrome/Firefox
|
||||
- Click the install icon in the address bar
|
||||
- Install as a desktop app
|
||||
|
||||
2. **Enable Notifications**
|
||||
- Login with any user account
|
||||
- Grant notification permissions when prompted
|
||||
- FCM token will be automatically registered
|
||||
|
||||
3. **Send Notifications**
|
||||
- Click "Send Notification" button
|
||||
- All other logged-in users will receive the notification
|
||||
- Check both desktop and mobile devices
|
||||
|
||||
## Deployment on Ubuntu LXC + HAProxy
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Install Docker Compose
|
||||
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
```
|
||||
|
||||
### SSL Certificate Setup
|
||||
|
||||
```bash
|
||||
# Create SSL directory
|
||||
mkdir -p ssl
|
||||
|
||||
# Generate self-signed certificate (for testing)
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout ssl/key.pem \
|
||||
-out ssl/cert.pem \
|
||||
-subj "/C=US/ST=State/L=City/O=Organization/CN=your-domain.com"
|
||||
|
||||
# OR use Let's Encrypt for production
|
||||
sudo apt install certbot
|
||||
sudo certbot certonly --standalone -d your-domain.com
|
||||
sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ssl/cert.pem
|
||||
sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem ssl/key.pem
|
||||
```
|
||||
|
||||
### HAProxy Configuration
|
||||
|
||||
Add to your `/etc/haproxy/haproxy.cfg`:
|
||||
|
||||
```haproxy
|
||||
frontend fcm_test_frontend
|
||||
bind *:80
|
||||
bind *:443 ssl crt /etc/ssl/certs/your-cert.pem
|
||||
redirect scheme https if !{ ssl_fc }
|
||||
default_backend fcm_test_backend
|
||||
|
||||
backend fcm_test_backend
|
||||
balance roundrobin
|
||||
server fcm_test 127.0.0.1:3000 check
|
||||
```
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone <your-repo>
|
||||
cd fcm-test-pwa
|
||||
cp .env.example .env
|
||||
# Edit .env with your Firebase config
|
||||
|
||||
# Deploy
|
||||
docker-compose up -d
|
||||
|
||||
# Check status
|
||||
docker-compose ps
|
||||
docker-compose logs
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Desktop Testing**
|
||||
- Open app in Chrome
|
||||
- Install as PWA
|
||||
- Login as pwau1
|
||||
- Send test notifications
|
||||
|
||||
2. **Mobile Testing**
|
||||
- Open app on mobile browsers
|
||||
- Install as PWA
|
||||
- Login as pwau2 and pwau3 on different devices
|
||||
- Test cross-device notifications
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Notifications not working**: Check Firebase configuration and service worker
|
||||
- **PWA not installing**: Ensure site is served over HTTPS
|
||||
- **Docker issues**: Check logs with `docker-compose logs`
|
||||
- **HAProxy issues**: Verify SSL certificates and backend connectivity
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Change default passwords in production
|
||||
- Use proper SSL certificates
|
||||
- Implement rate limiting for notifications
|
||||
- Consider using a database for token storage in production
|
||||
22
fcm-app/docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
fcm-test-app:
|
||||
build: .
|
||||
ports:
|
||||
- "3066:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ:-UTC}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- fcm_data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
fcm_data:
|
||||
1013
fcm-app/fcm_details.txt
Normal file
57
fcm-app/nginx.conf
Normal file
@@ -0,0 +1,57 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream app {
|
||||
server fcm-test-app:3000;
|
||||
}
|
||||
|
||||
# HTTP to HTTPS redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
# SSL configuration
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# PWA headers
|
||||
add_header Service-Worker-Allowed "/";
|
||||
|
||||
location / {
|
||||
proxy_pass http://app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Serve static files directly
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|json|webmanifest)$ {
|
||||
proxy_pass http://app;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
}
|
||||
23
fcm-app/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "fcm-test-pwa",
|
||||
"version": "1.0.0",
|
||||
"description": "PWA for testing Firebase Cloud Messaging",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"firebase": "^10.7.1",
|
||||
"firebase-admin": "^12.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"keywords": ["pwa", "fcm", "firebase", "notifications"],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
334
fcm-app/public/app.js
Normal file
@@ -0,0 +1,334 @@
|
||||
// Load Firebase SDK immediately
|
||||
const script1 = document.createElement('script');
|
||||
script1.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js';
|
||||
script1.onload = () => {
|
||||
const script2 = document.createElement('script');
|
||||
script2.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js';
|
||||
script2.onload = () => {
|
||||
// Initialize Firebase immediately
|
||||
initializeFirebase();
|
||||
console.log('Firebase SDK and initialization complete');
|
||||
|
||||
// Now that Firebase is ready, set up the app
|
||||
setupApp();
|
||||
};
|
||||
document.head.appendChild(script2);
|
||||
};
|
||||
document.head.appendChild(script1);
|
||||
|
||||
// Global variables
|
||||
let currentUser = null;
|
||||
let fcmToken = null;
|
||||
let messaging = null;
|
||||
let swRegistration = null;
|
||||
let initPromise = null;
|
||||
let foregroundHandlerSetup = false;
|
||||
|
||||
const VAPID_KEY = 'BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw';
|
||||
|
||||
// Simple user authentication
|
||||
const users = {
|
||||
'pwau1': { password: 'test123', name: 'Desktop User' },
|
||||
'pwau2': { password: 'test123', name: 'Mobile User 1' },
|
||||
'pwau3': { password: 'test123', name: 'Mobile User 2' }
|
||||
};
|
||||
|
||||
// Initialize Firebase — returns a promise that resolves when messaging is ready
|
||||
function initializeFirebase() {
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
|
||||
authDomain: "fcmtest-push.firebaseapp.com",
|
||||
projectId: "fcmtest-push",
|
||||
storageBucket: "fcmtest-push.firebasestorage.app",
|
||||
messagingSenderId: "439263996034",
|
||||
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
|
||||
};
|
||||
|
||||
if (firebase.apps.length === 0) {
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
console.log('Firebase app initialized');
|
||||
}
|
||||
|
||||
initPromise = navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('Service Worker registered:', registration);
|
||||
swRegistration = registration;
|
||||
messaging = firebase.messaging();
|
||||
console.log('Firebase messaging initialized successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
initPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
// Show user info panel and hide login form
|
||||
function showUserInfo() {
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('userInfo').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
|
||||
}
|
||||
|
||||
// Setup app after Firebase is ready
|
||||
function setupApp() {
|
||||
// Set up event listeners
|
||||
document.getElementById('loginForm').addEventListener('submit', login);
|
||||
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
|
||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||
|
||||
// Restore session and re-register FCM token if notifications were already granted
|
||||
const savedUser = localStorage.getItem('currentUser');
|
||||
if (savedUser) {
|
||||
currentUser = savedUser;
|
||||
showUserInfo();
|
||||
if (Notification.permission === 'granted') {
|
||||
initializeFirebase()
|
||||
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
|
||||
.then(token => { if (token) return registerToken(currentUser, token); })
|
||||
.catch(err => console.error('Token refresh on session restore failed:', err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request notification permission and get FCM token
|
||||
async function requestNotificationPermission() {
|
||||
try {
|
||||
console.log('Requesting notification permission...');
|
||||
const permission = await Notification.requestPermission();
|
||||
console.log('Permission result:', permission);
|
||||
|
||||
if (permission === 'granted') {
|
||||
console.log('Notification permission granted.');
|
||||
showStatus('Getting FCM token...', 'info');
|
||||
|
||||
try {
|
||||
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
|
||||
console.log('FCM Token generated:', token);
|
||||
|
||||
if (!token) {
|
||||
throw new Error('getToken() returned empty — check VAPID key and service worker');
|
||||
}
|
||||
|
||||
fcmToken = token;
|
||||
|
||||
// Send token to server
|
||||
await registerToken(currentUser, token);
|
||||
showStatus('Notifications enabled successfully!', 'success');
|
||||
} catch (tokenError) {
|
||||
console.error('Error getting FCM token:', tokenError);
|
||||
showStatus('Failed to get FCM token: ' + tokenError.message, 'error');
|
||||
}
|
||||
} else {
|
||||
console.log('Notification permission denied.');
|
||||
showStatus('Notification permission denied.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting notification permission:', error);
|
||||
showStatus('Failed to enable notifications: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Register FCM token with server
|
||||
async function registerToken(username, token) {
|
||||
try {
|
||||
console.log('Attempting to register token:', { username, token: token.substring(0, 20) + '...' });
|
||||
|
||||
const response = await fetch('/register-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, token })
|
||||
});
|
||||
|
||||
console.log('Registration response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Token registered successfully:', result);
|
||||
showStatus(`Token registered for ${username}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error registering token:', error);
|
||||
showStatus('Failed to register token with server: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle foreground messages (guard against duplicate registration)
|
||||
function handleForegroundMessages() {
|
||||
if (foregroundHandlerSetup) return;
|
||||
foregroundHandlerSetup = true;
|
||||
messaging.onMessage(function(payload) {
|
||||
console.log('Received foreground message: ', payload);
|
||||
|
||||
// Show notification in foreground
|
||||
const notificationTitle = payload.notification.title;
|
||||
const notificationOptions = {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png'
|
||||
};
|
||||
|
||||
new Notification(notificationTitle, notificationOptions);
|
||||
showStatus(`New notification: ${payload.notification.body}`, 'info');
|
||||
});
|
||||
}
|
||||
|
||||
// Login function
|
||||
async function login(event) {
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!users[username] || users[username].password !== password) {
|
||||
showStatus('Invalid username or password', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentUser = username;
|
||||
localStorage.setItem('currentUser', username);
|
||||
showUserInfo();
|
||||
|
||||
showStatus(`Logged in as ${users[username].name}`, 'success');
|
||||
|
||||
// Initialize Firebase and request notifications
|
||||
if (typeof firebase !== 'undefined') {
|
||||
await initializeFirebase();
|
||||
await requestNotificationPermission();
|
||||
handleForegroundMessages();
|
||||
} else {
|
||||
showStatus('Firebase not loaded. Please check your connection.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Logout function
|
||||
function logout() {
|
||||
currentUser = null;
|
||||
fcmToken = null;
|
||||
localStorage.removeItem('currentUser');
|
||||
document.getElementById('loginForm').style.display = 'block';
|
||||
document.getElementById('userInfo').style.display = 'none';
|
||||
document.getElementById('username').value = '';
|
||||
document.getElementById('password').value = '';
|
||||
showStatus('Logged out successfully.', 'info');
|
||||
}
|
||||
|
||||
// Send notification function
|
||||
async function sendNotification() {
|
||||
if (!currentUser) {
|
||||
showStatus('Please login first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// First check registered users
|
||||
const usersResponse = await fetch('/users');
|
||||
const users = await usersResponse.json();
|
||||
console.log('Registered users:', users);
|
||||
|
||||
const response = await fetch('/send-notification', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fromUser: currentUser,
|
||||
title: 'Test Notification',
|
||||
body: `Notification sent from ${currentUser} at ${new Date().toLocaleTimeString()}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send notification');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Send result:', result);
|
||||
|
||||
if (result.recipients === 0) {
|
||||
showStatus('No other users have registered tokens. Open the app on other devices and enable notifications.', 'error');
|
||||
} else {
|
||||
showStatus(`Notification sent to ${result.recipients} user(s)!`, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending notification:', error);
|
||||
showStatus('Failed to send notification.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Show status message
|
||||
function showStatus(message, type) {
|
||||
const statusEl = document.getElementById('status');
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = `status ${type}`;
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Register service worker and handle PWA installation
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(function(registration) {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
|
||||
// Handle PWA installation
|
||||
let deferredPrompt;
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
console.log('beforeinstallprompt fired');
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
// Show install button or banner
|
||||
showInstallButton();
|
||||
});
|
||||
|
||||
function showInstallButton() {
|
||||
const installBtn = document.createElement('button');
|
||||
installBtn.textContent = 'Install App';
|
||||
installBtn.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
`;
|
||||
|
||||
installBtn.addEventListener('click', async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
console.log(`User response to the install prompt: ${outcome}`);
|
||||
deferredPrompt = null;
|
||||
installBtn.remove();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(installBtn);
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.log('ServiceWorker registration failed: ', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
48
fcm-app/public/firebase-messaging-sw.js
Normal file
@@ -0,0 +1,48 @@
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
|
||||
authDomain: "fcmtest-push.firebaseapp.com",
|
||||
projectId: "fcmtest-push",
|
||||
storageBucket: "fcmtest-push.firebasestorage.app",
|
||||
messagingSenderId: "439263996034",
|
||||
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
|
||||
});
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
messaging.onBackgroundMessage(function(payload) {
|
||||
console.log('Received background message:', payload);
|
||||
|
||||
const notificationTitle = payload.notification.title;
|
||||
const notificationOptions = {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-test',
|
||||
data: payload.data
|
||||
};
|
||||
|
||||
self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
console.log('Notification clicked:', event);
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'close') return;
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
|
||||
for (const client of clientList) {
|
||||
if (client.url === '/' && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
BIN
fcm-app/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
fcm-app/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
111
fcm-app/public/index.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FCM Test PWA</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#2196F3">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.login-form {
|
||||
display: block;
|
||||
}
|
||||
.user-info {
|
||||
display: none;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #1976D2;
|
||||
}
|
||||
button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
.user-display {
|
||||
background-color: #e3f2fd;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>FCM Test PWA</h1>
|
||||
|
||||
<div id="status" class="status" style="display: none;"></div>
|
||||
|
||||
<div id="loginForm" class="login-form">
|
||||
<h2>Login</h2>
|
||||
<input type="text" id="username" placeholder="Username (pwau1, pwau2, or pwau3)" required>
|
||||
<input type="password" id="password" placeholder="Password" required>
|
||||
<button onclick="login()">Login</button>
|
||||
</div>
|
||||
|
||||
<div id="userInfo" class="user-info">
|
||||
<div class="user-display">
|
||||
Logged in as: <span id="currentUser"></span>
|
||||
</div>
|
||||
<button id="sendNotificationBtn" onclick="sendNotification()">Send Notification</button>
|
||||
<button id="logoutBtn" onclick="logout()">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
fcm-app/public/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "FCM Test PWA",
|
||||
"short_name": "FCM Test",
|
||||
"description": "PWA for testing Firebase Cloud Messaging",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#2196F3",
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"purpose": "any maskable",
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"purpose": "any maskable",
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "productivity"],
|
||||
"lang": "en-US"
|
||||
}
|
||||
82
fcm-app/public/sw.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const CACHE_NAME = 'fcm-test-pwa-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/app.js',
|
||||
'/manifest.json'
|
||||
];
|
||||
|
||||
// Install event
|
||||
self.addEventListener('install', function(event) {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(function(cache) {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event
|
||||
self.addEventListener('fetch', function(event) {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(function(response) {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync for FCM
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
|
||||
|
||||
// Initialize Firebase in service worker
|
||||
firebase.initializeApp({
|
||||
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
|
||||
authDomain: "fcmtest-push.firebaseapp.com",
|
||||
projectId: "fcmtest-push",
|
||||
storageBucket: "fcmtest-push.firebasestorage.app",
|
||||
messagingSenderId: "439263996034",
|
||||
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
|
||||
});
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
// Handle notification clicks
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
console.log('Notification clicked:', event);
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'close') return;
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
|
||||
for (const client of clientList) {
|
||||
if (client.url === '/' && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle background messages
|
||||
messaging.onBackgroundMessage(function(payload) {
|
||||
console.log('Received background message ', payload);
|
||||
|
||||
const notificationTitle = payload.notification.title;
|
||||
const notificationOptions = {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-test'
|
||||
};
|
||||
|
||||
return self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
});
|
||||
244
fcm-app/server.js
Normal file
@@ -0,0 +1,244 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const admin = require('firebase-admin');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// In-memory storage for FCM tokens (in production, use a database)
|
||||
const userTokens = new Map();
|
||||
|
||||
// Load tokens from file on startup (for persistence)
|
||||
const fs = require('fs');
|
||||
const TOKENS_FILE = './data/tokens.json';
|
||||
|
||||
function loadTokens() {
|
||||
try {
|
||||
if (fs.existsSync(TOKENS_FILE)) {
|
||||
const data = fs.readFileSync(TOKENS_FILE, 'utf8');
|
||||
const tokens = JSON.parse(data);
|
||||
for (const [user, tokenArray] of Object.entries(tokens)) {
|
||||
userTokens.set(user, new Set(tokenArray));
|
||||
}
|
||||
console.log(`Loaded tokens for ${userTokens.size} users from file`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('No existing tokens file found, starting fresh');
|
||||
}
|
||||
}
|
||||
|
||||
function saveTokens() {
|
||||
const tokens = {};
|
||||
for (const [user, tokenSet] of userTokens.entries()) {
|
||||
tokens[user] = Array.from(tokenSet);
|
||||
}
|
||||
fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
|
||||
}
|
||||
|
||||
// Load existing tokens on startup
|
||||
loadTokens();
|
||||
|
||||
// Auto-save tokens every 30 seconds
|
||||
setInterval(() => {
|
||||
try {
|
||||
saveTokens();
|
||||
} catch (error) {
|
||||
console.error('Auto-save tokens failed:', error);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Initialize Firebase Admin
|
||||
if (process.env.FIREBASE_PRIVATE_KEY) {
|
||||
const serviceAccount = {
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
privateKeyId: process.env.FIREBASE_PRIVATE_KEY_ID,
|
||||
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
clientId: process.env.FIREBASE_CLIENT_ID,
|
||||
authUri: process.env.FIREBASE_AUTH_URI,
|
||||
tokenUri: process.env.FIREBASE_TOKEN_URI,
|
||||
authProviderX509CertUrl: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL,
|
||||
clientC509CertUrl: process.env.FIREBASE_CLIENT_X509_CERT_URL
|
||||
};
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount)
|
||||
});
|
||||
|
||||
console.log('Firebase Admin initialized successfully');
|
||||
} else {
|
||||
console.log('Firebase Admin not configured. Please set up .env file');
|
||||
}
|
||||
|
||||
// Routes
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Register FCM token
|
||||
app.post('/register-token', (req, res) => {
|
||||
const { username, token } = req.body;
|
||||
|
||||
console.log(`Token registration request:`, { username, token: token?.substring(0, 20) + '...' });
|
||||
|
||||
if (!username || !token) {
|
||||
console.log('Token registration failed: missing username or token');
|
||||
return res.status(400).json({ error: 'Username and token are required' });
|
||||
}
|
||||
|
||||
// Store token for user
|
||||
if (!userTokens.has(username)) {
|
||||
userTokens.set(username, new Set());
|
||||
}
|
||||
|
||||
const userTokenSet = userTokens.get(username);
|
||||
if (userTokenSet.has(token)) {
|
||||
console.log(`Token already registered for user: ${username}`);
|
||||
} else {
|
||||
userTokenSet.add(token);
|
||||
console.log(`New token registered for user: ${username}`);
|
||||
// Save immediately after new registration
|
||||
try {
|
||||
saveTokens();
|
||||
} catch (saveError) {
|
||||
console.error('Failed to persist tokens to disk:', saveError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Total tokens for ${username}: ${userTokenSet.size}`);
|
||||
console.log(`Total registered users: ${userTokens.size}`);
|
||||
|
||||
res.json({ success: true, message: 'Token registered successfully' });
|
||||
});
|
||||
|
||||
// Send notification to all other users
|
||||
app.post('/send-notification', async (req, res) => {
|
||||
const { fromUser, title, body } = req.body;
|
||||
|
||||
if (!fromUser || !title || !body) {
|
||||
return res.status(400).json({ error: 'fromUser, title, and body are required' });
|
||||
}
|
||||
|
||||
if (!admin.apps.length) {
|
||||
return res.status(500).json({ error: 'Firebase Admin not initialized' });
|
||||
}
|
||||
|
||||
try {
|
||||
let totalRecipients = 0;
|
||||
const promises = [];
|
||||
|
||||
// Send to all users except the sender
|
||||
for (const [username, tokens] of userTokens.entries()) {
|
||||
if (username === fromUser) continue; // Skip sender
|
||||
|
||||
for (const token of tokens) {
|
||||
const message = {
|
||||
token: token,
|
||||
notification: {
|
||||
title: title,
|
||||
body: body
|
||||
},
|
||||
webpush: {
|
||||
headers: {
|
||||
'Urgency': 'high'
|
||||
},
|
||||
notification: {
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-test'
|
||||
},
|
||||
fcm_options: {
|
||||
link: '/'
|
||||
}
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
sound: 'default',
|
||||
click_action: '/'
|
||||
}
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
badge: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
promises.push(
|
||||
admin.messaging().send(message)
|
||||
.then(() => {
|
||||
console.log(`Notification sent to ${username} successfully`);
|
||||
totalRecipients++;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Error sending notification to ${username}:`, error);
|
||||
// Remove invalid token
|
||||
if (error.code === 'messaging/registration-token-not-registered') {
|
||||
tokens.delete(token);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
recipients: totalRecipients,
|
||||
message: `Notification sent to ${totalRecipients} recipient(s)`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending notifications:', error);
|
||||
res.status(500).json({ error: 'Failed to send notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all registered users (for debugging)
|
||||
app.get('/users', (req, res) => {
|
||||
const users = {};
|
||||
console.log('Current userTokens map:', userTokens);
|
||||
console.log('Number of registered users:', userTokens.size);
|
||||
|
||||
for (const [username, tokens] of userTokens.entries()) {
|
||||
users[username] = {
|
||||
tokenCount: tokens.size,
|
||||
tokens: Array.from(tokens)
|
||||
};
|
||||
}
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// Debug endpoint to check server status
|
||||
app.get('/debug', (req, res) => {
|
||||
res.json({
|
||||
firebaseAdminInitialized: admin.apps.length > 0,
|
||||
registeredUsers: userTokens.size,
|
||||
userTokens: Object.fromEntries(
|
||||
Array.from(userTokens.entries()).map(([user, tokens]) => [user, {
|
||||
count: tokens.size,
|
||||
tokens: Array.from(tokens)
|
||||
}])
|
||||
),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`FCM Test PWA server running on port ${PORT}`);
|
||||
console.log(`Open http://localhost:${PORT} in your browser`);
|
||||
console.log(`Server listening on all interfaces (0.0.0.0:${PORT})`);
|
||||
});
|
||||
@@ -2,13 +2,17 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/icons/rosterchirp.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#1a73e8" />
|
||||
<meta name="description" content="TeamChat - Modern 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="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>TeamChat</title>
|
||||
<title>RosterChirp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "teamchat-frontend",
|
||||
"version": "1.0.0",
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,10 +16,12 @@
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"date-fns": "^3.3.1"
|
||||
"date-fns": "^3.3.1",
|
||||
"marked": "^12.0.0",
|
||||
"firebase": "^10.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/avatar/admin.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/icons/icon-192-maskable.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1021 B After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/icons/icon-512-maskable.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 67 KiB |
BIN
frontend/public/icons/logo-64.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
frontend/public/icons/rosterchirp.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "TeamChat",
|
||||
"short_name": "TeamChat",
|
||||
"name": "RosterChirp",
|
||||
"short_name": "RosterChirp",
|
||||
"description": "Modern team messaging application",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"orientation": "any",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#1a73e8",
|
||||
"icons": [
|
||||
@@ -16,22 +16,25 @@
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"src": "/icons/icon-192-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"purpose": "maskable",
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
"type": "image/png"
|
||||
|
||||
},
|
||||
{
|
||||
"purpose": "any",
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"type": "image/png"
|
||||
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"min_width": "320px"
|
||||
}
|
||||
59
frontend/public/sw.gemini.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ── Unified Push Handler (Optimized for Mobile) ──────────────────────────────
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push event received. Messaging Ready:', !!messaging);
|
||||
|
||||
// event.waitUntil is the "Keep-Alive" signal for mobile OS
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
let payload;
|
||||
|
||||
// 1. Try to parse the data directly from the push event (Fastest/Reliable)
|
||||
if (event.data) {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
console.log('[SW] Raw push data parsed:', JSON.stringify(payload));
|
||||
} catch (e) {
|
||||
console.warn('[SW] Could not parse JSON, using text fallback');
|
||||
payload = { notification: { body: event.data.text() } };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If the payload is empty, check if Firebase can catch it
|
||||
// (This happens if your server sends "Notification" instead of "Data" messages)
|
||||
if (!payload && messaging) {
|
||||
// This is a last-resort wait for the SDK
|
||||
payload = await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(null), 2000);
|
||||
messaging.onBackgroundMessage((bgPayload) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(bgPayload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Construct and show the notification
|
||||
if (payload) {
|
||||
const n = payload.notification || {};
|
||||
const d = payload.data || {};
|
||||
|
||||
// Use the specific function you already defined
|
||||
await showRosterChirpNotification({
|
||||
title: n.title || d.title || 'New Message',
|
||||
body: n.body || d.body || '',
|
||||
url: d.url || d.link || '/', // some SDKs use 'link'
|
||||
groupId: d.groupId || '',
|
||||
});
|
||||
} else {
|
||||
// Fallback if we woke up for a "ghost" push with no data
|
||||
await self.registration.showNotification('RosterChirp', {
|
||||
body: 'You have a new update.',
|
||||
tag: 'rosterchirp-fallback'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SW] Critical Push Error:', error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
82
frontend/public/sw.gemini.js.txt
Normal file
@@ -0,0 +1,82 @@
|
||||
The Consolidated "Bulletproof" Push Listener
|
||||
To fix the "hit or miss" behavior on mobile, we need to move away from relying on the Firebase SDK's internal listener (which is a black box that doesn't always play nice with mobile power management) and instead wrap everything in the native push event using event.waitUntil.
|
||||
|
||||
Replace your current messaging.onBackgroundMessage and self.addEventListener('push') blocks with this unified version:
|
||||
|
||||
JavaScript
|
||||
// ── Unified Push Handler (Optimized for Mobile) ──────────────────────────────
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push event received. Messaging Ready:', !!messaging);
|
||||
|
||||
// event.waitUntil is the "Keep-Alive" signal for mobile OS
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
let payload;
|
||||
|
||||
// 1. Try to parse the data directly from the push event (Fastest/Reliable)
|
||||
if (event.data) {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
console.log('[SW] Raw push data parsed:', JSON.stringify(payload));
|
||||
} catch (e) {
|
||||
console.warn('[SW] Could not parse JSON, using text fallback');
|
||||
payload = { notification: { body: event.data.text() } };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If the payload is empty, check if Firebase can catch it
|
||||
// (This happens if your server sends "Notification" instead of "Data" messages)
|
||||
if (!payload && messaging) {
|
||||
// This is a last-resort wait for the SDK
|
||||
payload = await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(null), 2000);
|
||||
messaging.onBackgroundMessage((bgPayload) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(bgPayload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Construct and show the notification
|
||||
if (payload) {
|
||||
const n = payload.notification || {};
|
||||
const d = payload.data || {};
|
||||
|
||||
// Use the specific function you already defined
|
||||
await showRosterChirpNotification({
|
||||
title: n.title || d.title || 'New Message',
|
||||
body: n.body || d.body || '',
|
||||
url: d.url || d.link || '/', // some SDKs use 'link'
|
||||
groupId: d.groupId || '',
|
||||
});
|
||||
} else {
|
||||
// Fallback if we woke up for a "ghost" push with no data
|
||||
await self.registration.showNotification('RosterChirp', {
|
||||
body: 'You have a new update.',
|
||||
tag: 'rosterchirp-fallback'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SW] Critical Push Error:', error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
Why this fixes your issues:
|
||||
Eliminates the Race Condition: By wrapping the logic in an async function inside event.waitUntil, you tell Android/iOS: "Do not kill this process until the notification is actually rendered on the screen."
|
||||
|
||||
Bypasses SDK "Sleep": Sometimes the Firebase SDK takes too long to initialize. This code tries to parse the event.data directly first. Direct parsing is almost instantaneous, which is critical when a phone is in a deep sleep state.
|
||||
|
||||
Handles "Ghost" Pushes: Sometimes FCM sends a "heartbeat" or an empty push to wake the device. This script ensures that even if data is missing, the worker stays alive long enough to handle it gracefully rather than crashing.
|
||||
|
||||
One Final Server-Side Check
|
||||
Since you are using HAProxy, check your server logs for the HTTP status code when you send a message to a mobile device.
|
||||
|
||||
If you see 200 OK but no notification: The issue was definitely this Service Worker "sleep" issue.
|
||||
|
||||
If you see 401 or 403: HAProxy might be stripping the Authorization header from your backend's outbound request to Google.
|
||||
|
||||
If you see 400: Ensure your backend is sending priority: "high" in the FCM JSON.
|
||||
|
||||
Would you like me to provide a Python or Node.js snippet to test sending a "High Priority" message with the correct v1 API headers?
|
||||
@@ -1,4 +1,12 @@
|
||||
const CACHE_NAME = 'teamchat-v2';
|
||||
// ── Service Worker — RosterChirp ───────────────────────────────────────────────
|
||||
// Push notifications are handled via the standard W3C Push API (`push` event).
|
||||
// The Firebase SDK is not initialised here — FCM delivers the payload via the
|
||||
// standard push event and event.data.json() is sufficient to read it.
|
||||
// Firebase SDK initialisation (for getToken) happens in the main thread (Chat.jsx),
|
||||
// where the config is fetched at runtime from /api/push/firebase-config.
|
||||
|
||||
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
const CACHE_NAME = 'rosterchirp-v1';
|
||||
const STATIC_ASSETS = ['/'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
@@ -19,44 +27,88 @@ self.addEventListener('activate', (event) => {
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = event.request.url;
|
||||
if (url.includes('/api/') || url.includes('/socket.io/') || url.includes('/manifest.json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only intercept same-origin requests — never intercept cross-origin calls
|
||||
// (Firebase API, Google CDN, socket.io CDN, etc.) or specific local paths.
|
||||
// Intercepting cross-origin requests causes Firebase SDK calls to return
|
||||
// cached HTML, producing "unsupported MIME type" errors and breaking FCM.
|
||||
if (!url.startsWith(self.location.origin)) return;
|
||||
if (url.includes('/api/') || url.includes('/socket.io/') || url.includes('/manifest.json')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
|
||||
// Track badge count in SW
|
||||
// ── Badge counter ─────────────────────────────────────────────────────────────
|
||||
let badgeCount = 0;
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
const data = event.data.json();
|
||||
|
||||
function showRosterChirpNotification(data) {
|
||||
console.log('[SW] showRosterChirpNotification:', JSON.stringify(data));
|
||||
badgeCount++;
|
||||
if (self.navigator?.setAppBadge) self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||
|
||||
// Update app badge (supported on Android Chrome and some desktop)
|
||||
if (navigator.setAppBadge) {
|
||||
navigator.setAppBadge(badgeCount).catch(() => {});
|
||||
}
|
||||
return self.registration.showNotification(data.title || 'New Message', {
|
||||
body: data.body || '',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192-maskable.png',
|
||||
data: { url: data.url || '/' },
|
||||
tag: data.groupId ? `rosterchirp-group-${data.groupId}` : 'rosterchirp-message',
|
||||
renotify: true,
|
||||
vibrate: [200, 100, 200],
|
||||
});
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'New Message', {
|
||||
body: data.body || '',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { url: data.url || '/' },
|
||||
tag: 'teamchat-message', // replaces previous notification instead of stacking
|
||||
renotify: true, // still vibrate/sound even if replacing
|
||||
})
|
||||
);
|
||||
// ── Push handler ──────────────────────────────────────────────────────────────
|
||||
// Unified handler — always uses event.waitUntil so the mobile OS does not
|
||||
// terminate the SW before the notification is shown. Parses event.data
|
||||
// directly (fast, reliable) rather than delegating to the Firebase SDK's
|
||||
// internal push listener, which can be killed before it finishes on Android.
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push received, hasData:', !!event.data);
|
||||
|
||||
event.waitUntil((async () => {
|
||||
try {
|
||||
let payload = null;
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
console.log('[SW] Push data:', JSON.stringify({ notification: payload.notification, data: payload.data }));
|
||||
} catch (e) {
|
||||
console.warn('[SW] Push data not JSON:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
const n = payload.notification || {};
|
||||
const d = payload.data || {};
|
||||
await showRosterChirpNotification({
|
||||
title: n.title || d.title || 'New Message',
|
||||
body: n.body || d.body || '',
|
||||
url: d.url || '/',
|
||||
groupId: d.groupId || '',
|
||||
});
|
||||
} else {
|
||||
// Ghost push — keep SW alive and show a generic notification
|
||||
await self.registration.showNotification('RosterChirp', {
|
||||
body: 'You have a new message.',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192-maskable.png',
|
||||
tag: 'rosterchirp-fallback',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SW] Push handler error:', e);
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
// ── Notification click ────────────────────────────────────────────────────────
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
badgeCount = 0;
|
||||
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
||||
if (self.navigator?.clearAppBadge) self.navigator.clearAppBadge().catch(() => {});
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
const url = event.notification.data?.url || '/';
|
||||
@@ -71,10 +123,20 @@ self.addEventListener('notificationclick', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// Clear badge when user opens the app
|
||||
// ── Badge control messages from main thread ───────────────────────────────────
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'CLEAR_BADGE') {
|
||||
badgeCount = 0;
|
||||
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
||||
if (self.navigator?.clearAppBadge) self.navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
if (event.data?.type === 'SET_BADGE') {
|
||||
badgeCount = event.data.count || 0;
|
||||
if (self.navigator?.setAppBadge) {
|
||||
if (badgeCount > 0) {
|
||||
self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||
} else {
|
||||
self.navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext.jsx';
|
||||
import { SocketProvider } from './contexts/SocketContext.jsx';
|
||||
@@ -6,6 +7,50 @@ import Login from './pages/Login.jsx';
|
||||
import Chat from './pages/Chat.jsx';
|
||||
import ChangePassword from './pages/ChangePassword.jsx';
|
||||
|
||||
// ── iOS "Add to Home Screen" banner ───────────────────────────────────────────
|
||||
// iOS Safari does not fire beforeinstallprompt. Push notifications require the
|
||||
// app to be installed as a PWA. This banner is shown to any iOS Safari user who
|
||||
// has not yet added the app to their Home Screen.
|
||||
const IOS_BANNER_KEY = 'rc_ios_install_dismissed';
|
||||
|
||||
function IOSInstallBanner() {
|
||||
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
const isStandalone = window.navigator.standalone === true;
|
||||
const [dismissed, setDismissed] = useState(() => localStorage.getItem(IOS_BANNER_KEY) === '1');
|
||||
|
||||
if (!isIOS || isStandalone || dismissed) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(IOS_BANNER_KEY, '1');
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 9999,
|
||||
background: 'var(--primary, #1a73e8)', color: '#fff',
|
||||
padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12,
|
||||
boxShadow: '0 -2px 12px rgba(0,0,0,0.25)',
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 2 }}>Add to Home Screen</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.4, opacity: 0.9 }}>
|
||||
To receive push notifications, tap the{' '}
|
||||
<svg style={{ display: 'inline', verticalAlign: 'middle', margin: '0 2px' }} width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
|
||||
<polyline points="16 6 12 2 8 6"/>
|
||||
<line x1="12" y1="2" x2="12" y2="15"/>
|
||||
</svg>
|
||||
{' '}Share button, then select <strong>"Add to Home Screen"</strong>.
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={dismiss} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#fff', padding: 4, flexShrink: 0, opacity: 0.9 }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading, mustChangePassword } = useAuth();
|
||||
if (loading) return (
|
||||
@@ -20,25 +65,38 @@ function ProtectedRoute({ children }) {
|
||||
|
||||
function AuthRoute({ children }) {
|
||||
const { user, loading, mustChangePassword } = useAuth();
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
if (loading) return null;
|
||||
if (user && !mustChangePassword) return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function RestoreTheme() {
|
||||
const saved = localStorage.getItem('rosterchirp-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<AuthRoute><Login /></AuthRoute>} />
|
||||
<Route path="/change-password" element={<ChangePassword />} />
|
||||
<Route path="/" element={<ProtectedRoute><Chat /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
<IOSInstallBanner />
|
||||
<Routes>
|
||||
{/* All routes go through jama auth */}
|
||||
<Route path="/*" element={
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<AuthRoute><Login /></AuthRoute>} />
|
||||
<Route path="/change-password" element={<ChangePassword />} />
|
||||
<Route path="/" element={<ProtectedRoute><RestoreTheme /><Chat /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
} />
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
89
frontend/src/components/AboutModal.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
const CLAUDE_URL = 'https://claude.ai';
|
||||
|
||||
// Render "Built With" value — each token+separator is a nowrap unit; the flex
|
||||
// container wraps between tokens. Using display:flex (not inline) ensures Firefox
|
||||
// and Safari honour the wrap at the flex-item level rather than computing the
|
||||
// min-content width as the full un-broken string (which suppresses wrapping).
|
||||
function BuiltWithValue({ value }) {
|
||||
if (!value) return null;
|
||||
const parts = value.split('·').map(s => s.trim());
|
||||
return (
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', width: '100%' }}>
|
||||
{parts.map((part, i) => (
|
||||
<span key={part} style={{ whiteSpace: 'nowrap' }}>
|
||||
{part === 'Claude.ai'
|
||||
? <a href={CLAUDE_URL} target="_blank" rel="noreferrer" className="about-link">{part}</a>
|
||||
: part}
|
||||
{i < parts.length - 1 && <span style={{ margin: '0 4px', color: 'var(--text-tertiary)' }}>·</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AboutModal({ onClose }) {
|
||||
const [about, setAbout] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/about')
|
||||
.then(r => r.json())
|
||||
.then(({ about }) => setAbout(about))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Always use the original app identity — not the user-customised settings name/logo
|
||||
const appName = about?.default_app_name || 'rosterchirp';
|
||||
const logoSrc = about?.default_logo || '/icons/rosterchirp.png';
|
||||
const version = about?.version || '';
|
||||
const a = about || {};
|
||||
|
||||
const rows = [
|
||||
{ label: 'Version', value: version },
|
||||
{ label: 'Built With', value: a.built_with, builtWith: true },
|
||||
{ label: 'Developer', value: a.developer },
|
||||
{ label: 'License', value: a.license, link: a.license_url },
|
||||
].filter(r => r.value);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal about-modal">
|
||||
<button className="btn-icon about-close" 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 className="about-hero">
|
||||
<img src={logoSrc} alt={appName} className="about-logo" />
|
||||
<h1 className="about-appname">{appName}</h1>
|
||||
<p className="about-tagline">just another messaging app</p>
|
||||
</div>
|
||||
|
||||
{about ? (
|
||||
<>
|
||||
<div className="about-table">
|
||||
{rows.map(({ label, value, builtWith, link }) => (
|
||||
<div className="about-row" key={label}>
|
||||
<span className="about-label">{label}</span>
|
||||
<span className="about-value">
|
||||
{builtWith
|
||||
? <BuiltWithValue value={value} />
|
||||
: link
|
||||
? <a href={link} target="_blank" rel="noreferrer" className="about-link">{value}</a>
|
||||
: value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{a.description && <p className="about-footer">{a.description}</p>}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-center" style={{ padding: 24 }}><div className="spinner" /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
434
frontend/src/components/AddChildAliasModal.jsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export default function AddChildAliasModal({ features = {}, onClose }) {
|
||||
const toast = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
const loginType = features.loginType || 'guardian_only';
|
||||
const isMixedAge = loginType === 'mixed_age';
|
||||
|
||||
// ── Guardian-only state (alias form) ──────────────────────────────────────
|
||||
const [aliases, setAliases] = useState([]);
|
||||
const [editingAlias, setEditingAlias] = useState(null);
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||
const [avatarFile, setAvatarFile] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// ── Mixed-age state (real minor users) ────────────────────────────────────
|
||||
const [minorPlayers, setMinorPlayers] = useState([]); // available + already-mine
|
||||
const [selectedMinorId, setSelectedMinorId] = useState('');
|
||||
const [childDob, setChildDob] = useState('');
|
||||
const [addingMinor, setAddingMinor] = useState(false);
|
||||
|
||||
// ── Partner state (shared) ────────────────────────────────────────────────
|
||||
const [partner, setPartner] = useState(null);
|
||||
const [selectedPartnerId, setSelectedPartnerId] = useState('');
|
||||
const [respondSeparately, setRespondSeparately] = useState(false);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [savingPartner, setSavingPartner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loads = [api.getPartner(), api.searchUsers('')];
|
||||
if (isMixedAge) {
|
||||
loads.push(api.getMinorPlayers());
|
||||
} else {
|
||||
loads.push(api.getAliases());
|
||||
}
|
||||
Promise.all(loads).then(([partnerRes, usersRes, thirdRes]) => {
|
||||
const p = partnerRes.partner || null;
|
||||
setPartner(p);
|
||||
setSelectedPartnerId(p?.id?.toString() || '');
|
||||
setRespondSeparately(p?.respond_separately || false);
|
||||
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id && !u.is_default_admin));
|
||||
if (isMixedAge) {
|
||||
setMinorPlayers(thirdRes.users || []);
|
||||
} else {
|
||||
setAliases(thirdRes.aliases || []);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [isMixedAge]);
|
||||
|
||||
// Pre-populate DOB when a minor is selected from the dropdown
|
||||
useEffect(() => {
|
||||
if (!selectedMinorId) { setChildDob(''); return; }
|
||||
const minor = availableMinors.find(u => u.id === parseInt(selectedMinorId));
|
||||
setChildDob(minor?.date_of_birth ? minor.date_of_birth.slice(0, 10) : '');
|
||||
}, [selectedMinorId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingAlias(null);
|
||||
setForm({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||
setAvatarFile(null);
|
||||
};
|
||||
|
||||
const lbl = (text, required) => (
|
||||
<label className="text-sm" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
|
||||
{text}{required && <span style={{ color: 'var(--error)', marginLeft: 2 }}>*</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
// ── Partner handlers ──────────────────────────────────────────────────────
|
||||
const handleSavePartner = async () => {
|
||||
setSavingPartner(true);
|
||||
try {
|
||||
if (!selectedPartnerId) {
|
||||
await api.removePartner();
|
||||
setPartner(null);
|
||||
setRespondSeparately(false);
|
||||
if (!isMixedAge) {
|
||||
const { aliases: fresh } = await api.getAliases();
|
||||
setAliases(fresh || []);
|
||||
resetForm();
|
||||
} else {
|
||||
const { users: fresh } = await api.getMinorPlayers();
|
||||
setMinorPlayers(fresh || []);
|
||||
}
|
||||
toast('Spouse/Partner/Co-Parent removed', 'success');
|
||||
} else {
|
||||
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately);
|
||||
setPartner(p);
|
||||
setRespondSeparately(p?.respond_separately || false);
|
||||
if (!isMixedAge) {
|
||||
const { aliases: fresh } = await api.getAliases();
|
||||
setAliases(fresh || []);
|
||||
}
|
||||
toast('Spouse/Partner/Co-Parent saved', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setSavingPartner(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Guardian-only alias handlers ──────────────────────────────────────────
|
||||
const handleSelectAlias = (a) => {
|
||||
if (editingAlias?.id === a.id) { resetForm(); return; }
|
||||
setEditingAlias(a);
|
||||
setForm({
|
||||
firstName: a.first_name || '',
|
||||
lastName: a.last_name || '',
|
||||
dob: a.date_of_birth ? a.date_of_birth.slice(0, 10) : '',
|
||||
phone: a.phone || '',
|
||||
email: a.email || '',
|
||||
});
|
||||
setAvatarFile(null);
|
||||
};
|
||||
|
||||
const handleSaveAlias = async () => {
|
||||
if (!form.firstName.trim() || !form.lastName.trim())
|
||||
return toast('First and last name required', 'error');
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingAlias) {
|
||||
await api.updateAlias(editingAlias.id, {
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
dateOfBirth: form.dob || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
});
|
||||
if (avatarFile) await api.uploadAliasAvatar(editingAlias.id, avatarFile);
|
||||
toast('Child alias updated', 'success');
|
||||
} else {
|
||||
const { alias } = await api.createAlias({
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
dateOfBirth: form.dob || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
});
|
||||
if (avatarFile) await api.uploadAliasAvatar(alias.id, avatarFile);
|
||||
toast('Child alias added', 'success');
|
||||
}
|
||||
const { aliases: fresh } = await api.getAliases();
|
||||
setAliases(fresh || []);
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAlias = async (e, aliasId) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.deleteAlias(aliasId);
|
||||
setAliases(prev => prev.filter(a => a.id !== aliasId));
|
||||
if (editingAlias?.id === aliasId) resetForm();
|
||||
toast('Child alias removed', 'success');
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
};
|
||||
|
||||
// ── Mixed-age minor handlers ──────────────────────────────────────────────
|
||||
const myMinors = minorPlayers.filter(u => u.guardian_user_id === currentUser?.id);
|
||||
const availableMinors = minorPlayers.filter(u => !u.guardian_user_id);
|
||||
|
||||
const handleAddMinor = async () => {
|
||||
if (!selectedMinorId) return;
|
||||
if (!childDob.trim()) return toast('Date of Birth is required', 'error');
|
||||
setAddingMinor(true);
|
||||
try {
|
||||
await api.addGuardianChild(parseInt(selectedMinorId), childDob.trim());
|
||||
const { users: fresh } = await api.getMinorPlayers();
|
||||
setMinorPlayers(fresh || []);
|
||||
setSelectedMinorId('');
|
||||
setChildDob('');
|
||||
toast('Child added and account activated', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setAddingMinor(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMinor = async (e, minorId) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.removeGuardianChild(minorId);
|
||||
const { users: fresh } = await api.getMinorPlayers();
|
||||
setMinorPlayers(fresh || []);
|
||||
toast('Child removed', 'success');
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Family Manager</h2>
|
||||
<button className="btn-icon" onClick={onClose} aria-label="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Spouse/Partner/Co-Parent section */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{lbl('Spouse/Partner/Co-Parent')}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
value={selectedPartnerId}
|
||||
onChange={e => setSelectedPartnerId(e.target.value)}
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{allUsers.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.display_name || u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSavePartner}
|
||||
disabled={savingPartner}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{savingPartner ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, cursor: 'pointer', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={respondSeparately}
|
||||
onChange={e => setRespondSeparately(e.target.checked)}
|
||||
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: 'var(--primary)' }}
|
||||
/>
|
||||
Respond separately to events
|
||||
</label>
|
||||
{partner && (
|
||||
<div className="text-sm" style={{ color: 'var(--text-secondary)', marginTop: 6 }}>
|
||||
Linked with {partner.display_name || partner.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Mixed Age: link real minor users ── */}
|
||||
{isMixedAge && (
|
||||
<>
|
||||
{/* Current children list */}
|
||||
{myMinors.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
Your Children
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||
{myMinors.map((u, i) => (
|
||||
<div
|
||||
key={u.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 12px',
|
||||
borderBottom: i < myMinors.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{u.first_name} {u.last_name}</span>
|
||||
{u.date_of_birth && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
{u.date_of_birth.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={e => handleRemoveMinor(e, u.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
|
||||
aria-label="Remove"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add minor from players group */}
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
Add Child
|
||||
</div>
|
||||
<select
|
||||
className="input"
|
||||
style={{ marginBottom: 8 }}
|
||||
value={selectedMinorId}
|
||||
onChange={e => setSelectedMinorId(e.target.value)}
|
||||
>
|
||||
<option value="">— Select a player —</option>
|
||||
{availableMinors.map(u => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.first_name} {u.last_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{lbl('Date of Birth', true)}
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="YYYY-MM-DD"
|
||||
value={childDob}
|
||||
onChange={e => setChildDob(e.target.value)}
|
||||
autoComplete="off"
|
||||
style={childDob === '' && selectedMinorId ? { borderColor: 'var(--error)' } : {}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleAddMinor}
|
||||
disabled={addingMinor || !selectedMinorId || !childDob.trim()}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{addingMinor ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
{availableMinors.length === 0 && myMinors.length === 0 && (
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||
No minor players available to link.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Guardian Only: alias form ── */}
|
||||
{!isMixedAge && (
|
||||
<>
|
||||
{/* Existing aliases list */}
|
||||
{aliases.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
Your Children — click to edit
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||
{aliases.map((a, i) => (
|
||||
<div
|
||||
key={a.id}
|
||||
onClick={() => handleSelectAlias(a)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 12px', cursor: 'pointer',
|
||||
borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
background: editingAlias?.id === a.id ? 'var(--primary-light)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, fontSize: 14, fontWeight: editingAlias?.id === a.id ? 600 : 400 }}>
|
||||
{a.first_name} {a.last_name}
|
||||
</span>
|
||||
{a.date_of_birth && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
{a.date_of_birth.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={e => handleDeleteAlias(e, a.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
|
||||
aria-label="Remove"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form section label */}
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>
|
||||
{editingAlias
|
||||
? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}`
|
||||
: 'Add Child'}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
{lbl('First Name', true)}
|
||||
<input className="input" value={form.firstName} onChange={set('firstName')}
|
||||
autoComplete="off" autoCapitalize="words" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Last Name', true)}
|
||||
<input className="input" value={form.lastName} onChange={set('lastName')}
|
||||
autoComplete="off" autoCapitalize="words" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Date of Birth')}
|
||||
<input className="input" placeholder="YYYY-MM-DD" value={form.dob} onChange={set('dob')}
|
||||
autoComplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Phone')}
|
||||
<input className="input" type="tel" value={form.phone} onChange={set('phone')}
|
||||
autoComplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Email (optional)')}
|
||||
<input className="input" type="email" value={form.email} onChange={set('email')}
|
||||
autoComplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Avatar (optional)')}
|
||||
<input type="file" accept="image/*"
|
||||
onChange={e => setAvatarFile(e.target.files?.[0] || null)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
{editingAlias && (
|
||||
<button className="btn btn-secondary" onClick={resetForm}>Cancel Edit</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleSaveAlias} disabled={saving}>
|
||||
{saving ? 'Saving…' : editingAlias ? 'Update Alias' : 'Add Alias'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
export default function Avatar({ user, size = 'md', className = '' }) {
|
||||
if (!user) return null;
|
||||
|
||||
if (user.is_default_admin) {
|
||||
return (
|
||||
<div className={`avatar avatar-${size} ${className}`}>
|
||||
<img src="/avatar/admin.png" alt="Admin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const initials = (() => {
|
||||
const name = user.display_name || user.name || '';
|
||||
const parts = name.trim().split(' ').filter(Boolean);
|
||||
|
||||
556
frontend/src/components/BrandingModal.jsx
Normal file
@@ -0,0 +1,556 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
const DEFAULT_TITLE_COLOR = '#1a73e8'; // light mode default
|
||||
const DEFAULT_TITLE_DARK_COLOR = '#60a5fa'; // dark mode default (lighter blue readable on dark bg)
|
||||
const DEFAULT_PUBLIC_COLOR = '#1a73e8';
|
||||
const DEFAULT_DM_COLOR = '#a142f4';
|
||||
|
||||
const COLOUR_SUGGESTIONS = [
|
||||
'#1a73e8', '#a142f4', '#e53935', '#fa7b17', '#fdd835', '#34a853',
|
||||
];
|
||||
|
||||
// ── Title Colour Row — one row per mode ──────────────────────────────────────
|
||||
|
||||
function TitleColourRow({ bgColor, bgLabel, textColor, onChange }) {
|
||||
const [mode, setMode] = useState('idle'); // 'idle' | 'custom'
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{/* Preview box */}
|
||||
<div style={{
|
||||
background: bgColor, borderRadius: 8, padding: '0 14px',
|
||||
height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: '1px solid var(--border)', minWidth: 110, flexShrink: 0,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<span style={{ color: textColor, fontWeight: 700, fontSize: 16 }}>
|
||||
Title
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mode === 'idle' && (
|
||||
<>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', fontFamily: 'monospace', minWidth: 64 }}>{textColor}</span>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setMode('custom')}>Custom</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'custom' && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomPicker
|
||||
initial={textColor}
|
||||
onSet={(hex) => { onChange(hex); setMode('idle'); }}
|
||||
onBack={() => setMode('idle')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Colour math helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function hexToHsv(hex) {
|
||||
const r = parseInt(hex.slice(1,3),16)/255;
|
||||
const g = parseInt(hex.slice(3,5),16)/255;
|
||||
const b = parseInt(hex.slice(5,7),16)/255;
|
||||
const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max - min;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
return { h: h * 360, s: max === 0 ? 0 : d / max, v: max };
|
||||
}
|
||||
|
||||
function hsvToHex(h, s, v) {
|
||||
h = h / 360;
|
||||
const i = Math.floor(h * 6);
|
||||
const f = h * 6 - i;
|
||||
const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
|
||||
let r, g, b;
|
||||
switch (i % 6) {
|
||||
case 0: r=v; g=t; b=p; break; case 1: r=q; g=v; b=p; break;
|
||||
case 2: r=p; g=v; b=t; break; case 3: r=p; g=q; b=v; break;
|
||||
case 4: r=t; g=p; b=v; break; default: r=v; g=p; b=q;
|
||||
}
|
||||
return '#' + [r,g,b].map(x => Math.round(x*255).toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
|
||||
function isValidHex(h) { return /^#[0-9a-fA-F]{6}$/.test(h); }
|
||||
|
||||
// ── SV (saturation/value) square ─────────────────────────────────────────────
|
||||
|
||||
function SvSquare({ hue, s, v, onChange }) {
|
||||
const canvasRef = useRef(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width, H = canvas.height;
|
||||
// White → hue gradient (left→right)
|
||||
const hGrad = ctx.createLinearGradient(0, 0, W, 0);
|
||||
hGrad.addColorStop(0, '#fff');
|
||||
hGrad.addColorStop(1, `hsl(${hue},100%,50%)`);
|
||||
ctx.fillStyle = hGrad; ctx.fillRect(0, 0, W, H);
|
||||
// Transparent → black gradient (top→bottom)
|
||||
const vGrad = ctx.createLinearGradient(0, 0, 0, H);
|
||||
vGrad.addColorStop(0, 'transparent');
|
||||
vGrad.addColorStop(1, '#000');
|
||||
ctx.fillStyle = vGrad; ctx.fillRect(0, 0, W, H);
|
||||
}, [hue]);
|
||||
|
||||
const getPos = (e, canvas) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
|
||||
const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
|
||||
return {
|
||||
s: Math.max(0, Math.min(1, cx / r.width)),
|
||||
v: Math.max(0, Math.min(1, 1 - cy / r.height)),
|
||||
};
|
||||
};
|
||||
|
||||
const handle = (e) => {
|
||||
e.preventDefault();
|
||||
const p = getPos(e, canvasRef.current);
|
||||
onChange(p.s, p.v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none' }}>
|
||||
<canvas
|
||||
ref={canvasRef} width={260} height={160}
|
||||
style={{ display: 'block', width: '100%', height: 160, borderRadius: 8, cursor: 'crosshair', border: '1px solid var(--border)' }}
|
||||
onMouseDown={e => { dragging.current = true; handle(e); }}
|
||||
onMouseMove={e => { if (dragging.current) handle(e); }}
|
||||
onMouseUp={() => { dragging.current = false; }}
|
||||
onMouseLeave={() => { dragging.current = false; }}
|
||||
onTouchStart={handle} onTouchMove={handle} />
|
||||
{/* Cursor circle */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `calc(${s * 100}% - 7px)`,
|
||||
top: `calc(${(1 - v) * 100}% - 7px)`,
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.4)',
|
||||
pointerEvents: 'none',
|
||||
background: 'transparent',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hue bar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function HueBar({ hue, onChange }) {
|
||||
const barRef = useRef(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const handle = (e) => {
|
||||
e.preventDefault();
|
||||
const r = barRef.current.getBoundingClientRect();
|
||||
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
|
||||
onChange(Math.max(0, Math.min(360, (cx / r.width) * 360)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none', marginTop: 10 }}>
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
height: 20, borderRadius: 10,
|
||||
background: 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)',
|
||||
border: '1px solid var(--border)', cursor: 'pointer',
|
||||
}}
|
||||
onMouseDown={e => { dragging.current = true; handle(e); }}
|
||||
onMouseMove={e => { if (dragging.current) handle(e); }}
|
||||
onMouseUp={() => { dragging.current = false; }}
|
||||
onMouseLeave={() => { dragging.current = false; }}
|
||||
onTouchStart={handle} onTouchMove={handle} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `calc(${(hue / 360) * 100}% - 9px)`,
|
||||
top: -2, width: 18, height: 24, borderRadius: 4,
|
||||
background: `hsl(${hue},100%,50%)`,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom HSV picker ─────────────────────────────────────────────────────────
|
||||
|
||||
function CustomPicker({ initial, onSet, onBack }) {
|
||||
const { h: ih, s: is, v: iv } = hexToHsv(initial);
|
||||
const [hue, setHue] = useState(ih);
|
||||
const [sat, setSat] = useState(is);
|
||||
const [val, setVal] = useState(iv);
|
||||
const [hexInput, setHexInput] = useState(initial);
|
||||
const [hexError, setHexError] = useState(false);
|
||||
|
||||
const current = hsvToHex(hue, sat, val);
|
||||
|
||||
// Sync hex input when sliders change
|
||||
useEffect(() => { setHexInput(current); setHexError(false); }, [current]);
|
||||
|
||||
const handleHexInput = (e) => {
|
||||
const v = e.target.value;
|
||||
setHexInput(v);
|
||||
if (isValidHex(v)) {
|
||||
const { h, s, v: bv } = hexToHsv(v);
|
||||
setHue(h); setSat(s); setVal(bv);
|
||||
setHexError(false);
|
||||
} else {
|
||||
setHexError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SvSquare hue={hue} s={sat} v={val} onChange={(s, v) => { setSat(s); setVal(v); }} />
|
||||
<HueBar hue={hue} onChange={setHue} />
|
||||
|
||||
{/* Preview + hex input */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 8, background: current,
|
||||
border: '2px solid var(--border)', flexShrink: 0,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
|
||||
}} />
|
||||
<input
|
||||
value={hexInput}
|
||||
onChange={handleHexInput}
|
||||
maxLength={7}
|
||||
style={{
|
||||
fontFamily: 'monospace', fontSize: 14,
|
||||
padding: '6px 10px', borderRadius: 8,
|
||||
border: `1px solid ${hexError ? '#e53935' : 'var(--border)'}`,
|
||||
width: 110, background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
placeholder="#000000" autoComplete="off" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Chosen colour</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 2 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onSet(current)} disabled={hexError}>
|
||||
Set
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ColourPicker card ─────────────────────────────────────────────────────────
|
||||
|
||||
function ColourPicker({ label, value, onChange, preview }) {
|
||||
const [mode, setMode] = useState('suggestions'); // 'suggestions' | 'custom'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="settings-section-label">{label}</div>
|
||||
|
||||
{/* Current colour preview */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
{preview
|
||||
? preview(value)
|
||||
: <div style={{ width: 36, height: 36, borderRadius: 8, background: value, border: '2px solid var(--border)', flexShrink: 0 }} />
|
||||
}
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{value}</span>
|
||||
</div>
|
||||
|
||||
{mode === 'suggestions' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
{COLOUR_SUGGESTIONS.map(hex => (
|
||||
<button
|
||||
key={hex}
|
||||
onClick={() => onChange(hex)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: hex, border: hex === value ? '3px solid var(--text-primary)' : '2px solid var(--border)',
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
boxShadow: hex === value ? '0 0 0 2px var(--surface), 0 0 0 4px var(--text-primary)' : 'none',
|
||||
transition: 'box-shadow 0.15s',
|
||||
}}
|
||||
title={hex} />
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setMode('custom')}>
|
||||
Custom
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'custom' && (
|
||||
<CustomPicker
|
||||
initial={value}
|
||||
onSet={(hex) => { onChange(hex); setMode('suggestions'); }}
|
||||
onBack={() => setMode('suggestions')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BrandingModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [tab, setTab] = useState('general'); // 'general' | 'colours'
|
||||
const [settings, setSettings] = useState({});
|
||||
const [appName, setAppName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
const [colourTitle, setColourTitle] = useState(DEFAULT_TITLE_COLOR);
|
||||
const [colourTitleDark, setColourTitleDark] = useState(DEFAULT_TITLE_DARK_COLOR);
|
||||
const [colourPublic, setColourPublic] = useState(DEFAULT_PUBLIC_COLOR);
|
||||
const [colourDm, setColourDm] = useState(DEFAULT_DM_COLOR);
|
||||
const [savingColours, setSavingColours] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setSettings(settings);
|
||||
setAppName(settings.app_name || 'rosterchirp');
|
||||
setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR);
|
||||
setColourTitleDark(settings.color_title_dark || DEFAULT_TITLE_DARK_COLOR);
|
||||
setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
|
||||
setColourDm(settings.color_avatar_dm || DEFAULT_DM_COLOR);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const notifySidebarRefresh = () => window.dispatchEvent(new Event('rosterchirp:settings-changed'));
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!appName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.updateAppName(appName.trim());
|
||||
setSettings(prev => ({ ...prev, app_name: appName.trim() }));
|
||||
toast('App name updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 1024 * 1024) return toast('Logo must be less than 1MB', 'error');
|
||||
try {
|
||||
const { logoUrl } = await api.uploadLogo(file);
|
||||
setSettings(prev => ({ ...prev, logo_url: logoUrl }));
|
||||
toast('Logo updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveColours = async () => {
|
||||
setSavingColours(true);
|
||||
try {
|
||||
await api.updateColors({
|
||||
colorTitle: colourTitle,
|
||||
colorTitleDark: colourTitleDark,
|
||||
colorAvatarPublic: colourPublic,
|
||||
colorAvatarDm: colourDm,
|
||||
});
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
color_title: colourTitle,
|
||||
color_title_dark: colourTitleDark,
|
||||
color_avatar_public: colourPublic,
|
||||
color_avatar_dm: colourDm,
|
||||
}));
|
||||
toast('Colours updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setSavingColours(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.resetSettings();
|
||||
const { settings: fresh } = await api.getSettings();
|
||||
setSettings(fresh);
|
||||
setAppName(fresh.app_name || 'rosterchirp');
|
||||
setColourTitle(DEFAULT_TITLE_COLOR);
|
||||
setColourTitleDark(DEFAULT_TITLE_DARK_COLOR);
|
||||
setColourPublic(DEFAULT_PUBLIC_COLOR);
|
||||
setColourDm(DEFAULT_DM_COLOR);
|
||||
toast('Settings reset to defaults', 'success');
|
||||
notifySidebarRefresh();
|
||||
setShowResetConfirm(false);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 460 }}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Branding</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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2" style={{ marginBottom: 24 }}>
|
||||
<button className={`btn btn-sm ${tab === 'general' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('general')}>General</button>
|
||||
<button className={`btn btn-sm ${tab === 'colours' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('colours')}>Colours</button>
|
||||
</div>
|
||||
|
||||
{tab === 'general' && (
|
||||
<>
|
||||
{/* App Logo */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">App Logo</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: 16, background: 'var(--background)',
|
||||
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||
}}>
|
||||
<img src={settings.logo_url || '/icons/rosterchirp.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||
Upload Logo
|
||||
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||
</label>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>
|
||||
Square format, max 1MB. Used in sidebar, login page and browser tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App Name */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">App Name</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={appName}
|
||||
maxLength={16}
|
||||
onChange={e => setAppName(e.target.value)} autoComplete="off" onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>{loading ? '...' : 'Save'}</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>
|
||||
Maximum 16 characters including spaces. Currently {appName.length}/16.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reset */}
|
||||
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
|
||||
<div className="settings-section-label">Reset</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
{!showResetConfirm ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>Reset All to Defaults</button>
|
||||
) : (
|
||||
<div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '12px 14px' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
|
||||
This will reset the app name, logo and all colours to their install defaults. This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
|
||||
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{settings.app_version && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>v{settings.app_version}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.pw_reset_active === 'true' && (
|
||||
<div className="warning-banner">
|
||||
<span>⚠️</span>
|
||||
<span><strong>ADMPW_RESET is active.</strong> The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'colours' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div>
|
||||
<div className="settings-section-label">App Title Colour</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 }}>
|
||||
<TitleColourRow
|
||||
bgColor="#f1f3f4"
|
||||
bgLabel="Light mode"
|
||||
textColor={colourTitle}
|
||||
onChange={setColourTitle} />
|
||||
<TitleColourRow
|
||||
bgColor="#13131f"
|
||||
bgLabel="Dark mode"
|
||||
textColor={colourTitleDark}
|
||||
onChange={setColourTitleDark} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
|
||||
<ColourPicker
|
||||
label="Public Message Avatar Colour"
|
||||
value={colourPublic}
|
||||
onChange={setColourPublic}
|
||||
preview={(val) => (
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: '50%', background: val,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
|
||||
}}>A</div>
|
||||
)} />
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
|
||||
<ColourPicker
|
||||
label="Direct Message Avatar Colour"
|
||||
value={colourDm}
|
||||
onChange={setColourDm}
|
||||
preview={(val) => (
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: '50%', background: val,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
|
||||
}}>B</div>
|
||||
)} />
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
|
||||
<button className="btn btn-primary" onClick={handleSaveColours} disabled={savingColours}>
|
||||
{savingColours ? 'Saving...' : 'Save Colours'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
background: var(--surface-variant);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-window.empty {
|
||||
@@ -67,6 +69,13 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Real name in brackets in DM header */
|
||||
.chat-header-real-name {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.readonly-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
@@ -79,11 +88,24 @@
|
||||
/* Messages */
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
min-height: 0; /* critical: allows flex child to shrink below content size */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
scroll-padding-bottom: 0;
|
||||
overscroll-behavior: contain;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Cap message width and centre on wide screens */
|
||||
.messages-container > * {
|
||||
max-width: 1024px;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
|
||||
@@ -1,42 +1,128 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import Message from './Message.jsx';
|
||||
import MessageInput from './MessageInput.jsx';
|
||||
import GroupInfoModal from './GroupInfoModal.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import './ChatWindow.css';
|
||||
import GroupInfoModal from './GroupInfoModal.jsx';
|
||||
|
||||
export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
// Must match Avatar.jsx and Sidebar.jsx exactly so header colours are consistent with message avatars
|
||||
const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
|
||||
function nameToColor(name) {
|
||||
return AVATAR_COLORS[(name || '').charCodeAt(0) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
// Composite avatar layouts for the 40×40 chat header icon
|
||||
const COMPOSITE_LAYOUTS_SM = {
|
||||
1: [{ top: 4, left: 4, size: 32 }],
|
||||
2: [
|
||||
{ top: 10, left: 1, size: 19 },
|
||||
{ top: 10, right: 1, size: 19 },
|
||||
],
|
||||
3: [
|
||||
{ top: 2, left: 2, size: 17 },
|
||||
{ top: 2, right: 2, size: 17 },
|
||||
{ bottom: 2, left: 11, size: 17 },
|
||||
],
|
||||
4: [
|
||||
{ top: 1, left: 1, size: 18 },
|
||||
{ top: 1, right: 1, size: 18 },
|
||||
{ bottom: 1, left: 1, size: 18 },
|
||||
{ bottom: 1, right: 1, size: 18 },
|
||||
],
|
||||
};
|
||||
|
||||
function GroupAvatarCompositeSm({ memberPreviews }) {
|
||||
const members = (memberPreviews || []).slice(0, 4);
|
||||
const positions = COMPOSITE_LAYOUTS_SM[members.length];
|
||||
if (!positions) return null;
|
||||
return (
|
||||
<div className="group-icon-sm" style={{ background: 'transparent', position: 'relative', padding: 0, overflow: 'visible' }}>
|
||||
{members.map((m, i) => {
|
||||
const pos = positions[i];
|
||||
const base = {
|
||||
position: 'absolute',
|
||||
width: pos.size, height: pos.size,
|
||||
borderRadius: '50%',
|
||||
boxSizing: 'border-box',
|
||||
border: '2px solid var(--surface)',
|
||||
...(pos.top !== undefined ? { top: pos.top } : {}),
|
||||
...(pos.bottom !== undefined ? { bottom: pos.bottom } : {}),
|
||||
...(pos.left !== undefined ? { left: pos.left } : {}),
|
||||
...(pos.right !== undefined ? { right: pos.right } : {}),
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
};
|
||||
if (m.avatar) return <img key={m.id} src={m.avatar} alt={m.name} style={{ ...base, objectFit: 'cover' }} />;
|
||||
return (
|
||||
<div key={m.id} style={{ ...base, background: nameToColor(m.name), display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: Math.round(pos.size * 0.42), fontWeight: 700, color: 'white' }}>
|
||||
{(m.name || '')[0]?.toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onMessageDeleted, onHasTextChange, onlineUserIds = new Set() }) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { socket } = useSocket();
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
||||
const [typing, setTyping] = useState([]);
|
||||
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
||||
const [avatarColors, setAvatarColors] = useState({ public: '#1a73e8', dm: '#a142f4' });
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesTopRef = useRef(null);
|
||||
const messagesContainerRef = useRef(null);
|
||||
const typingTimers = useRef({});
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||
}).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
|
||||
window.addEventListener('teamchat:settings-changed', handler);
|
||||
return () => window.removeEventListener('teamchat:settings-changed', handler);
|
||||
const onResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('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(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
|
||||
}).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => {
|
||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
|
||||
}).catch(() => {});
|
||||
window.addEventListener('rosterchirp:settings-updated', handler);
|
||||
window.addEventListener('rosterchirp:settings-changed', handler);
|
||||
return () => {
|
||||
window.removeEventListener('rosterchirp:settings-updated', handler);
|
||||
window.removeEventListener('rosterchirp:settings-changed', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) { setMessages([]); return; }
|
||||
setMessages([]);
|
||||
@@ -65,26 +151,43 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
setTimeout(() => scrollToBottom(true), 50);
|
||||
};
|
||||
|
||||
const handleDeleted = ({ messageId }) => {
|
||||
setMessages(prev => prev.filter(m => m.id !== messageId));
|
||||
const handleDeleted = ({ messageId, groupId }) => {
|
||||
setMessages(prev => {
|
||||
const updated = prev.map(m =>
|
||||
m.id === messageId ? { ...m, is_deleted: 1, content: null, image_url: null } : m
|
||||
);
|
||||
// Notify Chat.jsx so the sidebar preview updates immediately — pass the
|
||||
// post-delete messages so it can derive the new last non-deleted message
|
||||
// without an extra API call.
|
||||
onMessageDeleted?.({ groupId, messages: updated });
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReaction = ({ messageId, reactions }) => {
|
||||
setMessages(prev => prev.map(m => m.id === messageId ? { ...m, reactions } : m));
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === messageId ? { ...m, reactions } : m
|
||||
));
|
||||
};
|
||||
|
||||
const handleTypingStart = ({ userId: tid, user: tu }) => {
|
||||
if (tid === user.id) return;
|
||||
setTyping(prev => prev.find(t => t.userId === tid) ? prev : [...prev, { userId: tid, name: tu?.display_name || tu?.name || 'Someone' }]);
|
||||
if (tid === currentUser?.id) return;
|
||||
setTyping(prev => prev.find(t => t.userId === tid)
|
||||
? prev
|
||||
: [...prev, { userId: tid, name: tu?.display_name || tu?.name || 'Someone' }]);
|
||||
if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]);
|
||||
typingTimers.current[tid] = setTimeout(() => {
|
||||
setTyping(prev => prev.filter(t => t.userId !== tid));
|
||||
}, 3000);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const handleTypingStop = ({ userId: tid }) => {
|
||||
clearTimeout(typingTimers.current[tid]);
|
||||
setTyping(prev => prev.filter(t => t.userId !== tid));
|
||||
if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]);
|
||||
};
|
||||
|
||||
const handleGroupUpdated = (updatedGroup) => {
|
||||
if (updatedGroup.id === group.id) onGroupUpdated?.();
|
||||
};
|
||||
|
||||
socket.on('message:new', handleNew);
|
||||
@@ -92,6 +195,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
socket.on('reaction:updated', handleReaction);
|
||||
socket.on('typing:start', handleTypingStart);
|
||||
socket.on('typing:stop', handleTypingStop);
|
||||
socket.on('group:updated', handleGroupUpdated);
|
||||
|
||||
return () => {
|
||||
socket.off('message:new', handleNew);
|
||||
@@ -99,154 +203,217 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
socket.off('reaction:updated', handleReaction);
|
||||
socket.off('typing:start', handleTypingStart);
|
||||
socket.off('typing:stop', handleTypingStop);
|
||||
socket.off('group:updated', handleGroupUpdated);
|
||||
};
|
||||
}, [socket, group?.id, user.id]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!messages.length) return;
|
||||
const oldest = messages[0];
|
||||
const { messages: older } = await api.getMessages(group.id, oldest.id);
|
||||
setMessages(prev => [...older, ...prev]);
|
||||
setHasMore(older.length >= 50);
|
||||
};
|
||||
|
||||
const handleSend = async ({ content, imageFile, linkPreview }) => {
|
||||
if (!group) return;
|
||||
const replyId = replyTo?.id;
|
||||
setReplyTo(null);
|
||||
}, [socket, group?.id, currentUser?.id]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (!hasMore || loading || messages.length === 0) return;
|
||||
const container = messagesContainerRef.current;
|
||||
const prevScrollHeight = container?.scrollHeight || 0;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (imageFile) {
|
||||
const { message } = await api.uploadImage(group.id, imageFile, { replyToId: replyId, content });
|
||||
// Add immediately to local state — don't wait for socket (it may be slow for large files)
|
||||
if (message) {
|
||||
setMessages(prev => prev.find(m => m.id === message.id) ? prev : [...prev, message]);
|
||||
setTimeout(() => scrollToBottom(true), 50);
|
||||
}
|
||||
} else {
|
||||
socket?.emit('message:send', {
|
||||
groupId: group.id, content, replyToId: replyId, linkPreview
|
||||
});
|
||||
}
|
||||
const oldest = messages[0];
|
||||
const { messages: older } = await api.getMessages(group.id, oldest.id);
|
||||
setMessages(prev => [...older, ...prev]);
|
||||
setHasMore(older.length >= 50);
|
||||
requestAnimationFrame(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight - prevScrollHeight;
|
||||
});
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async ({ content, imageFile, linkPreview, emojiOnly }) => {
|
||||
if ((!content?.trim() && !imageFile) || !group) return;
|
||||
const replyToId = replyTo?.id || null;
|
||||
setReplyTo(null);
|
||||
try {
|
||||
if (imageFile) {
|
||||
await api.uploadImage(group.id, imageFile, { replyToId, content: content?.trim() || '' });
|
||||
} else {
|
||||
await api.sendMessage(group.id, { content: content.trim(), replyToId, linkPreview, emojiOnly });
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message || 'Failed to send', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (msgId) => {
|
||||
try {
|
||||
await api.deleteMessage(msgId);
|
||||
} catch (e) {
|
||||
toast(e.message || 'Could not delete', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReact = async (msgId, emoji) => {
|
||||
try {
|
||||
await api.toggleReaction(msgId, emoji);
|
||||
} catch (e) {
|
||||
toast(e.message || 'Could not react', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = (msg) => {
|
||||
setReplyTo(msg);
|
||||
};
|
||||
|
||||
const handleDirectMessage = (dmGroup) => {
|
||||
onDirectMessage?.(dmGroup);
|
||||
};
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className="chat-window empty">
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" width="64" height="64">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Select a conversation</h3>
|
||||
<p>Choose from your existing chats or start a new one</p>
|
||||
<p>Choose a channel or direct message to start chatting</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isDirect = !!group.is_direct;
|
||||
const peerName = group.peer_display_name
|
||||
? <>{group.peer_display_name}<span className="chat-header-real-name"> ({group.peer_real_name})</span></>
|
||||
: group.peer_real_name || group.name;
|
||||
const isOnline = isDirect && group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-window">
|
||||
{/* Header */}
|
||||
<div className="chat-header">
|
||||
{onBack && (
|
||||
<button className="btn-icon" onClick={onBack}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
{isMobile && onBack && (
|
||||
<button className="btn-icon" onClick={onBack} style={{ marginRight: 4 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="group-icon-sm"
|
||||
style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}
|
||||
>
|
||||
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="chat-header-name">{group.name}</span>
|
||||
{group.is_readonly ? (
|
||||
<span className="readonly-badge">Read-only</span>
|
||||
) : null}
|
||||
|
||||
{isDirect && group.peer_avatar && !group.is_managed ? (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<img src={group.peer_avatar} alt={group.name} className="group-icon-sm" style={{ objectFit: 'cover', padding: 0 }} />
|
||||
{isOnline && <span className="online-dot" style={{ position: 'absolute', bottom: 1, right: 1 }} />}
|
||||
</div>
|
||||
<span className="chat-header-sub">
|
||||
{group.type === 'public' ? 'Public channel' : 'Private group'}
|
||||
</span>
|
||||
) : isDirect && !group.is_managed ? (
|
||||
// No custom avatar — use same per-user colour as Avatar.jsx and Sidebar.jsx
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="group-icon-sm" style={{ background: nameToColor(group.peer_real_name || group.name), flexShrink: 0 }}>
|
||||
{(group.peer_real_name || group.name)[0]?.toUpperCase()}
|
||||
</div>
|
||||
{isOnline && <span className="online-dot" style={{ position: 'absolute', bottom: 1, right: 1 }} />}
|
||||
</div>
|
||||
) : group.is_managed ? (
|
||||
<div className="group-icon-sm" style={{ background: avatarColors.dm, borderRadius: 8, flexShrink: 0, fontSize: 11, fontWeight: 700 }}>
|
||||
{group.is_multi_group ? 'MG' : 'UG'}
|
||||
</div>
|
||||
) : group.composite_members?.length > 0 ? (
|
||||
<GroupAvatarCompositeSm memberPreviews={group.composite_members} />
|
||||
) : (
|
||||
<div className="group-icon-sm" style={{ background: group.type === 'public' ? avatarColors.public : avatarColors.dm, flexShrink: 0 }}>
|
||||
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="chat-header-name truncate">
|
||||
{isDirect ? peerName : group.name}
|
||||
{group.is_readonly ? <span className="readonly-badge" style={{ marginLeft: 8 }}>read-only</span> : null}
|
||||
</div>
|
||||
{isDirect && <div className="chat-header-sub">Private message</div>}
|
||||
{!isDirect && group.type === 'public' && <div className="chat-header-sub">Public message</div>}
|
||||
{!isDirect && group.type === 'private' && group.is_managed && !group.is_multi_group && <div className="chat-header-sub">Private user group</div>}
|
||||
{!isDirect && group.type === 'private' && group.is_managed && group.is_multi_group && <div className="chat-header-sub">Private group</div>}
|
||||
{!isDirect && group.type === 'private' && !group.is_managed && <div className="chat-header-sub">Private group</div>}
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info">
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setShowInfo(true)}
|
||||
title="Conversation info"
|
||||
>
|
||||
{iconGroupInfo ? (
|
||||
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
<img src={iconGroupInfo} alt="info" style={{ width: 22, height: 22, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="24" height="24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="22" height="22">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="messages-container" ref={messagesTopRef}>
|
||||
<div className="messages-container" ref={messagesContainerRef}>
|
||||
{hasMore && (
|
||||
<button className="load-more-btn" onClick={loadMore}>Load older messages</button>
|
||||
<button className="load-more-btn" onClick={handleLoadMore} disabled={loading}>
|
||||
{loading ? 'Loading…' : 'Load older messages'}
|
||||
</button>
|
||||
)}
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
|
||||
{messages.map((msg, i) => {
|
||||
// Skip deleted entries when looking for the effective previous message.
|
||||
// Deleted messages render null, so they must not affect date separators
|
||||
// or avatar-grouping for the messages that follow them.
|
||||
let effectivePrev = null;
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (!messages[j].is_deleted) { effectivePrev = messages[j]; break; }
|
||||
}
|
||||
return (
|
||||
<Message
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
prevMessage={effectivePrev}
|
||||
currentUser={currentUser}
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
onReact={handleReact}
|
||||
onDirectMessage={handleDirectMessage}
|
||||
isDirect={isDirect}
|
||||
onlineUserIds={onlineUserIds} />
|
||||
);
|
||||
})}
|
||||
|
||||
{typing.length > 0 && (
|
||||
<div className="typing-indicator">
|
||||
<span>{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing</span>
|
||||
<div className="dots"><span /><span /><span /></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((msg, i) => (
|
||||
<Message
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
prevMessage={messages[i - 1]}
|
||||
currentUser={user}
|
||||
onReply={(m) => setReplyTo(m)}
|
||||
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
|
||||
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
|
||||
/>
|
||||
))}
|
||||
{typing.length > 0 && (
|
||||
<div className="typing-indicator">
|
||||
<span>{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing</span>
|
||||
<span className="dots"><span/><span/><span/></span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{(!group.is_readonly || user.role === 'admin') ? (
|
||||
<MessageInput
|
||||
group={group}
|
||||
replyTo={replyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSend={handleSend}
|
||||
onTyping={(isTyping) => {
|
||||
if (socket) {
|
||||
if (isTyping) socket.emit('typing:start', { groupId: group.id });
|
||||
else socket.emit('typing:stop', { groupId: group.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
{group.is_readonly && currentUser?.role !== 'admin' ? (
|
||||
<div className="readonly-bar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
This channel is read-only
|
||||
</div>
|
||||
) : (
|
||||
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={(isTyping) => { if (socket && group) socket.emit(isTyping ? 'typing:start' : 'typing:stop', { groupId: group.id }); }} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
{showInfo && (
|
||||
<GroupInfoModal
|
||||
group={group}
|
||||
onClose={() => setShowInfo(false)}
|
||||
onUpdated={onGroupUpdated}
|
||||
/>
|
||||
onUpdated={(updatedGroup) => { setShowInfo(false); onGroupUpdated && onGroupUpdated(updatedGroup); }}
|
||||
onBack={() => setShowInfo(false)} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
163
frontend/src/components/ColourPickerSheet.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// Shared mobile-friendly colour picker — used by EventTypesPanel and MobileEventForm
|
||||
// Renders inline (no sheet wrapper) so callers can embed it wherever they like.
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const COLOUR_SUGGESTIONS = [
|
||||
'#1a73e8','#a142f4','#e53935','#fa7b17','#34a853','#00bcd4',
|
||||
'#ff5722','#795548','#607d8b','#e91e63','#9c27b0','#3f51b5',
|
||||
];
|
||||
|
||||
function hexToHsv(hex) {
|
||||
const r=parseInt(hex.slice(1,3),16)/255, g=parseInt(hex.slice(3,5),16)/255, b=parseInt(hex.slice(5,7),16)/255;
|
||||
const max=Math.max(r,g,b), min=Math.min(r,g,b), d=max-min;
|
||||
let h=0;
|
||||
if(d!==0){if(max===r)h=((g-b)/d+(g<b?6:0))/6;else if(max===g)h=((b-r)/d+2)/6;else h=((r-g)/d+4)/6;}
|
||||
return{h:h*360,s:max===0?0:d/max,v:max};
|
||||
}
|
||||
function hsvToHex(h,s,v){
|
||||
h=h/360;const i=Math.floor(h*6),f=h*6-i;
|
||||
const p=v*(1-s),q=v*(1-f*s),t=v*(1-(1-f)*s);
|
||||
let r,g,b;
|
||||
switch(i%6){case 0:r=v;g=t;b=p;break;case 1:r=q;g=v;b=p;break;case 2:r=p;g=v;b=t;break;case 3:r=p;g=q;b=v;break;case 4:r=t;g=p;b=v;break;default:r=v;g=p;b=q;}
|
||||
return'#'+[r,g,b].map(x=>Math.round(x*255).toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
function isValidHex(h){return/^#[0-9a-fA-F]{6}$/.test(h);}
|
||||
|
||||
function SvSquare({hue,s,v,onChange}){
|
||||
const canvasRef=useRef(null);const dragging=useRef(false);
|
||||
useEffect(()=>{
|
||||
const canvas=canvasRef.current;if(!canvas)return;
|
||||
const ctx=canvas.getContext('2d'),W=canvas.width,H=canvas.height;
|
||||
const hGrad=ctx.createLinearGradient(0,0,W,0);hGrad.addColorStop(0,'#fff');hGrad.addColorStop(1,`hsl(${hue},100%,50%)`);
|
||||
ctx.fillStyle=hGrad;ctx.fillRect(0,0,W,H);
|
||||
const vGrad=ctx.createLinearGradient(0,0,0,H);vGrad.addColorStop(0,'transparent');vGrad.addColorStop(1,'#000');
|
||||
ctx.fillStyle=vGrad;ctx.fillRect(0,0,W,H);
|
||||
},[hue]);
|
||||
const getPos=(e,canvas)=>{
|
||||
const r=canvas.getBoundingClientRect();
|
||||
const cx=(e.touches?e.touches[0].clientX:e.clientX)-r.left;
|
||||
const cy=(e.touches?e.touches[0].clientY:e.clientY)-r.top;
|
||||
return{s:Math.max(0,Math.min(1,cx/r.width)),v:Math.max(0,Math.min(1,1-cy/r.height))};
|
||||
};
|
||||
const handle=(e)=>{e.preventDefault();const p=getPos(e,canvasRef.current);onChange(p.s,p.v);};
|
||||
return(
|
||||
<div style={{position:'relative',userSelect:'none',touchAction:'none'}}>
|
||||
<canvas ref={canvasRef} width={280} height={160}
|
||||
style={{display:'block',width:'100%',height:160,borderRadius:8,cursor:'crosshair',border:'1px solid var(--border)'}}
|
||||
onMouseDown={e=>{dragging.current=true;handle(e);}} onMouseMove={e=>{if(dragging.current)handle(e);}}
|
||||
onMouseUp={()=>{dragging.current=false;}} onMouseLeave={()=>{dragging.current=false;}}
|
||||
onTouchStart={handle} onTouchMove={handle}/>
|
||||
<div style={{position:'absolute',left:`calc(${s*100}% - 7px)`,top:`calc(${(1-v)*100}% - 7px)`,
|
||||
width:14,height:14,borderRadius:'50%',border:'2px solid white',
|
||||
boxShadow:'0 0 0 1.5px rgba(0,0,0,0.4)',pointerEvents:'none'}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HueBar({hue,onChange}){
|
||||
const barRef=useRef(null);const dragging=useRef(false);
|
||||
const handle=(e)=>{
|
||||
e.preventDefault();const r=barRef.current.getBoundingClientRect();
|
||||
const cx=(e.touches?e.touches[0].clientX:e.clientX)-r.left;
|
||||
onChange(Math.max(0,Math.min(360,(cx/r.width)*360)));
|
||||
};
|
||||
return(
|
||||
<div style={{position:'relative',userSelect:'none',touchAction:'none',marginTop:10}}>
|
||||
<div ref={barRef} style={{height:22,borderRadius:11,background:'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)',border:'1px solid var(--border)',cursor:'pointer'}}
|
||||
onMouseDown={e=>{dragging.current=true;handle(e);}} onMouseMove={e=>{if(dragging.current)handle(e);}}
|
||||
onMouseUp={()=>{dragging.current=false;}} onMouseLeave={()=>{dragging.current=false;}}
|
||||
onTouchStart={handle} onTouchMove={handle}/>
|
||||
<div style={{position:'absolute',left:`calc(${(hue/360)*100}% - 10px)`,top:-2,
|
||||
width:20,height:26,borderRadius:5,background:`hsl(${hue},100%,50%)`,
|
||||
border:'2px solid white',boxShadow:'0 0 0 1.5px rgba(0,0,0,0.3)',pointerEvents:'none'}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full inline picker — no sheet wrapper, callers handle the container
|
||||
export function ColourPicker({ value, onChange }) {
|
||||
const {h:ih,s:is,v:iv}=hexToHsv(value||'#6366f1');
|
||||
const [mode,setMode]=useState('suggestions'); // 'suggestions' | 'custom'
|
||||
const [hue,setHue]=useState(ih);
|
||||
const [sat,setSat]=useState(is);
|
||||
const [val,setVal]=useState(iv);
|
||||
const [hexInput,setHexInput]=useState(value||'#6366f1');
|
||||
const [hexError,setHexError]=useState(false);
|
||||
const current=hsvToHex(hue,sat,val);
|
||||
|
||||
// Sync from value prop when it changes externally
|
||||
useEffect(()=>{
|
||||
if(value&&isValidHex(value)){
|
||||
const{h,s,v}=hexToHsv(value);
|
||||
setHue(h);setSat(s);setVal(v);setHexInput(value);
|
||||
}
|
||||
},[value]);
|
||||
|
||||
useEffect(()=>{setHexInput(current);setHexError(false);},[current]);
|
||||
|
||||
const handleHexInput=(e)=>{
|
||||
const v=e.target.value;setHexInput(v);
|
||||
if(isValidHex(v)){const{h,s,v:bv}=hexToHsv(v);setHue(h);setSat(s);setVal(bv);setHexError(false);}
|
||||
else setHexError(true);
|
||||
};
|
||||
|
||||
if(mode==='suggestions') return(
|
||||
<div>
|
||||
{/* Current preview */}
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:12}}>
|
||||
<div style={{width:36,height:36,borderRadius:8,background:value,border:'2px solid var(--border)',flexShrink:0}}/>
|
||||
<span style={{fontSize:13,fontFamily:'monospace',color:'var(--text-secondary)'}}>{value}</span>
|
||||
</div>
|
||||
{/* Swatches */}
|
||||
<div style={{display:'flex',flexWrap:'wrap',gap:8,marginBottom:12}}>
|
||||
{COLOUR_SUGGESTIONS.map(hex=>(
|
||||
<button key={hex} onClick={()=>onChange(hex)} style={{
|
||||
width:36,height:36,borderRadius:8,background:hex,cursor:'pointer',flexShrink:0,
|
||||
border:hex===value?'3px solid var(--text-primary)':'2px solid var(--border)',
|
||||
boxShadow:hex===value?'0 0 0 2px var(--surface),0 0 0 4px var(--text-primary)':'none',
|
||||
}}/>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>setMode('custom')}>Custom colour</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return(
|
||||
<div>
|
||||
<SvSquare hue={hue} s={sat} v={val} onChange={(s,v)=>{setSat(s);setVal(v);}}/>
|
||||
<HueBar hue={hue} onChange={setHue}/>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10,marginTop:12}}>
|
||||
<div style={{width:40,height:40,borderRadius:8,background:current,border:'2px solid var(--border)',flexShrink:0}}/>
|
||||
<input value={hexInput} onChange={handleHexInput} maxLength={7} placeholder="#000000"
|
||||
style={{fontFamily:'monospace',fontSize:14,padding:'6px 10px',borderRadius:8,
|
||||
border:`1px solid ${hexError?'#e53935':'var(--border)'}`,width:110,
|
||||
background:'var(--surface)',color:'var(--text-primary)'}} autoComplete="new-password" />
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8,marginTop:12}}>
|
||||
<button className="btn btn-primary btn-sm" onClick={()=>{onChange(current);setMode('suggestions');}} disabled={hexError}>Set</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={()=>setMode('suggestions')}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bottom-sheet wrapper for mobile — position:fixed, slides up from bottom
|
||||
export default function ColourPickerSheet({ value, onChange, onClose, title='Pick a colour' }) {
|
||||
return (
|
||||
<div style={{position:'fixed',inset:0,zIndex:300,display:'flex',alignItems:'flex-end'}}
|
||||
onClick={e=>e.target===e.currentTarget&&onClose()}>
|
||||
<div style={{width:'100%',background:'var(--surface)',borderRadius:'16px 16px 0 0',
|
||||
padding:20,boxShadow:'0 -4px 24px rgba(0,0,0,0.2)',maxHeight:'85vh',overflowY:'auto'}}>
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
|
||||
<span style={{fontWeight:700,fontSize:16}}>{title}</span>
|
||||
<button onClick={onClose} style={{background:'none',border:'none',cursor:'pointer',
|
||||
color:'var(--text-secondary)',fontSize:20,lineHeight:1}}>✕</button>
|
||||
</div>
|
||||
<ColourPicker value={value} onChange={v=>{onChange(v);}}/>
|
||||
<button onClick={onClose} style={{width:'100%',padding:'14px',marginTop:16,
|
||||
background:'var(--primary)',color:'white',border:'none',borderRadius:'var(--radius)',
|
||||
fontSize:16,fontWeight:700,cursor:'pointer'}}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/GlobalBar.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export default function GlobalBar({ isMobile, showSidebar, onBurger, hasUnread = false }) {
|
||||
const { connected } = useSocket();
|
||||
const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '' });
|
||||
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
window.addEventListener('rosterchirp:settings-changed', handler);
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
});
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
return () => {
|
||||
window.removeEventListener('rosterchirp:settings-changed', handler);
|
||||
themeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const appName = settings.app_name || 'rosterchirp';
|
||||
const logoUrl = settings.logo_url;
|
||||
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null;
|
||||
|
||||
if (isMobile && !showSidebar) return null;
|
||||
|
||||
return (
|
||||
<div className="global-bar">
|
||||
{/* Left side: burger + logo + title grouped together */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flex: 1, minWidth: 0 }}>
|
||||
<button
|
||||
onClick={onBurger}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-primary)', padding: '4px 6px',
|
||||
display: 'flex', alignItems: 'center', flexShrink: 0, borderRadius: 8,
|
||||
}}
|
||||
title="Menu"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
{hasUnread && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: -1, right: -1,
|
||||
width: 9, height: 9, borderRadius: '50%',
|
||||
background: 'var(--primary)',
|
||||
border: '2px solid var(--surface)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="global-bar-brand">
|
||||
<img src={logoUrl || '/icons/rosterchirp.png'} alt={appName} className="global-bar-logo" />
|
||||
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!connected && (
|
||||
<span className="global-bar-offline" title="Offline">
|
||||
<span className="offline-dot" />
|
||||
<span className="offline-label">Offline</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||