Compare commits

...

315 Commits

Author SHA1 Message Date
f7f7eb9d2f details update 2026-04-10 13:50:22 -04:00
b527e24705 update 2026-04-10 13:18:59 -04:00
1af039ab0a Update README.md 2026-04-09 16:28:17 +00:00
03496884d5 Update README.md 2026-04-09 16:27:45 +00:00
c87b5f0304 Update .env 2026-04-09 16:25:19 +00:00
b1ceb7cccb Delete 2026-04-09 16:22:07 +00:00
3a86385038 Delete 2026-04-09 16:21:56 +00:00
70750890e3 Delete 2026-04-09 16:21:37 +00:00
8f5310754c Update .env 2026-04-09 16:20:43 +00:00
d79839b438 Merge branch 'main' of https://gitea.stretchy.ca/rick/jama 2026-04-07 16:37:15 -04:00
f0a591f474 ok 2026-04-07 16:37:13 -04:00
2874285cc7 file meta-data update 2026-04-07 11:29:21 -04:00
c9d6a4d9d4 v.0.13.1 fixed minor UI issues, updated rules (bumped from v0.12.53) 2026-04-07 11:27:26 -04:00
dbea35abe2 signout bug fix 2026-04-05 11:28:50 -04:00
18c63953cc v0.12.53 Restricted Login type rule changes 2026-04-02 18:40:51 -04:00
4df92752bb v0.12.52 version bump 2026-04-02 18:16:23 -04:00
ae47f66ef5 hint messages update 2026-04-02 18:15:10 -04:00
e4ac7e248c bug fix: Family manager now shows on drawer navigation menu. 2026-04-02 15:34:37 -04:00
18e4a92241 minor bug fixes 2026-04-02 15:22:38 -04:00
97b308e9f0 v0.12.51 updated "Mixed Age" login type. 2026-04-02 12:50:50 -04:00
1d4116d1a3 v0.12.50 Updated to Family Manager and Events modal 2026-04-02 09:58:15 -04:00
6de899112b Family manager bug fixes 2026-04-01 18:47:36 -04:00
3910063ed3 v0.12.49 family rules update 2026-04-01 16:26:58 -04:00
7031979571 v0.12.49 Login Type and Event bug fixes 2026-04-01 09:25:17 -04:00
a3a878854e v0.12.48 Login Type bug fixes 2026-03-31 20:11:40 -04:00
f942bc45b9 bug fixes 2026-03-31 14:12:51 -04:00
9c263e7e8d v0.12.47 Add Child alias update 2026-03-31 13:51:47 -04:00
350bb25ecd v0.12.46 host bug fixes and password reset feature, 2026-03-31 12:21:59 -04:00
d0f10c4d7e v0.12.45 fixed Guardian only feature 2026-03-30 19:07:15 -04:00
1a85d3930e v0.12.44 message notification updates 2026-03-30 16:32:21 -04:00
c82d113adf Merge branch 'main' of https://gitea.stretchy.ca/rick/jama 2026-03-30 16:02:11 -04:00
fe836ae69f v0.12.43 minor protection added 2026-03-30 16:02:09 -04:00
ec6246bd72 virtual update from "jama" 2026-03-30 15:45:40 -04:00
e8e941c436 v0.12.42 new availibilty list download 2026-03-30 08:39:55 -04:00
6a2f4438f9 v0.12.41 New settings options for messages 2026-03-30 08:04:36 -04:00
ff6743c9b1 v0.12.40 iso notificastion bug fix 2026-03-29 23:21:35 -04:00
d03baec163 group manager scrollable bug fix. 2026-03-29 19:24:02 -04:00
b456143d20 v0.12.39 bump 2026-03-29 16:01:36 -04:00
2710a9c111 event form fix 2026-03-29 15:48:56 -04:00
93689d4486 v0.12.38 event form bug fixes 2026-03-29 15:38:16 -04:00
cfb351a251 v0.12.37 bumped build number 2026-03-29 10:41:21 -04:00
2dffeb1fde major recurring event structure changes 2026-03-29 10:40:06 -04:00
4b4ddf0825 fixed the reccurring event delete bug 2026-03-28 22:28:46 -04:00
43ff0f450d recurring event delete bug 2026-03-28 22:02:21 -04:00
f4dfa6eeca user's event save is fixed. 2026-03-28 21:45:32 -04:00
36e1be8f40 event notification update 2026-03-28 21:23:19 -04:00
3bf01cba1f schedules bug fix 2026-03-28 21:03:12 -04:00
12c4a154e5 alignment of lists and user menu text 2026-03-28 20:31:21 -04:00
2037bb1caa new note bug fix on mobile. 2026-03-28 20:11:25 -04:00
1ed9d9d95e add the option for the user to add a note to their availability 2026-03-28 19:54:01 -04:00
a43d067e61 update availability view 2026-03-28 19:29:47 -04:00
a6ac21aed0 updated auto-generated avatars to have transaparent backgrounds 2026-03-28 15:29:52 -04:00
5c5f2b4050 css fix for about tab 2026-03-28 15:19:27 -04:00
252c0e09cb v0.12.26 iOS notification bug fix 2026-03-28 14:51:00 -04:00
f40bb123d2 trivial update 2026-03-28 14:28:31 -04:00
d07d9e3919 v0.12.35 UI fixes 2026-03-28 14:18:20 -04:00
76edd7abd1 v0.12.34 iOS bug fixes 2026-03-28 13:22:02 -04:00
fb9d4dc956 v0.12.33 text cleanup of the app and bug fixes. 2026-03-28 12:55:53 -04:00
eb3e45d88f priviate group avatars update 2026-03-28 12:02:57 -04:00
d7790bb7ef chrome mobile autofill bug fix 2026-03-28 11:27:36 -04:00
a0d7125dd3 iOS message window bugs 2026-03-28 11:09:31 -04:00
459ab27c5b pinch zoom bug fix 2026-03-28 11:06:59 -04:00
abd4574ee3 swipe bug fix 2026-03-28 10:00:52 -04:00
f50f2aaba1 close button on user menu 2026-03-27 23:00:59 -04:00
7476ca5cd1 input fix for chome 2026-03-27 22:51:02 -04:00
f1683e2ff5 iOS button bug fix 2026-03-27 22:41:38 -04:00
407e9ee731 bug fix 2026-03-27 16:57:07 -04:00
8a21ffddb5 iOS bug fixes 2026-03-27 16:34:09 -04:00
2b2b184f04 bug fix 2026-03-27 16:15:33 -04:00
eea7cb91e7 bug fixes 2026-03-27 15:52:43 -04:00
05f7d48bf1 bug fixes 2026-03-27 15:04:52 -04:00
fe55e6481a v0.12.32 bug fixes 2026-03-27 14:38:08 -04:00
97f1dace4f v0.12.31 multiple UI changes 2026-03-27 10:19:52 -04:00
d6a37d5948 v0.12.30 add notifications for iOS 2026-03-26 14:49:17 -04:00
6e5c39607c minor text update on user manager forms 2026-03-26 13:56:39 -04:00
13e5e3a627 v0.12.29 various bug fixes 2026-03-26 09:46:35 -04:00
92dbcf2780 build.sh fix 2026-03-25 13:05:46 -04:00
ba91fce44c v0.12.28 new modal window for event edit/delete 2026-03-25 13:00:43 -04:00
2b2e98fa48 fixed recurring event bug 2026-03-25 12:38:56 -04:00
0b03f15e4a scroll update for schedule list view 2026-03-25 12:07:07 -04:00
6af892c9a6 schedule views update 2026-03-25 09:10:44 -04:00
941d216f38 day view on mobile bug fix 2026-03-25 08:49:14 -04:00
163d71d505 v0.12.27 schedule list view update 2026-03-25 07:56:35 -04:00
8c4650d1bc FCM bug fix 2026-03-24 19:16:36 -04:00
f48ce589ca v0.12.26 FCM feature changes 2026-03-24 19:04:09 -04:00
225dcd718b v0.12.25 FCM bug fixes 2026-03-24 18:13:15 -04:00
7276228a98 v0.12.24 minor user manager buf fixes 2026-03-24 16:10:27 -04:00
e77176841c v0.12.23 group manager display update 2026-03-24 15:55:48 -04:00
72094d7d15 v0.12.22 User Manager updates 2026-03-24 15:19:32 -04:00
65e7cc4007 v0.12.21 adjusted mobile group manager ui 2026-03-24 13:08:30 -04:00
9dd3392e95 v0.12.20 added option to not create user group dm 2026-03-24 12:41:17 -04:00
780020fc46 V0.12.19 Scroll full page issue bug fixes 2026-03-24 11:15:16 -04:00
d0c15287c4 v0.12.18 fixed user manager scroll issue 2026-03-24 10:56:23 -04:00
85177a643f favicon.ico update 2026-03-24 10:41:52 -04:00
ce6e03d66b v0.12.17 schedule view update 2026-03-24 09:40:49 -04:00
b5672eb4a2 v0.12.16 drawer menu update for member user 2026-03-24 09:20:40 -04:00
7c0c3e1132 v0.12.15 PWA bug and schema error for GM 2026-03-24 08:58:09 -04:00
117b5cbe4c v0.12.14 FCM optimization 2026-03-24 08:22:56 -04:00
bb5a3b6813 code cleanup 2026-03-24 08:07:21 -04:00
44799f76cc v0.12.13 user manager permissions + member event calendar 2026-03-24 07:57:03 -04:00
2e3e4100f5 v0.12.12 user manager update 2026-03-24 07:34:03 -04:00
dec24eb842 v0.12.11 new drawer menu notification feature 2026-03-23 22:59:03 -04:00
bcd9f4a060 v0.12.10 ui bug fixes 2026-03-23 22:34:42 -04:00
477b25dfa0 v0.12.9 bug fixes (FCM and list ordering) 2026-03-23 21:43:35 -04:00
01f37e60be v0.12.8 FCM bug fix 2026-03-23 19:34:13 -04:00
eca93aae28 v0.12.7 FCM bug fixes 2026-03-23 19:10:21 -04:00
ad67330d20 update 2026-03-23 17:29:57 -04:00
a0183458eb v0.12.6 FCM updates 2026-03-23 13:11:47 -04:00
10e3df25f9 icons 2026-03-23 12:34:51 -04:00
048abcfbfd v0.12.5 FCM bug fixes 2026-03-23 12:07:52 -04:00
f9024a6f3a icon updates 2026-03-23 11:52:23 -04:00
b3cc9727e4 v0.12.4 windsurf changes 2026-03-23 11:46:22 -04:00
cf9b22feb5 icon test 2026-03-23 11:04:52 -04:00
3c7d3002f1 logo update 2026-03-23 10:38:39 -04:00
de15d28d3a v0.12.3 FCM bug test 2026-03-23 10:11:07 -04:00
15bc1d110e pwa icon update 2026-03-23 10:02:30 -04:00
6e179eb1ec new icon update 2026-03-23 10:01:04 -04:00
edc7885a6b FCM testing 2026-03-23 09:32:25 -04:00
14c80f436a FCM test 2026-03-23 08:14:02 -04:00
2d164958d8 FCM test 2026-03-22 23:31:19 -04:00
64522764cb missing version numbers 2026-03-22 23:29:34 -04:00
2495a2c358 v0.11.28 FCM bug fixes 2026-03-22 23:10:17 -04:00
bfb67261b2 set priority icons 2026-03-22 23:01:41 -04:00
3d7e75a1e6 v0.11.27 message bug fixes 2026-03-22 22:48:46 -04:00
d2ed487079 v0.11.26 FCM bugs fixes 2026-03-22 21:39:09 -04:00
ef3935560d v0.12.1 FCM db update 2026-03-22 21:21:57 -04:00
8e05d02695 FCM update 2026-03-22 20:53:24 -04:00
88135c7e77 updated icons and logo 2026-03-22 20:43:14 -04:00
819d60d693 v0.12.0 codes for FCM and rebranded jama to RosterChirp 2026-03-22 20:15:57 -04:00
21dc788cd3 v0.11.26 new rules for default admin user 2026-03-22 18:51:46 -04:00
25a9fa4a02 claude code instructions 2026-03-22 18:38:39 -04:00
89bc8d00f7 v0.11.25 schedule list filter bug fixes 2026-03-22 18:23:15 -04:00
344ca70b64 v011.24 event bug fixes 2026-03-22 17:28:30 -04:00
b72ce57544 v0.11.23 filter schedule list bug fix 2026-03-22 15:17:26 -04:00
ca470f1bb6 v0.11.22 bug fix 2026-03-22 15:06:03 -04:00
300cf5d869 v0.11.21 bug fixes 2026-03-22 14:49:35 -04:00
8116b307f7 v0.11.20 UI updates 2026-03-22 14:30:06 -04:00
7d6b28b4a3 build.sh compile bug fix 2026-03-22 14:06:46 -04:00
2856505f8d v0.11.19 bug fixes 2026-03-22 14:03:22 -04:00
bddfcaac7e v0.11.18 user avatar upload fix 2026-03-22 12:43:34 -04:00
c3d20c51a3 v0.11.17 tool managers bug fix 2026-03-22 12:32:52 -04:00
6a2dc83764 v0.11.16 mobile event form fix 2026-03-22 11:30:45 -04:00
aef1ef90fe v0.11.15 user manager bug fix 2026-03-22 11:18:07 -04:00
8a2ca50032 v0.11.14 event message 2026-03-21 21:57:05 -04:00
69515c9e95 v0.11.13 login screen fix 2026-03-21 19:23:20 -04:00
e2cff3180e missed files in last update 2026-03-21 19:08:15 -04:00
c71abc67b8 v0.11.12 new migration to fix orphaned users 2026-03-21 19:07:16 -04:00
9245c6032b v0.11.11 updated user suspend/delete rules 2026-03-21 18:55:07 -04:00
253bc1f963 v0.11.10 ui changes 2026-03-21 18:50:34 -04:00
c5a8d728d2 v0.11.9 fixed tenant isolation bug 2026-03-21 12:53:00 -04:00
e0e800012c v0.10.7 UI rule changes 2026-03-21 11:55:50 -04:00
82a521f12c v0.11.6 added event form rules. 2026-03-21 11:07:22 -04:00
60df2cf97e v0.11.5 event form bug fixes 2026-03-21 10:08:10 -04:00
f60730d0a5 v0.10.4 fix event date/time functions 2026-03-21 09:22:02 -04:00
596fd0f969 v0.11.3 fixed timezone issue 2026-03-21 00:23:29 -04:00
01a9b97f0d new file 2026-03-20 23:53:24 -04:00
cad1c44605 v0.11.2 fixed broken drawer links 2026-03-20 23:39:03 -04:00
fc0c071d1d bugs fixes due to reges curruption 2026-03-20 23:19:45 -04:00
6da08942a7 same - bug fixes 2026-03-20 23:13:00 -04:00
cfdbdd9a44 build.sh update 2026-03-20 23:10:11 -04:00
3ac72b7ac9 build.sh bug fixes 2026-03-20 23:06:39 -04:00
0a048271c6 v0.11.1 various UI bug fixes 2026-03-20 23:01:26 -04:00
50e7adf246 v0.11.0 more bug fixes 2026-03-20 22:45:09 -04:00
d2c157e8d0 v0.10.9 update ui settings 2026-03-20 22:28:14 -04:00
8a99fb5ed6 v0.10.8 mobile bug fixes 2026-03-20 21:57:53 -04:00
b224237cf7 v0.10.7 fixed UI settings 2026-03-20 21:34:53 -04:00
66fd4c5377 v0.10.6 auto update tenants (seed) 2026-03-20 21:07:19 -04:00
3990471275 build.sh error update 2026-03-20 20:33:24 -04:00
241d913e0f v0.10.5 added some new permission options 2026-03-20 20:27:44 -04:00
f49fd5b885 v0.10.4 new UI changes 2026-03-20 13:35:22 -04:00
a072a13706 v0.10.3 ui changes and bug fixes 2026-03-20 12:56:28 -04:00
f2e32dae92 v0.10.2 build rules update 2026-03-20 12:37:25 -04:00
419f7320b2 add an updated .env file 2026-03-20 10:55:04 -04:00
99e5cf9a7a v0.10.1 build.sh update 2026-03-20 10:53:19 -04:00
ac7cba0f92 v0.9.88 major change sqlite to postgres 2026-03-20 10:46:29 -04:00
7dc4cfcbce v0.9.87 build.sh fixes 2026-03-19 12:16:25 -04:00
33b0264080 v0.9.86 minor UI changes 2026-03-19 12:10:10 -04:00
6169ad5d99 v0.9.85.1 updates 2026-03-19 11:29:30 -04:00
6b7d555879 v0.9.85 minor ui updates 2026-03-19 10:12:04 -04:00
71575f278e v0.9.84 bug fixes 2026-03-18 20:11:34 -04:00
a069407fbb v0.9.83 build.sh fix 2026-03-18 19:52:50 -04:00
600abc1800 v0.9.82 ui changes 2026-03-18 19:49:36 -04:00
ca2d472837 v0.9.81 bugs fixes 2026-03-18 19:14:34 -04:00
de22432cc5 v0.9.80 filter fixes 2026-03-18 15:59:14 -04:00
58e0607e4c v0.9.79 colour picker update 2026-03-18 15:33:48 -04:00
d9f0986e26 V0.7.78 fixes 2026-03-18 15:21:29 -04:00
d88d74bd49 c0.9.77 bug fixes for bug fixes 2026-03-18 14:56:46 -04:00
bff1ad6d89 v0.9.76 ui layout changes 2026-03-18 14:42:11 -04:00
a65541a4f1 v0.9.75 bug fixes for bugs fixes 2026-03-18 14:30:57 -04:00
8346d4658a v0.9.74 bug fixes 2026-03-18 14:20:45 -04:00
779234e906 bug fixes 2026-03-18 14:15:28 -04:00
7d44bff526 v0.9.72 bugs fixes 2026-03-18 14:07:35 -04:00
3c97fd6769 backout 2026-03-18 13:04:57 -04:00
fbce4ce397 rick code update 2026-03-18 12:41:30 -04:00
830567305b gemini fix 2026-03-18 12:35:12 -04:00
083da849b9 v0.9.71 fixed broken ui layout 2026-03-18 12:22:15 -04:00
fde8bd1a38 v.0.9.70 fix build.sh 2026-03-18 11:45:45 -04:00
19932d9ca8 v0.9.69 updated UI layout and styles 2026-03-18 11:33:14 -04:00
efaec151c6 v0.9.68 new filter rules 2026-03-18 10:55:07 -04:00
3d3e8068db v0.9.66 minor bug fixes 2026-03-18 10:14:05 -04:00
d5abdab4ca v.0.9.66 UI changes 2026-03-18 09:45:58 -04:00
6fb685d273 update icon colours 2026-03-18 09:11:43 -04:00
cb12804ca2 V0.9.65 ui changes 2026-03-17 22:05:43 -04:00
7418575935 v0.9.64 fixed margins on mobile 2026-03-17 21:22:31 -04:00
4602c2e586 v0.9.63 updated for mobile 2026-03-17 21:17:07 -04:00
85fc75dd19 v0.9.62 fixed date format 2026-03-17 21:06:32 -04:00
10659a37b5 v0.9.61 icon updates 2026-03-17 19:30:36 -04:00
b2b09cb0d0 v0.9.60 Ramsey recommended feature adds 2026-03-17 18:43:25 -04:00
c823c86b63 v0.9.59 bugs fixes 2026-03-17 17:55:36 -04:00
b90087f5da v0.9.58 event title bug fix 2026-03-17 17:24:54 -04:00
8bde33ffc5 v0.9.57 event title bug fix 2026-03-17 17:05:43 -04:00
dc7be22ed2 v0.9.56 various bugs fixes 2026-03-17 16:51:18 -04:00
12aa4e0bca logo_update 2026-03-17 16:15:41 -04:00
a480a78ba7 v0.9.54 functionality bug fixes 2026-03-17 15:55:34 -04:00
5f8e86c914 v0.9.53 schedule list bug fix 2026-03-17 15:43:06 -04:00
e4f5504e52 v0.9.52 minor bug fixes 2026-03-17 15:24:29 -04:00
5d21420ed9 v0.9.51 minor schedule bug fixes. 2026-03-17 14:58:08 -04:00
c7b0b0462d v0.9.50 fixed page not loading bug 2026-03-17 12:22:46 -04:00
7b89985a3d V0.9.49 UI updated to schedule 2026-03-17 12:09:03 -04:00
417952af40 v0.9.48 schedule changes 2026-03-17 11:11:19 -04:00
0e7a20e45b v0.9.47 schedules redesign 2026-03-17 10:05:51 -04:00
fed5e75122 v0.9.46 Add event scheduler 2026-03-17 09:48:09 -04:00
3c62782a8d v0.9.45 fixed user group permissions 2026-03-16 20:27:42 -04:00
ccfccaac0c v0.9.44 permissions changes 2026-03-16 20:15:28 -04:00
177c05d7da v0.9.43 user group dm permissions fixed 2026-03-16 19:14:04 -04:00
ee4bb4b86d v0.9.42 notification fix 2026-03-16 19:00:08 -04:00
5322eabee3 v0.9.41 fixed permissions 2026-03-16 18:42:00 -04:00
de5912c206 v0.9.39 bugs fixes 2026-03-16 18:29:51 -04:00
5025f0043d v.9.38 bug fixes 2026-03-16 18:11:31 -04:00
3519042591 v0.9.37 bug fixes 2026-03-16 17:55:30 -04:00
3f7579e6be v0.9.36 2026-03-16 17:31:50 -04:00
7d9d86d5cc v0.9.35 settings redesign 2026-03-16 17:23:13 -04:00
28ae533b0e v0.9.34 label changes 2026-03-16 11:06:55 -04:00
5f50fa74df v0.9.33 bugs fixes 2026-03-16 10:48:52 -04:00
99e63a83cf v0.9.32 logo update 2026-03-16 10:15:18 -04:00
161f4cec3a v0.9.32 bugs fixes 2026-03-15 23:44:52 -04:00
aed750e4ee v0.9.31 rules changes 2026-03-15 23:11:40 -04:00
5728fd294e v0.9.30 more bugfixes 2026-03-15 22:47:46 -04:00
d3f6dcecd5 v0.9.29 Bugs fixes 2026-03-15 22:33:26 -04:00
02c8427cad v0.9.28 bug fixes 2026-03-15 22:06:51 -04:00
add52cfd09 V0.8.27 group manager changes 2026-03-15 20:14:09 -04:00
7bc0d26cdd v0.9.26 added a admin tools 2026-03-15 16:36:01 -04:00
53d665cc6f v0.9.25 limit app-name length 2026-03-15 14:34:30 -04:00
41319157b2 v0.9.24 fixed notifications 2026-03-15 14:33:00 -04:00
5c1dd94efb v0.9.23 bug fixes 2026-03-15 11:41:51 -04:00
8fcadf7f0d V0.9.21 branding updates 2026-03-15 11:25:00 -04:00
d058c9cd5f v0.9.21 added a cleanup routibe for deleted images. 2026-03-14 18:15:32 -04:00
55adf514de help.md update 2026-03-14 18:04:18 -04:00
4307e16fda updating the help file 2026-03-14 17:47:47 -04:00
878299d661 v0.9.20 created new colour picker on mobile 2026-03-14 17:24:05 -04:00
5086d86340 v0.9.18 branding fixes 2026-03-14 16:45:06 -04:00
42b3226306 v0.9.17 branding bug fixes 2026-03-14 16:27:59 -04:00
313095984f v0.9.16 updated baranding colours 2026-03-14 15:58:04 -04:00
9409f4bb08 v0.9.15 updated branding modal 2026-03-14 15:21:22 -04:00
2ffa6202f1 v0.9.14 adjusted emoticon and image view. 2026-03-14 13:52:58 -04:00
2d0214fc10 v0.9.12 Fixed back swipe 2026-03-14 12:38:46 -04:00
e38c7358f6 v0.9.11 bugs fixes 2026-03-14 01:36:08 -04:00
d7a1b09253 v0.9.10 build.sh fix 2026-03-14 01:23:48 -04:00
71f97e89c9 v0.9.9 css fixes 2026-03-14 01:19:04 -04:00
ea2d84d36e v0.9.8 bug fixes 2026-03-14 01:04:20 -04:00
1fc50fdd6d v0.9.6 bug fixes 2026-03-14 00:49:05 -04:00
11277c6167 V0.9.5 build.sh fix 2026-03-14 00:37:16 -04:00
e7f1bdb195 v0.9.4 bugs fixes 2026-03-14 00:33:53 -04:00
28678dc5b0 formatting update 2026-03-13 16:59:45 -04:00
00f0cbfece adding an example file 2026-03-13 16:55:09 -04:00
62b89b6548 v0.9.3 added user feature to disable participation in private messages 2026-03-13 16:25:33 -04:00
5301d8a525 v0.9.2 switched to correct version of encrypted database 2026-03-13 15:30:57 -04:00
e02cf69745 v0.9.1 encrypt database and bug fixes 2026-03-13 15:22:40 -04:00
9f7266bc6a V0.8.8 removed the pinning feature 2026-03-13 12:57:36 -04:00
83b2105a9a v0.8.7 pin bug fixes 2026-03-13 11:47:33 -04:00
6d435844c9 v0.8.6 pin bug fixes 2026-03-13 11:27:11 -04:00
022cbd41ea v0.8.5 pinning bug fix 2026-03-13 10:57:20 -04:00
b3aac1981c v0.8.5 fix pinning 2026-03-13 10:51:27 -04:00
a02facff1a v0.8.4 full refresh 2026-03-13 10:02:04 -04:00
33b5f2ee4d v0.8.2 added font resizing pinch zoom 2026-03-12 23:49:24 -04:00
25a1343838 v0.8.0 add menu items + vapid key 2026-03-12 13:03:04 -04:00
5697e3a59c v0.7.8 message list order bug fix 2026-03-11 19:59:58 -04:00
67bea6c2c3 v0.7.8 bugs fixes for pinned messages 2026-03-11 19:38:59 -04:00
08243be745 v0.7.7 bugs fixes 2026-03-11 19:11:18 -04:00
03a8983b7d v0.7.5 bugs fixes 2026-03-11 18:52:17 -04:00
8202c838f5 v0.7.4 bug fixes 2026-03-11 18:36:07 -04:00
6ad9584ea9 build.sh update 2026-03-11 15:44:43 -04:00
d822784826 v0.7.1 bugs fix for last update 2026-03-11 15:41:00 -04:00
39fa6e9ff2 updated build.sh 2026-03-11 14:52:58 -04:00
3fe17c7901 V0.7.1 New user online and pin features 2026-03-11 14:47:44 -04:00
861ded53e0 v0.7.0.1 updated build.sh 2026-03-11 14:45:06 -04:00
a1f0c35e8d v0.7.1 minor bug fixes 2026-03-11 14:37:49 -04:00
fd041ea95a V0.7.0 avatar and user to user name update 2026-03-10 23:52:42 -04:00
34d834944b v0.6.9 added avatar to message list 2026-03-10 23:18:14 -04:00
acc24f4d1d icon edit 2026-03-10 22:52:22 -04:00
2b1a25b9b0 updated help 2026-03-10 21:51:16 -04:00
8fc7a01778 v06.8 various bug fixes 2026-03-10 21:30:36 -04:00
9da2f64f5e v0.6.7 updated message display name handling 2026-03-10 20:08:44 -04:00
741cf5390f updated help info 2026-03-10 19:34:35 -04:00
3b52093be8 help file update 2026-03-10 19:24:06 -04:00
daaf4a4805 v0.6.5 various bug fixes 2026-03-10 19:02:14 -04:00
2d21aac35f v0.6.5 various updates 2026-03-10 18:16:05 -04:00
09e6a75a9b v0.6.4 fixed help.md display 2026-03-10 15:02:45 -04:00
120f76c6f8 v0.6.3 fixed user menu icon bug 2026-03-10 14:44:23 -04:00
85cfad6318 v0.6.2 added help window 2026-03-10 14:43:25 -04:00
605d10ae02 v0.5.1 fixed mobile PWA refesh 2026-03-10 12:08:49 -04:00
110624c866 v0.5.0 UI and new message rules 2026-03-10 11:14:33 -04:00
d5087cd693 v0.4.0 altered logo and icon 2026-03-10 11:13:19 -04:00
843e07ab88 v.0.3.9 updated app logo 2026-03-10 11:12:03 -04:00
78dc7d5cb3 v0.3.8 fixed messages summaries 2026-03-10 00:14:50 -04:00
27bee43f89 v0.3.7 @mentions lookup fix 2026-03-09 22:53:08 -04:00
08d57309ae v.0.3.6 message input box update 2026-03-09 22:34:04 -04:00
0f3983dc93 v0.3.5 avatar alignment fix 2026-03-09 20:58:17 -04:00
1e4dfe5110 v0.3.4 style fixes 2026-03-09 16:26:07 -04:00
c192c4d7a1 v0.3.3 bug fixes 2026-03-09 15:39:42 -04:00
31f61cc056 v0.3.2 bug fixes 2026-03-09 15:13:44 -04:00
8469ff7b6a v0.3.1 bug fix 2026-03-09 15:13:07 -04:00
42ad779750 v0.3.0 2026-03-09 14:36:19 -04:00
f37fe0086f Merge branch 'main' of https://gitea.stretchy.ca/rick/teamchat 2026-03-06 22:38:23 -05:00
edbee5c8ef version 0.0.24 2026-03-06 22:37:48 -05:00
139 changed files with 26469 additions and 2378 deletions

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(/mnt/c/Program Files/nodejs/npm.cmd run build)",
"Bash(cmd.exe /c \"npm run build\")",
"Bash(cmd.exe /c \"cd /d d:\\\\_projects\\\\gitea\\\\jama\\\\frontend && npm run build 2>&1\")",
"Bash(cmd.exe /c \"cd /d d:\\\\_projects\\\\gitea\\\\jama\\\\frontend && npm run build\")",
"Bash(powershell.exe -Command \"cd ''d:\\\\_projects\\\\gitea\\\\jama\\\\frontend''; & ''C:\\\\Program Files\\\\nodejs\\\\npm.cmd'' run build 2>&1\")",
"Bash(powershell.exe -Command \"Get-Command npm -ErrorAction SilentlyContinue; \\(Get-Command node -ErrorAction SilentlyContinue\\).Source\")",
"Bash(npm run:*)",
"Bash(*)"
]
}
}

46
.env Normal file
View 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=

View File

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

@@ -0,0 +1,36 @@
# Environment — never commit real credentials
.env
.env.local
.env.*.local
# Dependencies
node_modules/
frontend/node_modules/
backend/node_modules/
# Build output
frontend/dist/
# Runtime data
data/
uploads/
# Docker local volume mounts
postgres-data/
# OS / editor artefacts
.DS_Store
Thumbs.db
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Private reference / scratch docs
ReferenceDocs/

51
Bulk_User_Import.txt Normal file
View File

@@ -0,0 +1,51 @@
<info_box>
CVS Format (title>
FULL: email,firstname,lastname,password,role,default_usergroup
MINIMUM: email,firstname,lastname,,,
OPTIONAL: email,firstname,lastname,,,default_usergroup
We highly recommend using spreadsheet editor and save as a CSV file to ensure maximum accuracy
You can include this header row (ie: email,firstname,lastname,password,role,usergroup,minor,guardian-user-email)
Examples:
Parent: example@rosterchirp.com,Barney,Rubble,,member,parents,,
Player: example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players,,
Minor: Player: example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players,minor,example@rosterchirp.com
CVS Details (accordion title) - collapsed by default, click title to expand accordion
<accordion>
CSV requirements:
five commas, exactly, are required per row (rows with more or less will be ignored)
email,firstname,lastname are the minimum required fields
user can onlt be added to one group during a bulk import.
optional fields: these fields can be left blank and the system defaults will be used
User Groups available: *
list $user_groups (single column, multiple rows) that new users can be added to
Roles available: *
member - non-priviledged user (default)
manager - priviledged user: add/edit/remove schedules/users/user groups etc
admin - priviledged user: manager + edit settings, change branding
* Only available group values (user group, roles) will be used, group values that do not exist will be ignored
Option field defaults:
password ($setpass)
role = member
usergroup = <unset>
minor = <unset>
guardian-user-email = <unset>
</accordion>
</info_box>
[Select CVS file] button as it currently exists
checkbox "Ignore first row (header)"
**** Do not include the following text in the details above, they are your build instrcutions
Build Instructions
- validate all CVS requirements, skip rows that do not mean requirements
- even if ignore first row is unchecked, check first header row for any values in "FULL" format, if true ignore row

1001
CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

75
Caddyfile.example Normal file
View 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
# }

View File

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

@@ -0,0 +1,152 @@
# RosterChirp — Feature Reference
> **Current version:** 0.12.42
> **Application types:** RosterChirp-Chat · RosterChirp-Brand · RosterChirp-Team
---
## All Users
### Messaging
- **Public Messages** — Read and post in public group channels open to all members. Channels can be marked read-only by an admin (announcements-style).
- **Private Group Messages** — Participate in named private groups with a specific set of members.
- **Direct Messages (U2U)** — Start a private one-on-one conversation with any user who has not blocked direct messages.
- **Group Messages** — Access managed private group conversations assigned to you through User Groups (requires RosterChirp-Team).
- **Message History** — Scroll back through conversation history with paginated loading (50 messages per page).
- **Message Reactions** — React to any message with an emoji.
- **Image Sharing** — Attach and send images in any conversation.
- **Reply Threading** — Reply to a specific message to preserve context.
- **@Mentions** — Mention users by name; mentioned users receive a notification badge.
- **Link Previews** — URLs pasted into messages automatically generate a title/image preview card.
- **Message Deletion** — Authors can delete their own messages; deleted messages are replaced with a tombstone.
### Schedule (requires RosterChirp-Team)
- **Calendar View** — Browse events in a full monthly calendar grid (desktop) or a day-list view (mobile).
- **Event Details** — Tap any event to view its full details: date/time, location, description, event type, assigned user groups, and recurrence pattern.
- **Availability Response** — Respond to events with **Going**, **Maybe**, or **Not Going**, plus an optional short note (up to 20 characters).
- **Bulk Availability** — Respond to multiple pending events at once from a single screen.
- **Response Summary** — See how many group members have responded Going / Maybe / Not Going on any event you are assigned to.
- **Filter & Search** — Filter the calendar by event type, keyword, or your own availability status. Keyword search supports word-boundary matching and exact quoted terms.
### Profile & Account
- **Display Name** — Set a public display name shown alongside your username (must be unique).
- **Avatar** — Upload a custom profile photo. A consistent colour avatar is generated automatically from your name if no photo is set.
- **About Me** — Add a short bio visible on your profile.
- **Hide Admin Tag** — Admins can choose to hide the "Admin" role badge on their messages.
- **Block Direct Messages** — Opt out of receiving unsolicited direct messages from other users.
- **Change Password** — Change your own account password at any time.
- **Font Scale** — Adjust the interface text size (80%200%) stored per-device.
### Notifications & Presence
- **Push Notifications** — Receive push notifications for new messages when the app is backgrounded. Supports Android (Firebase Cloud Messaging) and iOS 16.4+ PWA (Web Push / VAPID).
- **Notification Permission** — Grant or revoke push notification permission from the Notifications tab in your profile.
- **Unread Badges** — Conversations with unread messages display a count badge in the sidebar and on the PWA app icon.
- **Online Presence** — A green indicator shows which users are currently active. Last-seen time is displayed for offline users.
- **Browser Tab Badge** — The page title and PWA icon badge update with the total unread count across all conversations.
### App Experience
- **Progressive Web App (PWA)** — Install RosterChirp to your home screen on Android, iOS, and desktop for a native app feel.
- **Dark / Light Theme** — The interface respects your operating system's colour scheme preference automatically.
- **Mobile-Optimised Layout** — A dedicated mobile layout with a slide-in sidebar, swipe-back navigation, and mobile-native time/date pickers.
- **Keyboard Shortcuts** — Press Enter to send messages; Escape to dismiss modals.
---
## Managers (Tool Managers)
Tool Manager access is granted by an admin to members of one or more designated **User Groups**. Managers have access to the following tools in addition to all user features.
### User Manager
- **View All Users** — Browse the full user directory including email, role, phone, status, and last seen time.
- **Create Users** — Add individual new user accounts with name, email, role, and phone.
- **Bulk Import** — Import multiple users at once from a structured list (CSV-compatible).
- **Edit Users** — Update names, email addresses, phone numbers, and minor status for any user.
- **Suspend / Activate** — Suspend a user to block login without deleting their account or messages. Reversible at any time.
- **Reset Password** — Set a new temporary password for any user.
### Group Manager (requires RosterChirp-Team)
- **Create User Groups** — Create named user groups to organise members into teams or departments.
- **Manage Members** — Add or remove users from any user group. Member changes trigger a system notification in the group's conversation.
- **Multi-Groups** — Create a multi-group conversation that spans multiple user groups simultaneously.
- **Assign Schedule Groups** — Link user groups to schedule events to control who is invited and whose availability is tracked.
### Schedule Manager (requires RosterChirp-Team)
- **Create Events** — Create new calendar events with title, type, date/time, location, description, visibility (public/private), and assigned user groups.
- **Edit & Delete Events** — Modify or remove any event. Recurring events support editing/deleting a single occurrence, all future occurrences, or the entire series.
- **Recurring Events** — Schedule repeating events (daily, weekly, bi-weekly, monthly) with optional end date or occurrence count. Supports specific weekday selection for weekly recurrence.
- **Event Types** — Create and manage colour-coded event type categories (e.g. Training, Match, Meeting).
- **Track Availability** — Enable availability tracking on an event to collect Going / Maybe / Not Going responses from assigned group members.
- **View Full Responses** — See the complete list of who has responded and with what answer, including individual notes. The **No Response** count shows how many assigned members have not yet replied.
- **Download Availability List** — Export a formatted `.txt` file of all availability responses for an event, organised by section (Going, Maybe, Not Going, No Response) and sorted alphabetically by last name within each section.
- **Import Schedule** — Upload and preview a schedule import file, then confirm to bulk-create events.
- **Past Event Visibility** — View and manage past events in the calendar; past events are displayed in a greyed style.
---
## Admins
Admins have full access to all user and manager features plus the following administrative controls.
### User Manager (extended)
- **Delete Users** — Permanently scrub a user's account: email and name are anonymised, all their messages are marked deleted, and direct message threads become read-only. Frees the email address for re-registration immediately.
- **Assign Roles** — Promote or demote users between the **User**, **Manager**, and **Admin** roles.
### Settings
- **Message Features** — Enable or disable individual message channel types across the entire instance: Public Messages, Group Messages, Private Group Messages, and Private Messages (U2U). Disabled features are hidden from all menus, sidebars, and modals.
- **Registration** — Apply a registration code to unlock the application type (Chat / Brand / Team) and associated features. View the instance serial number and current registration status.
### Branding (requires RosterChirp-Brand or higher)
- **App Name** — Set a custom application name that appears in the header, browser tab, and push notifications.
- **Logo / Favicon** — Upload a custom logo used as the app header image and PWA icon (192×512 px generated automatically).
- **Header Colour** — Set custom header bar colours for light mode and dark mode independently.
- **Avatar Colours** — Customise the default avatar colours used for public channel icons and direct message icons.
- **Reset Branding** — Restore all branding settings to the default RosterChirp values in one click.
### Team Configuration (requires RosterChirp-Team)
- **Tool Manager Groups** — Designate one or more User Groups whose members are granted Tool Manager access (User Manager, Group Manager, Schedule Manager). Admins always have full access regardless of this setting.
### Control Panel (Host mode only — admin on the host domain)
- **Tenant Management** — View, create, suspend, and delete tenant instances from a central dashboard.
- **Assign Plans** — Set the application type (Chat / Brand / Team) for each tenant.
- **Custom Domains** — Assign a custom domain to a tenant in addition to its default subdomain.
- **Tenant Details** — View each tenant's slug, plan, status, custom domain, and creation date.
---
## Hosting & Tenant Privacy
RosterChirp supports two deployment modes configured via the `APP_TYPE` environment variable.
### Self-Hosted (Single Tenant)
`APP_TYPE=selfhost` — The default mode for teams running their own private instance. All data is stored in a single PostgreSQL schema. There are no subdomains or tenant concepts; the application runs at the root of whatever domain or IP the server is deployed on.
### RosterChirp-Host (Multi-Tenant)
`APP_TYPE=host` — Enables multi-tenant hosting from a single server. Each tenant is provisioned with:
- **A unique slug** — for example, the slug `acme` creates a dedicated instance accessible at `acme.yourdomain.com`. The slug is set at provisioning time and forms the permanent subdomain for that tenant.
- **An isolated Postgres schema** — every tenant's data (users, messages, groups, events, settings) lives in its own named schema (`tenant_acme`, etc.) within the same database. No data is shared between tenants.
- **An optional custom domain** — a tenant can be mapped to a fully custom domain (e.g. `chat.acme.com`) in addition to its default subdomain. Custom domain lookups are cached for performance.
- **Plan-level feature control** — each tenant can be assigned a different application type (Chat / Brand / Team), enabling per-tenant feature gating from the host control panel.
### Privacy & Isolation Guarantees
- **Schema isolation** — all database queries are scoped to the tenant's schema. A query in one tenant's context cannot read or write another tenant's tables.
- **Socket room isolation** — all real-time socket rooms are prefixed with the tenant schema name (`acme:group:42`). Events emitted in one tenant's rooms cannot reach sockets in another tenant.
- **Online presence isolation** — the online user map is keyed by `schema:userId`, preventing user ID collisions between tenants from leaking presence data.
- **Session isolation** — JWT tokens are validated against the tenant schema. A valid token for one tenant is not accepted by another.
- **Host control plane separation** — the host admin control panel is only accessible on the host's own root domain, protected by a separate `HOST_ADMIN_KEY`, and hidden from all tenant subdomains.

650
README.md
View File

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

View File

@@ -0,0 +1,311 @@
# FCM PWA Implementation Notes
_Reference for applying FCM fixes to other projects_
---
## Part 1 — Guide Key Points (fcm_details.txt)
### How FCM works (the correct flow)
1. User grants notification permission
2. Firebase generates a unique FCM token for the device
3. Token is stored on your server for targeting
4. Server sends push requests to Firebase
5. Firebase delivers notifications to the device
6. Service worker handles display and click interactions
### Common vibe-coding failures with FCM
**1. Service worker confusion**
Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail.
**2. Deprecated API usage**
Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is:
```javascript
const token = await messaging.getToken({
vapidKey: VAPID_KEY,
serviceWorkerRegistration: registration
});
```
**3. Token generation without durable storage**
Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart.
**4. Poor permission flow**
Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit.
**5. Missing notificationclick handler**
Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app.
**6. Silent failures**
Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback.
**7. iOS blind spots**
iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop.
### Correct `getToken()` pattern (from guide)
```javascript
// Register SW first, then pass it directly to getToken
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
const token = await getToken(messaging, {
vapidKey: VAPID_KEY,
serviceWorkerRegistration: registration
});
if (!token) throw new Error('getToken() returned empty — check VAPID key and SW');
```
### Correct `firebase-messaging-sw.js` pattern (from guide)
```javascript
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
firebase.initializeApp({ /* config */ });
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
self.registration.showNotification(payload.notification.title, {
body: payload.notification.body,
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'fcm-notification',
data: payload.data
});
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'close') return;
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) return client.focus();
}
if (clients.openWindow) return clients.openWindow('/');
})
);
});
```
---
## Part 2 — Code Fixes Applied to fcm-app
### app.js fixes
**Fix: `showUserInfo()` missing**
Function was called on login and session restore but never defined — crashed immediately on login.
```javascript
function showUserInfo() {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('userInfo').style.display = 'block';
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
}
```
**Fix: `setupApp()` wrong element IDs**
`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML.
```javascript
// Wrong
document.getElementById('sendNotification').addEventListener('click', sendNotification);
// Fixed
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
// Also added id="logoutBtn" to the logout button in index.html
```
**Fix: `logout()` not clearing localStorage**
Session was restored on next page load even after logout.
```javascript
function logout() {
currentUser = null;
fcmToken = null;
localStorage.removeItem('currentUser'); // was missing
// ...
}
```
**Fix: Race condition in messaging initialization**
`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise:
```javascript
let initPromise = null;
function initializeFirebase() {
if (initPromise) return initPromise;
initPromise = navigator.serviceWorker.register('/sw.js')
.then((registration) => {
swRegistration = registration;
messaging = firebase.messaging();
})
.catch((error) => { initPromise = null; throw error; });
return initPromise;
}
// In login():
await initializeFirebase(); // ensures messaging is ready before getToken()
```
**Fix: `deleteToken()` invalidating tokens on every page load**
`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0.
Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`.
**Fix: Session restore without re-registering token**
When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token.
```javascript
// In setupApp(), after restoring session:
if (Notification.permission === 'granted') {
initializeFirebase()
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
.then(token => { if (token) return registerToken(currentUser, token); })
.catch(err => console.error('Token refresh on session restore failed:', err));
}
```
**Fix: Deprecated VAPID/SW API replaced**
```javascript
// Removed (deprecated):
messaging.usePublicVapidKey(VAPID_KEY);
messaging.useServiceWorker(registration);
const token = await messaging.getToken();
// Replaced with:
const VAPID_KEY = 'your-vapid-key';
let swRegistration = null;
// swRegistration set inside initializeFirebase() .then()
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
```
**Fix: Null token guard**
`getToken()` can return null — passing null to the server produced a confusing 400 error.
```javascript
if (!token) {
throw new Error('getToken() returned empty — check VAPID key and service worker');
}
```
**Fix: Error message included server response**
```javascript
// Before: throw new Error('Failed to register token');
// After:
throw new Error(`Server returned ${response.status}: ${errorText}`);
```
**Fix: Duplicate foreground message handlers**
`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners.
```javascript
let foregroundHandlerSetup = false;
function handleForegroundMessages() {
if (foregroundHandlerSetup) return;
foregroundHandlerSetup = true;
messaging.onMessage(/* ... */);
}
```
**Fix: `login()` event.preventDefault() crash**
Button called `login()` with no argument, so `event.preventDefault()` threw on undefined.
```javascript
async function login(event) {
if (event) event.preventDefault(); // guard added
```
**Fix: `firebase-messaging-sw.js` redirect stub replaced**
File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above).
**Fix: `notificationclick` handler added to `sw.js`**
Clicking a background notification did nothing. Handler added to focus existing window or open a new one.
**Fix: CDN URLs removed from `urlsToCache` in `sw.js`**
External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install.
```javascript
// Removed from urlsToCache:
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js',
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'
```
### server.js fixes
**Fix: `icon`/`badge`/`tag` in wrong notification object**
These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`).
```javascript
// Wrong:
notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }
// Fixed:
notification: { title, body },
webpush: {
notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' },
// ...
}
```
**Fix: `saveTokens()` in route handler not crash-safe**
```javascript
try {
saveTokens();
} catch (saveError) {
console.error('Failed to persist tokens to disk:', saveError);
}
```
**Fix: `setInterval(saveTokens)` uncaught exception crashed the server**
An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state.
```javascript
setInterval(() => {
try { saveTokens(); }
catch (error) { console.error('Auto-save tokens failed:', error); }
}, 30000);
```
---
## Part 3 — Docker / Infrastructure Fixes
### Root cause of "no other users" bug
The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain:
1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`)
2. This propagated out of `setInterval` as an uncaught exception
3. Node.js exited the process
4. Docker restarted the container with empty state
5. Tokens were never on disk, so restart = all tokens lost
### Dockerfile fix
```dockerfile
# Create non-root user AND a writable data directory (while still root)
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
mkdir -p /app/data && \
chown nodejs:nodejs /app/data
```
`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it.
### docker-compose.yml fix
```yaml
services:
your-app:
volumes:
- app_data:/app/data # named volume survives container rebuilds
volumes:
app_data:
```
Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`.
### server.js path fix
```javascript
// Changed from:
const TOKENS_FILE = './tokens.json';
// To:
const TOKENS_FILE = './data/tokens.json';
```
---
## Checklist for applying to another project
- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub)
- [ ] `notificationclick` handler present in service worker
- [ ] CDN URLs NOT in `urlsToCache` in any service worker
- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()`
- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker`
- [ ] `deleteToken()` is NOT called on page load
- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'`
- [ ] Null/empty token check before sending to server
- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification`
- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval`
- [ ] Docker: data directory created with correct user ownership in Dockerfile
- [ ] Docker: named volume mounted for data directory in docker-compose.yml
- [ ] Duplicate foreground message handler registration is guarded

View File

@@ -0,0 +1,31 @@
# RosterChirp — Future Feature Requests
---
## Markdown Message Rendering
**Request:** Render markdown-formatted text in the message window.
**Scope recommendation:** Start with inline-only markdown (bold, italic, inline code, strikethrough, links). Full block-level markdown (headers, lists, tables, code blocks) is a follow-on.
### Why inline-only first
- Zero risk of misformatting existing plain-text messages
- Full markdown risks rendering existing content oddly (e.g. a message starting with `1.` or `#` rendering as a list/header)
### Implementation notes
- Integration point is `formatMsgContent()` in `Message.jsx` — already returns HTML for `dangerouslySetInnerHTML`; a markdown parser slots straight in
- Add `marked` npm package (~13KB) for parsing
- Add `DOMPurify` for XSS sanitization — **required**, markdown allows raw HTML passthrough and content comes from other users
- `marked` config: `{ breaks: true }` to preserve single-newline-as-`<br>` behaviour, `{ mangle: false, headerIds: false }` to suppress heading anchors
- Apply markdown parse first, then @mention substitution (so `**@[Name]**` renders correctly)
- Remove the existing URL regex linkifier in `formatMsgContent` — markdown handles links natively
- Strip markdown from reply previews (currently shows raw `**text**`)
- `handleCopy` copies `msg.content` (raw markdown source) — correct behaviour, no change needed
- Emoji-only detection runs on raw content before rendering — no change needed
- Compose box stays plain textarea for v1; no preview toolbar required
### Effort estimate
| Scope | Estimate |
|---|---|
| Inline-only (bold, italic, code, strikethrough) | ~1.5 hours |
| Full markdown (+ block elements, CSS for bubbles) | ~45 hours |

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
RULE: minor age is when DOB is 15 years and under.
User manager:
Enable "Date of Birth" field (default optional unless "Mixed Age" is selected in the (new) as "Login Type" on Settings modal page)
Enable "Guardian" field as optional
My Profile modal:
Change: replace tab buttons with a select list labled "SELECT OPTION:" Profile - default selected (on both desktop and mobile)
On the "Profile" tab, add two new text inputs: Date of Birth and Phone (same format field verification as used in the user manager field) - once saved, user manager is updated with data from these two fields for the given user
Add a new select option "Add Child" (only displayed IF the new "Login Type" setting has either "Guardians Only" or "Mixed Age" selected.
"Add Child" Form:
- (on user's my profile): Firstname, Lastname, Email, DOB, Profile avatar (follow avatar upload rules), number (input box), Add button, Save button (disabled until there is a child added to guardian's child list)
- Clicking the Add button will add child to the guardians child list.)
- Clicking save, will save the the guardian's child list and will each child entry to players user group.
Settings modal:
Change: replace tab tabs buttons to a select list, labelled "SELECT OPTION" Messages is still the default option (on both desktop and mobile)
Add new selection "Login Type" to the list, form details below:
An option list with brief description under:
Option: "Guardian Only"
Descriptions: "Parents are required to add their child's details in their profile. They will respond on behalf of the child for events with availability tracking for the "players" group".
- User manager DOB is optional
- "Players" user group entry will be the child's name as an alias to the guardians account, if more than one child each entry will be treated uniquely for mentions and event availabillty responses.
- "Players" User Group DM will be disabled/hidden and cannot be added to Multi-Group DMs
- The event modal with Availabilty requested for the "players" user group, will have a new drop down select list (under the response buttons, above the note input box - hidden/disabled by default) and will only be unhidden/enable if the event includes the "players" user group, and only displayed for user who have a saved child list of at least one. The select list options will include the guardians child list, AND will also the guardian's name IF multiple user groups are selected for the event, with one be players, and at least one being a user group that the guardian is a member of that is not the players group. There will be a default option of "ALL", if selected, the response and note (if entered) will be the same for all "people" listed in the select list, but responses will be listed indivually in the response list for the event.
Scenario 1: Event: party, track availability enabled, groups selected: players + parents
- select drop down will display option: "All" default selected, then guadians name on row 2 (parent user group), each child's name on susequent rows (listed in the players user group owned by the guardian)
- selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each
- selecting an idividual name will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list
Scenario 2: Event, track availability enabled, groups selected: players
- select drop down will display "All" on the top row, each child's name (listed in the players user group and owned by the guardian)
- selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each
- selecting an idividual name in the selectlist will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list
Scenario 3: Event, track availability enabled, groups selected: parents
- select drop down is hidden
- availability response and note is for the guardian only
Option: "Mixed Age"
Descriptions: "Parents, or user managers, are required to add the minor aged child's user account to the guardians user profile. Minor aged users cannot login until this is complete."
- User manager DOB is required for all users
- Minor aged user's account are automatically suspended
on the add child form:
- search user input field, will display only a list of minor aged users. Selecting a user fills out the "Add Child" read-only form
- "Add Child" Form all fields are read-only: Firstname, Lastname, DOB*, user's avatar, number (input box), Add button when form is filled by selecting a user, Save button (disabled until there is a child added to guardian's child list). Requires admin approval. A message is sent to all users with the managers from from the default admin user account indicating "User Manager requires approval".
- The minor user account name that requires gaurdina name approval is bold red in the user manager list. When the "editing" user account, the Gaurdian field will be highlighted in red. Besides the label will be two link options [approve - green] [deny - red] (same size font as the label). Approve will clear the approval required flag and unsuspend the user account, with the guardian name save to the user's profile.
- Saving the form will update the minor aged user account with a guardian name, requires a user with manager role approval, once approved, the minor's user account is unsuspended.
- Events are handled like they are currently
- Guardians are not part of players user group DMs
- Guardians do not respond to availability on the childs behalf (new select list on the event form remains hidden/diabled)
- any private messages initiated by a user 18 years or older to a minor aged will automatically include the guardian user account as well.
-> a modal confirmation will be provide to the 18+ user that they are messaging a user that will also include their parent/guardian.
Option: All Ages (default selection)
- "Add Child" hidden/disbled Profile select option list.
- Aliase select list is hidden/disabled
- Events are handled like they are currently
Yellow warning symbol and text below: "This setting can only be set/changed when the user table in the database is empty." (form is read-only unless user manager is empty OR only has users with the admin role).
- if the setting is changed and users exisit with admin role, on each subsequent login the user's profile modal popup until the Date of Birth is entered.

View File

@@ -0,0 +1,77 @@
Gemini said
Ive reviewed your Service Worker code. Its well-structured, but there is a critical architectural "race condition" occurring here that is likely causing the "hit or miss" behavior on mobile.
On a desktop, the browser keeps the Service Worker "warm" or gives it plenty of time to boot. On mobile (especially Android), the OS is much more aggressive.
1. The onBackgroundMessage vs. event.waitUntil Problem
In your Firebase Messaging block:
JavaScript
messaging.onBackgroundMessage((payload) => {
// ... logic ...
return showRosterChirpNotification({...});
});
The Flaw: The Firebase onBackgroundMessage handler is a callback, but it is not being awaited by the Service Worker's underlying push event.
When a push arrives, the browser fires a native push event. The Firebase SDK intercepts this and triggers your onBackgroundMessage. However, because you aren't explicitly telling the browser to "stay awake" while Firebase does its thing, the mobile OS often kills the Service Worker thread before the Firebase SDK even finishes parsing the message or before showRosterChirpNotification completes.
2. The "Double Listener" Conflict
You have both messaging.onBackgroundMessage and a manual self.addEventListener('push').
You added a check if (messaging) return; inside the push listener to prevent double-firing.
The Catch: In 2026, the firebase-messaging-compat SDK actually needs that native push event to stay alive. By returning early, you might be accidentally telling the browser "I'm done here," allowing it to terminate the process before the Firebase logic finishes.
The Fix: "Promisifying" the Background Handler
To fix the reliability on mobile, you need to ensure the native push event wraps the Firebase logic in a Promise.
Modify your code to look like this:
JavaScript
// 1. Remove the return showRosterChirpNotification from onBackgroundMessage
// 2. Wrap the logic in the native 'push' event to guarantee execution.
self.addEventListener('push', (event) => {
console.log('[SW] Push received');
// We use event.waitUntil to tell the Mobile OS: "Don't kill me yet!"
event.waitUntil(
// If Firebase is ready, we let it process, but we must make sure
// we return a promise that resolves only AFTER the notification shows.
Promise.resolve().then(async () => {
try {
// You can still use Firebase logic, but ensure it's awaited.
// Or, for maximum mobile reliability, parse the event.data directly:
const payload = event.data ? event.data.json() : null;
if (payload) {
// Manually handle the notification display here to ensure
// the 'waitUntil' scope covers it.
return showRosterChirpNotification({
title: payload.notification?.title || payload.data?.title || 'New Message',
body: payload.notification?.body || payload.data?.body || '',
url: payload.data?.url || '/',
groupId: payload.data?.groupId || ''
});
}
} catch (err) {
console.error('[SW] Push processing failed', err);
}
})
);
});
3. Why Desktop works but Mobile fails
Desktop: The browser process is persistent. It waits for the Firebase callback to finish even if you don't use event.waitUntil.
Mobile: The moment the push event function finishes executing (which happens instantly if it's just a bunch of if statements and callbacks), the OS is allowed to freeze the thread.
4. HAProxy / PFSense Check (The "Vary" Header)
Since you are using importScripts for Firebase:
If HAProxy is caching your sw.js but serving it with a content-type or encoding that makes the mobile browser hesitate, it will fail to update. Ensure HAProxy isn't stripping the Service-Worker-Allowed header if you've set one, and make sure your server sends Cache-Control: no-cache for the sw.js file specifically.
Summary of adjustments:
Remove the if (messaging) return; from your push listener.
Consolidate your logic inside the push listener using event.waitUntil().
Check Firebase Console: Ensure the messages you send from your server are marked as High Priority (priority: 'high'), otherwise mobile devices will "batch" them and they'll arrive 10-20 minutes late.

View File

@@ -0,0 +1,880 @@
# RosterChirp — Complete Vibe-Code Build Prompt (v0.13.1)
> **How to use this document**
> Paste the contents of any single section (or the whole document) as your opening prompt when starting a new AI coding session. The more context you give upfront, the fewer clarifying rounds you need. This document reflects the real production build of RosterChirp as of v0.12.53.
---
## Part 1 — What to Build (Product Brief)
Build a **self-hosted team chat Progressive Web App** called **RosterChirp**.
It is a full-stack, single-container application that runs entirely inside Docker. Users install it on a private server and access it via a browser or as an installed PWA on desktop/mobile. It supports two deployment modes:
| Mode | Description |
|---|---|
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
| `host` | Multi-tenant — one Postgres schema per tenant, provisioned at `{slug}.{HOST_DOMAIN}`. |
### Core philosophy
- Simple to self-host (one `docker compose up`)
- Works as an installed PWA on Android, iOS, and desktop Chrome/Edge
- Instant real-time messaging via WebSockets (Socket.io)
- Push notifications via Firebase Cloud Messaging (FCM), works when app is backgrounded
- Multi-tenant via Postgres schema isolation (not separate databases)
---
## Part 2 — Tech Stack
| Layer | Technology |
|---|---|
| Backend runtime | Node.js 20 (Alpine) |
| Backend framework | Express.js |
| Real-time | Socket.io (server + client) |
| Database | PostgreSQL 16 via `pg` npm package |
| Auth | JWT in HTTP-only cookies + localStorage, `jsonwebtoken`, `bcryptjs` |
| Push notifications | Firebase Cloud Messaging (FCM) — Firebase Admin SDK (backend) + Firebase JS SDK (frontend) |
| Image processing | `sharp` (avatar/logo resizing) |
| Frontend framework | React 18 + Vite (PWA) |
| Frontend styling | Plain CSS with CSS custom properties (no Tailwind, no CSS modules) |
| Emoji picker | `@emoji-mart/react` + `@emoji-mart/data` |
| Markdown rendering | `marked` (for help modal) |
| Container | Docker multi-stage build (builder stage for Vite, runtime stage for Node) |
| Orchestration | `docker-compose.yaml` (selfhost) + `docker-compose.host.yaml` (multi-tenant) |
| Reverse proxy | Caddy (SSL termination) |
### Key npm packages (backend)
```
express, socket.io, pg, bcryptjs, jsonwebtoken,
cookie-parser, cors, multer, sharp,
firebase-admin, node-fetch
```
### Key npm packages (frontend)
```
react, react-dom, vite, socket.io-client,
@emoji-mart/react, @emoji-mart/data, marked,
firebase
```
---
## Part 3 — Project File Structure
```
rosterchirp/
├── CLAUDE.md
├── Dockerfile
├── build.sh # VERSION="${1:-X.Y.Z}" — bump here + both package.json files
├── docker-compose.yaml # selfhost
├── docker-compose.host.yaml # multi-tenant host mode
├── Caddyfile.example
├── .env.example
├── about.json.example
├── backend/
│ ├── package.json # version bump required
│ └── src/
│ ├── index.js # Express app, Socket.io, tenant middleware wiring
│ ├── middleware/
│ │ └── auth.js # JWT auth, teamManagerMiddleware
│ ├── models/
│ │ ├── db.js # Postgres pool, query helpers, migrations, seeding
│ │ └── migrations/ # 001008 SQL files, auto-applied on startup
│ └── routes/
│ ├── auth.js # receives io
│ ├── groups.js # receives io
│ ├── messages.js # receives io
│ ├── usergroups.js # receives io
│ ├── schedule.js # receives io
│ ├── users.js
│ ├── settings.js
│ ├── push.js
│ ├── host.js # RosterChirp-Host control plane only
│ ├── about.js
│ └── help.js
└── frontend/
├── package.json # version bump required
├── vite.config.js
├── index.html # viewport: user-scalable=no (pinch handled via JS)
├── public/
│ ├── manifest.json
│ ├── sw.js # service worker / FCM push
│ └── icons/
└── src/
├── App.jsx
├── main.jsx # pinch→font-scale handler, pull-to-refresh blocker, iOS keyboard fix
├── index.css # CSS vars, dark mode, --font-scale, mobile autofill fixes
├── contexts/
│ ├── AuthContext.jsx
│ ├── SocketContext.jsx # force transports: ['websocket']
│ └── ToastContext.jsx
├── pages/
│ ├── Chat.jsx # main shell, page routing, all socket wiring
│ ├── Login.jsx
│ ├── ChangePassword.jsx
│ ├── UserManagerPage.jsx
│ └── GroupManagerPage.jsx
└── components/
├── Sidebar.jsx # groupMessagesMode prop
├── ChatWindow.jsx
├── MessageInput.jsx # onTextChange prop, fixed font size (no --font-scale)
├── Message.jsx # fonts scaled via --font-scale
├── NavDrawer.jsx
├── SchedulePage.jsx # ~1600 lines, desktop+mobile views
├── MobileEventForm.jsx
├── Avatar.jsx # consistent colour algorithm — must match Sidebar + ChatWindow
├── PasswordInput.jsx
├── GroupInfoModal.jsx
├── ProfileModal.jsx # appearance tab: font-scale slider (saved), pinch is session-only
├── SettingsModal.jsx
├── BrandingModal.jsx
├── HostPanel.jsx
├── NewChatModal.jsx
├── UserFooter.jsx
├── GlobalBar.jsx
├── ImageLightbox.jsx
├── UserProfilePopup.jsx
├── AddChildAliasModal.jsx
├── ScheduleManagerModal.jsx
├── ColourPickerSheet.jsx
└── SupportModal.jsx
```
### Dead code (safe to delete)
- `frontend/src/pages/HostAdmin.jsx`
- `frontend/src/components/UserManagerModal.jsx`
- `frontend/src/components/GroupManagerModal.jsx`
- `frontend/src/components/MobileGroupManager.jsx`
---
## Part 4 — Database Architecture
### Connection pool (`db.js`)
```javascript
const pool = new Pool({
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'rosterchirp',
user: process.env.DB_USER || 'rosterchirp',
password: process.env.DB_PASSWORD || '',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
```
### Query helpers
```javascript
query(schema, sql, params) // SET search_path then SELECT
queryOne(schema, sql, params) // returns first row or null
queryResult(schema, sql, params) // returns full result object
exec(schema, sql, params) // INSERT/UPDATE/DELETE
withTransaction(schema, async (client) => { ... })
```
`SET search_path TO {schema}` is called before every query. `assertSafeSchema(name)` validates all schema names against `/^[a-z_][a-z0-9_]*$/`.
### Migrations
Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` are applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
Current migrations: 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users) → 007 (FCM push) → 008 (rebrand)
### Seeding order
`seedSettings → seedEventTypes → seedAdmin → seedUserGroups`
All seed functions use `ON CONFLICT DO NOTHING`.
### Core tables (PostgreSQL — schema-qualified at query time)
```sql
users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'suspended'
is_default_admin BOOLEAN DEFAULT FALSE,
must_change_password BOOLEAN DEFAULT TRUE,
avatar TEXT,
about_me TEXT,
display_name TEXT,
hide_admin_tag BOOLEAN DEFAULT FALSE,
allow_dm INTEGER DEFAULT 1,
last_online TIMESTAMPTZ,
help_dismissed BOOLEAN DEFAULT FALSE,
date_of_birth DATE,
phone TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT DEFAULT 'public',
owner_id INTEGER REFERENCES users(id),
is_default BOOLEAN DEFAULT FALSE,
is_readonly BOOLEAN DEFAULT FALSE,
is_direct BOOLEAN DEFAULT FALSE,
is_managed BOOLEAN DEFAULT FALSE, -- managed private groups (Group Messages mode)
direct_peer1_id INTEGER,
direct_peer2_id INTEGER,
track_availability BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
group_members (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(group_id, user_id)
)
messages (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT,
type TEXT DEFAULT 'text', -- 'text' | 'system'
image_url TEXT,
reply_to_id INTEGER REFERENCES messages(id),
is_deleted BOOLEAN DEFAULT FALSE,
is_readonly BOOLEAN DEFAULT FALSE,
link_preview JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
)
reactions (
id SERIAL PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(message_id, user_id, emoji)
)
notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
message_id INTEGER,
group_id INTEGER,
from_user_id INTEGER,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
)
active_sessions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device TEXT NOT NULL DEFAULT 'desktop', -- 'mobile' | 'desktop'
token TEXT NOT NULL,
ua TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, device)
)
-- One session per device type per user. New login on same device displaces old session.
-- Displaced socket receives 'session:displaced' event.
push_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device TEXT DEFAULT 'desktop',
fcm_token TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, device)
)
settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
-- Feature flag keys: feature_branding ('true'/'false'), feature_group_manager,
-- feature_schedule_manager, app_type ('RosterChirp-Chat'/'RosterChirp-Brand'/'RosterChirp-Team')
user_group_names (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (user_id, group_id)
)
user_groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
colour TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
user_group_members (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (user_group_id, user_id)
)
group_user_groups (
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, user_group_id)
)
events (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN DEFAULT FALSE,
is_public BOOLEAN DEFAULT TRUE,
created_by INTEGER REFERENCES users(id),
event_type_id INTEGER REFERENCES event_types(id),
recurrence_rule JSONB,
track_availability BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
event_types (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
colour TEXT NOT NULL DEFAULT '#1a73e8',
created_at TIMESTAMPTZ DEFAULT NOW()
)
event_availability (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL, -- 'going' | 'maybe' | 'not_going'
note TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(event_id, user_id)
)
tenants (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
schema_name TEXT UNIQUE NOT NULL,
display_name TEXT,
custom_domain TEXT,
status TEXT DEFAULT 'active',
plan TEXT DEFAULT 'team',
created_at TIMESTAMPTZ DEFAULT NOW()
)
```
---
## Part 5 — Multi-Tenant Architecture (Host Mode)
### Tenant resolution
`tenantMiddleware` in `index.js` sets `req.schema` from the HTTP `Host` header before any route runs:
```javascript
// Subdomain tenants: {slug}.{HOST_DOMAIN} → schema 'tenant_{slug}'
// Custom domains: looked up in tenants table custom_domain column
// Host admin: HOST_DOMAIN itself → schema 'public'
const tenantDomainCache = new Map(); // in-process cache, cleared on tenant update
```
### Socket room naming (tenant-isolated)
All socket rooms are prefixed with the tenant schema:
```javascript
const R = (schema, type, id) => `${schema}:${type}:${id}`;
// e.g. R('tenant_acme', 'group', 42) → 'tenant_acme:group:42'
// Room types: group:{id}, user:{id}, schema:all
```
### Online user tracking
```javascript
const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
// Key includes schema to prevent cross-tenant ID collisions
```
---
## Part 6 — Auth & Session System
- JWT stored in **HTTP-only cookie** (`token`) AND `localStorage` (for PWA/mobile fallback)
- `authMiddleware` in `middleware/auth.js` — verifies JWT, attaches `req.user`
- `teamManagerMiddleware` — checks if user is a team manager (role-based feature access)
- **Per-device sessions**: `active_sessions` PK is `(user_id, device)` — logging in on mobile doesn't kick out desktop
- Device: `mobile` if `/Mobile|Android|iPhone/i.test(ua)`, else `desktop`
- `must_change_password = true` redirects to `/change-password` after login
- `ADMPW_RESET=true` env var resets default admin password on container start
---
## Part 7 — Real-time Architecture (Socket.io)
### Connection
- Socket auth: JWT in `socket.handshake.auth.token`
- On connect: user joins `R(schema, 'group', id)` for all their groups, and `R(schema, 'user', userId)` for direct notifications, and `R(schema, 'schema', 'all')` for tenant-wide broadcasts
### Routes that receive `io`
```javascript
// All of these are called as: require('./routes/foo')(io)
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
```
### Key socket events (server → client)
| Event | When |
|---|---|
| `message:new` | new message in a group |
| `message:deleted` | soft delete |
| `reaction:updated` | reaction toggled |
| `typing:start` / `typing:stop` | typing indicator |
| `notification:new` | mention or private message |
| `group:updated` | group settings changed |
| `group:removed` | user removed from group |
| `user:online` / `user:offline` | presence change |
| `users:online` | full online user list (on request) |
| `session:displaced` | same device logged in elsewhere |
| `schedule:event-created/updated/deleted` | schedule changes |
### Reconnect strategy (SocketContext.jsx)
```javascript
const socket = io({ transports: ['websocket'] }); // websocket only — no polling
// reconnectionDelay: 500, reconnectionDelayMax: 3000, timeout: 8000
// visibilitychange → visible: call socket.connect() if disconnected
```
---
## Part 8 — FCM Push Notifications
### Architecture
```
Frontend (browser/PWA)
└─ Chat.jsx
├─ GET /api/push/firebase-config → fetches SDK config
├─ Initialises Firebase JS SDK + getMessaging()
├─ getToken(messaging, { vapidKey }) → FCM token
└─ POST /api/push/subscribe → registers in push_subscriptions
Backend (push.js)
├─ sendPushToUser(schema, userId, payload) → called from messages.js (primary)
│ and index.js socket handler (fallback)
└─ Firebase Admin SDK → Google FCM servers → device
```
### Message payload
```javascript
{
token: sub.fcm_token,
notification: { title, body },
data: { url: '/', groupId: '42' },
android: { priority: 'high', notification: { sound: 'default' } },
webpush: { headers: { Urgency: 'high' }, fcm_options: { link: url } },
}
```
### Push trigger logic (messages.js)
- Frontend sends messages via `POST /api/messages/group/:groupId` (REST), not socket
- **Push must be fired from messages.js**, not just socket handler
- Private group: push to all `group_members` except sender
- Public group: push to all `DISTINCT user_id FROM push_subscriptions WHERE user_id != sender`
- Image messages: body `'📷 Image'`
### Stale token cleanup
`sendPushToUser` catches FCM errors and deletes the `push_subscriptions` row for:
`messaging/registration-token-not-registered`, `messaging/invalid-registration-token`, `messaging/invalid-argument`
### Required env vars
```
FIREBASE_API_KEY=
FIREBASE_PROJECT_ID=
FIREBASE_APP_ID=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_VAPID_KEY= # Web Push certificate public key (Cloud Messaging tab)
FIREBASE_SERVICE_ACCOUNT= # Full service account JSON, stringified (backend only)
```
---
## Part 9 — API Routes
All routes require `authMiddleware` except login/health.
### Auth (`/api/auth`)
- `POST /login`, `POST /logout`, `POST /change-password`, `GET /me`
### Users (`/api/users`)
- `GET /` — admin: full user list
- `POST /` — admin: create user
- `PATCH /:id` — admin: update role/status/password
- `PATCH /me/profile` — own profile (displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone)
- `POST /me/avatar` — multipart: resize to 200×200 webp
- `GET /check-display-name?name=`
### Groups (`/api/groups`)
- `GET /` — returns `{ publicGroups, privateGroups }` with last_message, peer data for DMs
- `POST /` — create group or DM
- `PATCH /:id`, `DELETE /:id`
- `POST /:id/members`, `DELETE /:id/members/:userId`, `GET /:id/members`
- `POST /:id/custom-name`, `DELETE /:id/custom-name`
### Messages (`/api/messages`)
- `GET /?groupId=&before=&limit=` — 50 per page, cursor-based
- `POST /group/:groupId` — send message (REST path, fires push)
- `POST /image` — image upload
### User Groups (`/api/usergroups`)
- CRUD for user groups (team roster groupings), member management
### Schedule (`/api/schedule`)
- CRUD for events, event types, availability tracking
- `GET /events` — with date range, keyword filter, type filter, availability filter
- `POST /events/:id/availability` — set own availability
- `GET /events/:id/availability` — get all availability for an event
### Settings (`/api/settings`)
- `GET /`, `PATCH /`, `POST /logo`, `POST /icon/:key`
### Push (`/api/push`)
- `GET /firebase-config` — returns FCM SDK config
- `POST /subscribe` — save FCM token
- `GET /debug` — admin: list tokens + firebase status
- `POST /test` — send test push to own device
### Host (`/api/host`) — host mode only
- Tenant provisioning, plan management, host admin panel
### About (`/api/about`), Help (`/api/help`)
---
## Part 10 — Frontend Architecture
### Page navigation (Chat.jsx)
`page` state: `'chat'` | `'groupmessages'` | `'schedule'` | `'users'` | `'groups'` | `'hostpanel'`
**Rule:** Every page navigation must call `setActiveGroupId(null)` and `setChatHasText(false)`.
### Group Messages vs Messages (Sidebar)
- `groupMessagesMode={false}` → public groups + non-managed private groups
- `groupMessagesMode={true}` → only `is_managed` private groups
### Unsaved text guard (Chat.jsx → ChatWindow.jsx → MessageInput.jsx)
- `MessageInput` fires `onTextChange(val)` on every keystroke and after send
- `ChatWindow` converts to boolean: `onHasTextChange?.(!!val.trim())`
- `Chat.jsx` stores as `chatHasText`; `selectGroup()` shows `window.confirm` if switching with unsaved text
- `MessageInput` resets all state on `group?.id` change via `useEffect`
### Font scale system
- CSS var `--font-scale` on `<html>` element (default `1`, range `0.8``2.0`)
- **Message fonts** use `calc(Xrem * var(--font-scale))` — they scale
- **MessageInput font** is fixed (`0.875rem`) — it does NOT scale
- **Slider** (ProfileModal appearance tab) is the saved setting — persisted to `localStorage`
- **Pinch zoom** (main.jsx touch handler) is session-only — updates `--font-scale` but does NOT write to localStorage
- On startup, `--font-scale` is initialised from the saved localStorage value
### Avatar colour algorithm
Must be **identical** across `Avatar.jsx`, `Sidebar.jsx`, `ChatWindow.jsx`:
```javascript
const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
const bg = AVATAR_COLORS[(user.name || '').charCodeAt(0) % AVATAR_COLORS.length];
```
### Notification rules (group member changes, usergroups.js)
- 1 user added/removed → named system message: `"{Name} has joined/been removed from the conversation."`
- 2+ users added/removed → generic: `"N new members have joined/been removed from the conversation."`
### User deletion (v0.11.11+)
Email → `deleted_{id}@deleted`, name → `'Deleted User'`, all messages `is_deleted=TRUE`, DMs `is_readonly=TRUE`, sessions/subscriptions/availability purged.
---
## Part 11 — Schedule / Events
- All date/time stored as `TIMESTAMPTZ`
- `buildISO(date, time)` — builds timezone-aware ISO string from date + HH:MM input
- `toTimeIn(iso)` — extracts exact HH:MM (no rounding) for edit forms
- `roundUpToHalfHour()` — default start time for new events
- New events cannot have a start date/time in the past
- Recurring events: `expandRecurringEvent` returns occurrences within requested range only
- Keyword filter: unquoted = `\bterm` (prefix match), quoted = `\bterm\b` (exact word)
- Type filter does NOT shift date window to today (unlike keyword/availability filters)
- Clearing keyword also resets `filterFromDate`
Both `SchedulePage.jsx` and `MobileEventForm.jsx` maintain their own copies of the time utilities (`roundUpToHalfHour`, `parseTypedTime`, `fmt12`, `toTimeIn`, `buildISO`).
---
## Part 12 — CSS Design System
### Variables (`:root` — light mode)
```css
--primary: #1a73e8;
--primary-dark: #1557b0;
--primary-light: #e8f0fe;
--surface: #ffffff;
--surface-variant: #f8f9fa;
--background: #f1f3f4;
--border: #e0e0e0;
--text-primary: #202124;
--text-secondary: #5f6368;
--text-tertiary: #9aa0a6;
--error: #d93025;
--success: #188038;
--bubble-out: #1a73e8;
--bubble-in: #f1f3f4;
--radius: 8px;
--font: 'Google Sans', 'Roboto', sans-serif;
--font-scale: 1; /* adjusted by pinch or slider */
```
### Dark mode (`[data-theme="dark"]`)
```css
--primary: #4d8fd4;
--primary-light: #1a2d4a;
--surface: #1e1e2e;
--surface-variant: #252535;
--background: #13131f;
--border: #2e2e45;
--text-primary: #e2e2f0;
--text-secondary: #9898b8;
--text-tertiary: #606080; /* exactly 6 hex digits — a common typo is 7 */
--bubble-out: #4d8fd4;
--bubble-in: #252535;
```
### Mobile input fixes
```css
/* Prevent iOS zoom on input focus (requires font-size >= 16px) */
@media (max-width: 768px) {
input:focus, textarea:focus, select:focus { font-size: 16px !important; }
}
/* Autofill styling */
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px var(--surface) inset !important;
-webkit-text-fill-color: var(--text-primary) !important;
}
```
### Layout
- Desktop: sidebar (320px fixed) + chat area (flex-1)
- Mobile (≤768px): sidebar and chat stack — one visible at a time
- `--visual-viewport-height` and `--visual-viewport-offset` CSS vars exposed by main.jsx for iOS keyboard handling
- `.keyboard-open` class toggled on `<html>` when iOS keyboard is visible
---
## Part 13 — Docker & Deployment
### Dockerfile (multi-stage)
```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm install
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
FROM node:20-alpine
WORKDIR /app
COPY backend/package*.json ./
RUN npm install --omit=dev
COPY backend/ ./
COPY --from=builder /app/frontend/dist ./public
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
EXPOSE 3000
CMD ["node", "src/index.js"]
```
### Version bump — all three locations
```
backend/package.json "version": "X.Y.Z"
frontend/package.json "version": "X.Y.Z"
build.sh VERSION="${1:-X.Y.Z}"
```
### Environment variables (key ones)
```
APP_TYPE=selfhost|host
HOST_DOMAIN= # host mode only
HOST_ADMIN_KEY= # host mode only
JWT_SECRET=
DB_HOST=db # set to 'pgbouncer' after Phase 1 scaling
DB_NAME=rosterchirp
DB_USER=rosterchirp
DB_PASSWORD= # avoid ! (shell interpolation issue in docker-compose)
ADMIN_EMAIL=
ADMIN_NAME=
ADMIN_PASS=
ADMPW_RESET=true|false
APP_NAME=rosterchirp
USER_PASS= # default password for bulk-created users
DEFCHAT_NAME=General Chat
FIREBASE_API_KEY=
FIREBASE_PROJECT_ID=
FIREBASE_APP_ID=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_VAPID_KEY=
FIREBASE_SERVICE_ACCOUNT= # stringified JSON, no newlines
VAPID_PUBLIC= # legacy, auto-generated, no longer used for push delivery
VAPID_PRIVATE=
```
---
## Part 14 — Scale Architecture (Planned)
### Phase 1 — PgBouncer (zero code changes)
Add PgBouncer service to `docker-compose.host.yaml`. Point `DB_HOST=pgbouncer`. Increase Node pool `max` to 100. Use `POOL_MODE=transaction`. Eliminates the 20-connection bottleneck.
### Phase 2 — Redis (horizontal scaling)
Required for multiple Node instances:
1. `@socket.io/redis-adapter` — cross-instance socket fan-out
2. Replace `onlineUsers` Map with Redis `SADD`/`SREM` presence keys (`presence:{schema}:{userId}`)
3. Replace `tenantDomainCache` Map with Redis hash + TTL
4. Move uploads to Cloudflare R2 (S3-compatible) — `@aws-sdk/client-s3`
5. Force WebSocket transport only (eliminates polling sticky-session concern)
**Note on Phase 2:** `SET search_path` per query is safe with PgBouncer in transaction mode. Do NOT use `LISTEN/NOTIFY` or session-level state through PgBouncer.
---
## Part 15 — Known Gotchas & Decisions
| Gotcha | Solution |
|---|---|
| Multi-tenant schema isolation | Every query must go through `query(schema, ...)` — never raw `pool.query` |
| `assertSafeSchema()` | Always validate schema names before use — injection risk |
| Socket room names include schema | `R(schema, 'group', id)` not bare `group:{id}` — cross-tenant leakage otherwise |
| `onlineUsers` key is `${schema}:${userId}` | Two tenants can share the same integer user ID |
| FCM push fired from messages.js REST route | Frontend uses REST POST, not socket, for sending messages |
| Pinch zoom is session-only | Remove `localStorage.setItem` from touchend — slider is the saved setting |
| MessageInput font is fixed | Do not apply `--font-scale` to `.msg-input` font-size |
| iOS keyboard layout | Use `--visual-viewport-height` CSS var, not `100vh`, for the chat layout height |
| Avatar colour algorithm | Must be identical in Avatar.jsx, Sidebar.jsx, and ChatWindow.jsx |
| `is_managed` groups | Managed private groups appear in Group Messages view, not regular Messages view |
| Migrations are SQL files | Not try/catch ALTER TABLE — numbered SQL files in `migrations/` applied in order |
| DB_PASSWORD must not contain `!` | Shell interpolation breaks docker-compose env parsing |
| Routes accept `io` as parameter | `module.exports = (io) => router` — not default export |
| `session:displaced` socket event | Sent to the old socket when a new login displaces a session on the same device type |
| `help.md` is NOT in the volume path | Must be at `backend/src/data/help.md` — not in `/app/data/` which is volume-mounted |
| Dark mode `--text-tertiary` | Exactly 6 hex digits: `#606080` not `#6060808` |
| Web Share API for mobile file download | Use `navigator.share({ files: [...] })` on mobile; fall back to `a.click()` download on desktop |
---
## Part 16 — Features Checklist
### Messaging
- [x] Text messages with URL auto-linking and @mentions
- [x] Image upload + lightbox
- [x] Link preview cards (og: meta, server-side fetch)
- [x] Reply-to with quoted preview
- [x] Emoji reactions (quick bar + full picker)
- [x] Message soft-delete
- [x] Typing indicator
- [x] Date separators, consecutive-message collapsing
- [x] System messages
- [x] Emoji-only messages render larger
- [x] Infinite scroll / cursor-based pagination (50 per page)
### Groups & DMs
- [x] Public channels, private groups, read-only channels
- [x] Managed private groups (Group Messages view)
- [x] User-to-user direct messages
- [x] Per-user custom group display name
- [x] User groups (team roster groupings) with colour coding
- [x] Group availability tracking (events)
### Users & Profiles
- [x] Display name, avatar, about me, date of birth, phone
- [x] Hide admin tag option
- [x] Allow/block DMs toggle
- [x] Child/alias user accounts (`AddChildAliasModal`)
- [x] Bulk user import via CSV
### Admin
- [x] Settings modal: app name, logo, PWA icons
- [x] Branding modal (Brand+ plan)
- [x] User manager (full page): create, edit, suspend, reset password, bulk import
- [x] Group manager (full page): create groups, manage members, assign user groups
- [x] Schedule manager modal: event types with custom colours
- [x] Admin can delete any message
### Schedule
- [x] Event creation (one-time + recurring)
- [x] Event types with colour coding
- [x] Availability tracking (Going / Maybe / Not Going)
- [x] Download availability list (Web Share API on mobile, download link on desktop)
- [x] Keyword filter (prefix and exact-word modes)
- [x] Type filter, date range filter
- [x] Desktop and mobile views (separate implementations)
### PWA / Notifications
- [x] Installable PWA (manifest, service worker, icons)
- [x] FCM push notifications (Android working; iOS in progress)
- [x] App badge on home screen icon
- [x] Page title unread count `(N) App Name`
- [x] Per-conversation notification grouping
### UX
- [x] Light / dark mode (CSS vars, saved localStorage)
- [x] Font scale slider (saved setting) + pinch zoom (session only)
- [x] Mobile-responsive layout
- [x] Pull-to-refresh blocked in PWA standalone mode
- [x] iOS keyboard layout fix (`--visual-viewport-height`)
- [x] Getting Started help modal
- [x] About modal, Support modal
- [x] User profile popup (click any avatar)
- [x] NavDrawer (hamburger menu)
---
## Part 17 — One-Shot Prompt (Copy-Paste to Start)
```
Build a self-hosted team chat PWA called "RosterChirp". Single Docker container.
Supports selfhost (single tenant) and host (multi-tenant via Postgres schema per tenant) modes.
STACK: Node 20 + Express + Socket.io + PostgreSQL 16 (pg npm package) + JWT
(HTTP-only cookie + localStorage) + bcryptjs + React 18 + Vite.
Push via Firebase Cloud Messaging (firebase-admin backend, firebase frontend SDK).
Images via multer + sharp. Frontend: plain CSS with CSS custom properties, no Tailwind.
MULTI-TENANT: tenantMiddleware sets req.schema from Host header. assertSafeSchema()
validates all schema names. Socket rooms prefixed: `${schema}:${type}:${id}`.
onlineUsers Map key is `${schema}:${userId}` to prevent cross-tenant ID collisions.
Every DB query calls SET search_path TO {schema} first.
MIGRATIONS: Numbered SQL files in backend/src/models/migrations/ (001, 002, ...).
Auto-applied on startup via runMigrations(schema). Never edit applied migrations.
ROUTES accept io as parameter: module.exports = (io) => router
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
KEY FEATURES:
- Public/private/readonly channels, managed private groups (Group Messages view),
user-to-user DMs, @mentions, emoji reactions, reply-to, image upload, link previews,
soft-delete, typing indicator, unread badges, page title (N) count
- User groups (team roster groupings) with colour coding
- Schedule: events, event types, availability tracking, recurring events
- Font scale: --font-scale CSS var on <html>. Message fonts scale with it. MessageInput
font is FIXED (no --font-scale). Slider in ProfileModal = saved setting (localStorage).
Pinch zoom = session only (touchend must NOT write to localStorage).
- FCM push: fired from messages.js REST route (not socket handler). sendPushToUser helper.
Stale token cleanup on FCM error codes.
- Avatar colour: AVATAR_COLORS array, charCodeAt(0) % length. Must be identical in
Avatar.jsx, Sidebar.jsx, ChatWindow.jsx.
- User deletion: email scrubbed, messages nulled, DMs set readonly, sessions purged.
- Web Share API for mobile file downloads; a.click() fallback for desktop.
GOTCHAS:
- DB_PASSWORD must not contain '!' (shell interpolation in docker-compose)
- dark mode --text-tertiary must be exactly 6 hex digits: #606080
- help.md at backend/src/data/help.md (NOT /app/data — volume-mounted, shadows files)
- Session displaced: socket receives 'session:displaced' when new login takes device slot
- iOS keyboard: use --visual-viewport-height CSS var (not 100vh) for chat layout height
- Routes that emit socket events receive io as first argument, not default export
```

7
about.json.example Normal file
View 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."
}

View File

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

View 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
View 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 doesnt 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).
Theres 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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
-- Migration 007: FCM push — add fcm_token column, relax NOT NULL on legacy web-push columns
ALTER TABLE push_subscriptions ADD COLUMN IF NOT EXISTS fcm_token TEXT;
ALTER TABLE push_subscriptions ALTER COLUMN endpoint DROP NOT NULL;
ALTER TABLE push_subscriptions ALTER COLUMN p256dh DROP NOT NULL;
ALTER TABLE push_subscriptions ALTER COLUMN auth DROP NOT NULL;

View File

@@ -0,0 +1,4 @@
-- Migration 008: Rebrand — update app_type values from JAMA-* to RosterChirp-*
UPDATE settings SET value = 'RosterChirp-Chat' WHERE key = 'app_type' AND value = 'JAMA-Chat';
UPDATE settings SET value = 'RosterChirp-Brand' WHERE key = 'app_type' AND value = 'JAMA-Brand';
UPDATE settings SET value = 'RosterChirp-Team' WHERE key = 'app_type' AND value = 'JAMA-Team';

View File

@@ -0,0 +1,17 @@
-- Migration 009: Extended user profile fields
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_minor BOOLEAN NOT NULL DEFAULT FALSE;
-- Back-fill first_name / last_name from existing combined name for non-deleted users
UPDATE users
SET
first_name = SPLIT_PART(TRIM(name), ' ', 1),
last_name = CASE
WHEN POSITION(' ' IN TRIM(name)) > 0
THEN NULLIF(TRIM(SUBSTR(TRIM(name), POSITION(' ' IN TRIM(name)) + 1)), '')
ELSE NULL
END
WHERE first_name IS NULL
AND TRIM(name) NOT IN ('Deleted User', '');

View File

@@ -0,0 +1,3 @@
-- Migration 010: Date of birth and guardian fields
ALTER TABLE users ADD COLUMN IF NOT EXISTS date_of_birth DATE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,9 @@
-- Migration 011: Add Web Push (VAPID) subscription columns for iOS PWA support
-- iOS uses the standard W3C Web Push protocol (not FCM). A subscription consists of
-- an endpoint URL (web.push.apple.com) plus two crypto keys (p256dh + auth).
-- Rows will have either fcm_token set (Android/Chrome) OR the three webpush_* columns
-- set (iOS/Firefox/Edge). Never both on the same row.
ALTER TABLE push_subscriptions
ADD COLUMN IF NOT EXISTS webpush_endpoint TEXT,
ADD COLUMN IF NOT EXISTS webpush_p256dh TEXT,
ADD COLUMN IF NOT EXISTS webpush_auth TEXT;

View File

@@ -0,0 +1,5 @@
-- Migration 012: Add composite_members to groups for private group avatar composites
-- Stores up to 4 member previews (id, name, avatar) as a JSONB snapshot.
-- Only set for non-managed, non-direct private groups with 3+ members.
-- Updated only when a member is added and pre-add membership count was ≤3.
ALTER TABLE groups ADD COLUMN IF NOT EXISTS composite_members JSONB;

View File

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

View File

@@ -0,0 +1,5 @@
-- Exception instances for recurring events (Google Calendar Series-Instance model)
-- recurring_master_id: links a standalone exception instance back to its series master
-- original_start_at: the virtual occurrence date/time this instance replaced
ALTER TABLE events ADD COLUMN IF NOT EXISTS recurring_master_id INTEGER REFERENCES events(id) ON DELETE CASCADE;
ALTER TABLE events ADD COLUMN IF NOT EXISTS original_start_at TIMESTAMPTZ;

View File

@@ -0,0 +1,41 @@
-- 015_minor_age_protection.sql
-- Adds tables and columns for Guardian Only and Mixed Age login type modes.
-- 1. guardian_approval_required on users (Mixed Age: minor needs approval before unsuspend)
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_approval_required BOOLEAN NOT NULL DEFAULT FALSE;
-- 2. guardian_aliases — children as name aliases under a guardian (Guardian Only mode)
CREATE TABLE IF NOT EXISTS guardian_aliases (
id SERIAL PRIMARY KEY,
guardian_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT,
date_of_birth DATE,
avatar TEXT,
phone TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_guardian_aliases_guardian ON guardian_aliases(guardian_id);
-- 3. alias_group_members — links guardian aliases to user groups (e.g. players group)
CREATE TABLE IF NOT EXISTS alias_group_members (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_group_id, alias_id)
);
-- 4. event_alias_availability — availability responses for guardian aliases
CREATE TABLE IF NOT EXISTS event_alias_availability (
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
note VARCHAR(20),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (event_id, alias_id)
);
CREATE INDEX IF NOT EXISTS idx_event_alias_availability_event ON event_alias_availability(event_id);

View File

@@ -0,0 +1,16 @@
-- 016_guardian_partners.sql
-- Partner/spouse relationship between guardians.
-- Partners share the same child alias list (both can manage it) and can
-- respond to events on behalf of each other within shared user groups.
CREATE TABLE IF NOT EXISTS guardian_partners (
id SERIAL PRIMARY KEY,
user_id_1 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id_2 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id_1, user_id_2),
CHECK (user_id_1 < user_id_2)
);
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user1 ON guardian_partners(user_id_1);
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user2 ON guardian_partners(user_id_2);

View File

@@ -0,0 +1,6 @@
-- 017_partner_respond_separately.sql
-- Adds respond_separately flag to guardian_partners.
-- When true, linked partners can each respond to events on behalf of children
-- in the shared alias list, but cannot respond on behalf of each other.
ALTER TABLE guardian_partners ADD COLUMN IF NOT EXISTS respond_separately BOOLEAN NOT NULL DEFAULT FALSE;

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View 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

View File

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

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,15 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.env.local
.env.*.local
.nyc_output
coverage
.vscode
.idea
*.log
ssl/
icon-*.png

18
fcm-app/.env Normal file
View File

@@ -0,0 +1,18 @@
# Firebase Configuration
FIREBASE_PROJECT_ID=fcmtest-push
FIREBASE_PRIVATE_KEY_ID=ac38f0122d21b6db2e7cfae4ed2120d848afcb13
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7S59WBylwnzgq\nYUpbwj4vzoLa6MtC7K/ZrB2Uxj1QuqdbnMsFid9RkWs+z86FUH/DgGyABnhhuBxO\nK8yQ+f1WR6deM7v1xFLrmYVDLk/7VGNGtn/xmQ7yjJPLFLqNplPWxjz8StJDiRRh\nFjPewGFrk/afDy0garsJTP6tK1IRGIf/dvIdBiCHQ1xpmWwkNDb1xNFSWx3JpN9m\nEbsMZBo5Af2jL044Z4jLEO+y32freiRoZBG4KG6Jb4+xo2qwjxFATmychpc9xEsf\nrMyOaV7omuhqOmjK3PfSotZnYyYAat8kerATe/EZsRtlTh1UHsiN+1FNy/RPV5s8\nTFYWf7a/AgMBAAECggEAJ7Ce01wxK+yRumljmI5RH1Bj6n/qkwQVP8t5eU2JMNJd\nJMzVORc+e8qVL3paCWZFrOhKFddJK2wYk3g0oYRYazBEB3JvImW4LLUbyGDIEjqP\nzyxdcJU+1ad0qlR6NApLOfhIdC5m4GjsKKbL1yhtfJ6eZJaSuYvkltP6JDhJ69Uq\nLdtA2dA5RGr1W1I8G3Yw4tNw5ImrfxbD7sO1y7A2aI5ZRL4/fOK0QCjbu8dznqPg\n8qT4dqabIRWTdM70ixEqfojQwNmL1w4wVajX470jn8iJZau0QMpJVfm2PtBxzXcM\nuQU+kP6b7BrFvKJ4LD0UOweiDQncfnKiNamMZKQgAQKBgQDcobi+lhkYxekvztq/\nv0d3RqgpmnABg1dPvNYbFV1WPjzCy/Pv87HFROb0LA/xNQKjA+2ss+LDEZXgSRuV\n7ovEQ2Zib/TyN10ihYGpIbXlbxz9rEtsatIuynKvYFlWm/v1S5LnPkCXlkHLi+cO\n2Z6DniGjCLqB4w5ZqkYzWVnSfwKBgQDZUdh5VRAR/ge1Vi5QtpQKuaZRvxjS+GZH\nmJNuIfm/+9zKakOMXgieT1wyTFr6I7955h967BrfO/djtvAQca+7l68hlyTgS4bf\n+nEVCTd3wwAbcEXOubpgnyLzQeaztRTFkcpyTZ2eVGraoAjijsElOtbJBbu9xaqS\nOoH4Adt7wQKBgQDNppSMWV41QCx2Goq9li6oGB0hAkoKrwEQWwT7I7PncoWyUOck\nr3LxXKMlz3hgrbeyeTPt+ZKRnu+jqqFi5II0w1pIwPCBYWeXiPftzXU90Y8lSJbZ\nDMyzPpMds2Iyn5x/7RyWHOmaIj1b3CDYL7JYHmpeDAHElf7HRza+IDfgQwKBgBTQ\nfwBYAlsGzqwynesDIbjJQUHRIMqMGhe/aFeDD42wzNviQ6f9Faw8A6OZppkQtXUy\nck9ur8Az2SUGz4VzrhY0mASKmnCVK0zmitAt+s8QsUDvhvAe39gDRfCwni0WKfAm\nX5KFFpSklztrWo6Ah8VOFmZYkzvA4+5vhiU/4ErBAoGAboI2WX/JNd8A5KQgRRpT\n5RkNLbhgg1TaBBEdfCkpuCJbpghAnfpvg/2lTtbLJ7SbAijmldrT5nNbhVNxAgYM\nZgOcoZJPBGi1AB1HzlkcGO/C9/H1tnEBB6ECbQ3yaz0n8TLUuJqHGwsomJJVPACT\n2FSNbfQ0TqCs1ba+Hx9iQBQ=\n-----END PRIVATE KEY-----\n"
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-fbsvc@fcmtest-push.iam.gserviceaccount.com
FIREBASE_CLIENT_ID=103917424542871804597
FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs
FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40fcmtest-push.iam.gserviceaccount.com
# VAPID Key for Web Push
VAPID_KEY=BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw
# Server Configuration
PORT=3000
NODE_ENV=production
TZ=America/Toronto

15
fcm-app/.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Firebase Configuration
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_PRIVATE_KEY_ID=your-private-key-id
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com
FIREBASE_CLIENT_ID=your-client-id
FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs
FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-...%40your-project-id.iam.gserviceaccount.com
# Server Configuration
PORT=3000
NODE_ENV=production
TZ=America/Toronto

33
fcm-app/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Use Node.js 18 LTS
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files first (for better layer caching)
COPY package*.json ./
# Install dependencies and wget
RUN npm install --omit=dev && apk add --no-cache wget
# Create non-root user and a writable data directory before copying app code
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
mkdir -p /app/data && \
chown nodejs:nodejs /app/data
# Copy application code (exclude node_modules via .dockerignore)
COPY --chown=nodejs:nodejs . .
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
# Start the application
CMD ["npm", "start"]

View File

@@ -0,0 +1,311 @@
# FCM PWA Implementation Notes
_Reference for applying FCM fixes to other projects_
---
## Part 1 — Guide Key Points (fcm_details.txt)
### How FCM works (the correct flow)
1. User grants notification permission
2. Firebase generates a unique FCM token for the device
3. Token is stored on your server for targeting
4. Server sends push requests to Firebase
5. Firebase delivers notifications to the device
6. Service worker handles display and click interactions
### Common vibe-coding failures with FCM
**1. Service worker confusion**
Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail.
**2. Deprecated API usage**
Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is:
```javascript
const token = await messaging.getToken({
vapidKey: VAPID_KEY,
serviceWorkerRegistration: registration
});
```
**3. Token generation without durable storage**
Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart.
**4. Poor permission flow**
Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit.
**5. Missing notificationclick handler**
Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app.
**6. Silent failures**
Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback.
**7. iOS blind spots**
iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop.
### Correct `getToken()` pattern (from guide)
```javascript
// Register SW first, then pass it directly to getToken
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
const token = await getToken(messaging, {
vapidKey: VAPID_KEY,
serviceWorkerRegistration: registration
});
if (!token) throw new Error('getToken() returned empty — check VAPID key and SW');
```
### Correct `firebase-messaging-sw.js` pattern (from guide)
```javascript
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
firebase.initializeApp({ /* config */ });
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
self.registration.showNotification(payload.notification.title, {
body: payload.notification.body,
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'fcm-notification',
data: payload.data
});
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'close') return;
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) return client.focus();
}
if (clients.openWindow) return clients.openWindow('/');
})
);
});
```
---
## Part 2 — Code Fixes Applied to fcm-app
### app.js fixes
**Fix: `showUserInfo()` missing**
Function was called on login and session restore but never defined — crashed immediately on login.
```javascript
function showUserInfo() {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('userInfo').style.display = 'block';
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
}
```
**Fix: `setupApp()` wrong element IDs**
`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML.
```javascript
// Wrong
document.getElementById('sendNotification').addEventListener('click', sendNotification);
// Fixed
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
// Also added id="logoutBtn" to the logout button in index.html
```
**Fix: `logout()` not clearing localStorage**
Session was restored on next page load even after logout.
```javascript
function logout() {
currentUser = null;
fcmToken = null;
localStorage.removeItem('currentUser'); // was missing
// ...
}
```
**Fix: Race condition in messaging initialization**
`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise:
```javascript
let initPromise = null;
function initializeFirebase() {
if (initPromise) return initPromise;
initPromise = navigator.serviceWorker.register('/sw.js')
.then((registration) => {
swRegistration = registration;
messaging = firebase.messaging();
})
.catch((error) => { initPromise = null; throw error; });
return initPromise;
}
// In login():
await initializeFirebase(); // ensures messaging is ready before getToken()
```
**Fix: `deleteToken()` invalidating tokens on every page load**
`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0.
Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`.
**Fix: Session restore without re-registering token**
When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token.
```javascript
// In setupApp(), after restoring session:
if (Notification.permission === 'granted') {
initializeFirebase()
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
.then(token => { if (token) return registerToken(currentUser, token); })
.catch(err => console.error('Token refresh on session restore failed:', err));
}
```
**Fix: Deprecated VAPID/SW API replaced**
```javascript
// Removed (deprecated):
messaging.usePublicVapidKey(VAPID_KEY);
messaging.useServiceWorker(registration);
const token = await messaging.getToken();
// Replaced with:
const VAPID_KEY = 'your-vapid-key';
let swRegistration = null;
// swRegistration set inside initializeFirebase() .then()
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
```
**Fix: Null token guard**
`getToken()` can return null — passing null to the server produced a confusing 400 error.
```javascript
if (!token) {
throw new Error('getToken() returned empty — check VAPID key and service worker');
}
```
**Fix: Error message included server response**
```javascript
// Before: throw new Error('Failed to register token');
// After:
throw new Error(`Server returned ${response.status}: ${errorText}`);
```
**Fix: Duplicate foreground message handlers**
`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners.
```javascript
let foregroundHandlerSetup = false;
function handleForegroundMessages() {
if (foregroundHandlerSetup) return;
foregroundHandlerSetup = true;
messaging.onMessage(/* ... */);
}
```
**Fix: `login()` event.preventDefault() crash**
Button called `login()` with no argument, so `event.preventDefault()` threw on undefined.
```javascript
async function login(event) {
if (event) event.preventDefault(); // guard added
```
**Fix: `firebase-messaging-sw.js` redirect stub replaced**
File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above).
**Fix: `notificationclick` handler added to `sw.js`**
Clicking a background notification did nothing. Handler added to focus existing window or open a new one.
**Fix: CDN URLs removed from `urlsToCache` in `sw.js`**
External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install.
```javascript
// Removed from urlsToCache:
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js',
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'
```
### server.js fixes
**Fix: `icon`/`badge`/`tag` in wrong notification object**
These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`).
```javascript
// Wrong:
notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }
// Fixed:
notification: { title, body },
webpush: {
notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' },
// ...
}
```
**Fix: `saveTokens()` in route handler not crash-safe**
```javascript
try {
saveTokens();
} catch (saveError) {
console.error('Failed to persist tokens to disk:', saveError);
}
```
**Fix: `setInterval(saveTokens)` uncaught exception crashed the server**
An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state.
```javascript
setInterval(() => {
try { saveTokens(); }
catch (error) { console.error('Auto-save tokens failed:', error); }
}, 30000);
```
---
## Part 3 — Docker / Infrastructure Fixes
### Root cause of "no other users" bug
The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain:
1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`)
2. This propagated out of `setInterval` as an uncaught exception
3. Node.js exited the process
4. Docker restarted the container with empty state
5. Tokens were never on disk, so restart = all tokens lost
### Dockerfile fix
```dockerfile
# Create non-root user AND a writable data directory (while still root)
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
mkdir -p /app/data && \
chown nodejs:nodejs /app/data
```
`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it.
### docker-compose.yml fix
```yaml
services:
your-app:
volumes:
- app_data:/app/data # named volume survives container rebuilds
volumes:
app_data:
```
Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`.
### server.js path fix
```javascript
// Changed from:
const TOKENS_FILE = './tokens.json';
// To:
const TOKENS_FILE = './data/tokens.json';
```
---
## Checklist for applying to another project
- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub)
- [ ] `notificationclick` handler present in service worker
- [ ] CDN URLs NOT in `urlsToCache` in any service worker
- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()`
- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker`
- [ ] `deleteToken()` is NOT called on page load
- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'`
- [ ] Null/empty token check before sending to server
- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification`
- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval`
- [ ] Docker: data directory created with correct user ownership in Dockerfile
- [ ] Docker: named volume mounted for data directory in docker-compose.yml
- [ ] Duplicate foreground message handler registration is guarded

209
fcm-app/README.md Normal file
View File

@@ -0,0 +1,209 @@
# FCM Test PWA
A Progressive Web App for testing Firebase Cloud Messaging (FCM) notifications across desktop and mobile devices.
## Features
- PWA with install capability
- Firebase Cloud Messaging integration
- Multi-user support (pwau1, pwau2, pwau3)
- SSL/HTTPS ready
- Docker deployment
- Real-time notifications
## Quick Start
### 1. Firebase Setup
1. **Create Firebase Project**
- Go to [Firebase Console](https://console.firebase.google.com/)
- Click "Add project"
- Enter project name (e.g., "fcm-test-pwa")
- Enable Google Analytics (optional)
- Click "Create project"
2. **Enable Cloud Messaging**
- In your project dashboard, go to "Build" → "Cloud Messaging"
- Click "Get started"
- Cloud Messaging is now enabled for your project
3. **Get Firebase Configuration**
- Go to Project Settings (⚙️ icon)
- Under "Your apps", click "Web app" (</> icon)
- Register app with nickname "FCM Test PWA"
- Copy the Firebase config object (you'll need this later)
4. **Generate Service Account Key**
- In Project Settings, go to "Service accounts"
- Click "Generate new private key"
- Save the JSON file (you'll need this for the server)
5. **Get Web Push Certificate**
- In Cloud Messaging settings, click "Web Push certificates"
- Generate and save the key pair
### 2. Server Configuration
1. **Copy environment template**
```bash
cp .env.example .env
```
2. **Update .env file** with your Firebase credentials:
```env
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_PRIVATE_KEY_ID=your-private-key-id
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com
FIREBASE_CLIENT_ID=your-client-id
# ... other fields from service account JSON
```
3. **Update Firebase config in client files**:
- Edit `public/app.js` - replace Firebase config
- Edit `public/sw.js` - replace Firebase config
### 3. Local Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
```
Open http://localhost:3000 in your browser.
### 4. Docker Deployment
```bash
# Build and run with Docker Compose
docker-compose up -d
# View logs
docker-compose logs -f
```
## User Accounts
| Username | Password | Purpose |
|----------|----------|---------|
| pwau1 | test123 | Desktop user |
| pwau2 | test123 | Mobile user 1 |
| pwau3 | test123 | Mobile user 2 |
## Usage
1. **Install as PWA**
- Open the app in Chrome/Firefox
- Click the install icon in the address bar
- Install as a desktop app
2. **Enable Notifications**
- Login with any user account
- Grant notification permissions when prompted
- FCM token will be automatically registered
3. **Send Notifications**
- Click "Send Notification" button
- All other logged-in users will receive the notification
- Check both desktop and mobile devices
## Deployment on Ubuntu LXC + HAProxy
### Prerequisites
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# Install Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
### SSL Certificate Setup
```bash
# Create SSL directory
mkdir -p ssl
# Generate self-signed certificate (for testing)
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout ssl/key.pem \
-out ssl/cert.pem \
-subj "/C=US/ST=State/L=City/O=Organization/CN=your-domain.com"
# OR use Let's Encrypt for production
sudo apt install certbot
sudo certbot certonly --standalone -d your-domain.com
sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ssl/cert.pem
sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem ssl/key.pem
```
### HAProxy Configuration
Add to your `/etc/haproxy/haproxy.cfg`:
```haproxy
frontend fcm_test_frontend
bind *:80
bind *:443 ssl crt /etc/ssl/certs/your-cert.pem
redirect scheme https if !{ ssl_fc }
default_backend fcm_test_backend
backend fcm_test_backend
balance roundrobin
server fcm_test 127.0.0.1:3000 check
```
### Deploy
```bash
# Clone and setup
git clone <your-repo>
cd fcm-test-pwa
cp .env.example .env
# Edit .env with your Firebase config
# Deploy
docker-compose up -d
# Check status
docker-compose ps
docker-compose logs
```
## Testing
1. **Desktop Testing**
- Open app in Chrome
- Install as PWA
- Login as pwau1
- Send test notifications
2. **Mobile Testing**
- Open app on mobile browsers
- Install as PWA
- Login as pwau2 and pwau3 on different devices
- Test cross-device notifications
## Troubleshooting
- **Notifications not working**: Check Firebase configuration and service worker
- **PWA not installing**: Ensure site is served over HTTPS
- **Docker issues**: Check logs with `docker-compose logs`
- **HAProxy issues**: Verify SSL certificates and backend connectivity
## Security Notes
- Change default passwords in production
- Use proper SSL certificates
- Implement rate limiting for notifications
- Consider using a database for token storage in production

View File

@@ -0,0 +1,22 @@
services:
fcm-test-app:
build: .
ports:
- "3066:3000"
environment:
- NODE_ENV=production
- TZ=${TZ:-UTC}
env_file:
- .env
volumes:
- fcm_data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
fcm_data:

1013
fcm-app/fcm_details.txt Normal file

File diff suppressed because it is too large Load Diff

57
fcm-app/nginx.conf Normal file
View File

@@ -0,0 +1,57 @@
events {
worker_connections 1024;
}
http {
upstream app {
server fcm-test-app:3000;
}
# HTTP to HTTPS redirect
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
# HTTPS server
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL configuration
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# PWA headers
add_header Service-Worker-Allowed "/";
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Serve static files directly
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|json|webmanifest)$ {
proxy_pass http://app;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

23
fcm-app/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "fcm-test-pwa",
"version": "1.0.0",
"description": "PWA for testing Firebase Cloud Messaging",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"firebase": "^10.7.1",
"firebase-admin": "^12.0.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.2"
},
"keywords": ["pwa", "fcm", "firebase", "notifications"],
"author": "",
"license": "MIT"
}

334
fcm-app/public/app.js Normal file
View File

@@ -0,0 +1,334 @@
// Load Firebase SDK immediately
const script1 = document.createElement('script');
script1.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js';
script1.onload = () => {
const script2 = document.createElement('script');
script2.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js';
script2.onload = () => {
// Initialize Firebase immediately
initializeFirebase();
console.log('Firebase SDK and initialization complete');
// Now that Firebase is ready, set up the app
setupApp();
};
document.head.appendChild(script2);
};
document.head.appendChild(script1);
// Global variables
let currentUser = null;
let fcmToken = null;
let messaging = null;
let swRegistration = null;
let initPromise = null;
let foregroundHandlerSetup = false;
const VAPID_KEY = 'BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw';
// Simple user authentication
const users = {
'pwau1': { password: 'test123', name: 'Desktop User' },
'pwau2': { password: 'test123', name: 'Mobile User 1' },
'pwau3': { password: 'test123', name: 'Mobile User 2' }
};
// Initialize Firebase — returns a promise that resolves when messaging is ready
function initializeFirebase() {
if (initPromise) return initPromise;
const firebaseConfig = {
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
authDomain: "fcmtest-push.firebaseapp.com",
projectId: "fcmtest-push",
storageBucket: "fcmtest-push.firebasestorage.app",
messagingSenderId: "439263996034",
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
};
if (firebase.apps.length === 0) {
firebase.initializeApp(firebaseConfig);
console.log('Firebase app initialized');
}
initPromise = navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered:', registration);
swRegistration = registration;
messaging = firebase.messaging();
console.log('Firebase messaging initialized successfully');
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
initPromise = null;
throw error;
});
return initPromise;
}
// Show user info panel and hide login form
function showUserInfo() {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('userInfo').style.display = 'block';
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
}
// Setup app after Firebase is ready
function setupApp() {
// Set up event listeners
document.getElementById('loginForm').addEventListener('submit', login);
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
document.getElementById('logoutBtn').addEventListener('click', logout);
// Restore session and re-register FCM token if notifications were already granted
const savedUser = localStorage.getItem('currentUser');
if (savedUser) {
currentUser = savedUser;
showUserInfo();
if (Notification.permission === 'granted') {
initializeFirebase()
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
.then(token => { if (token) return registerToken(currentUser, token); })
.catch(err => console.error('Token refresh on session restore failed:', err));
}
}
}
// Request notification permission and get FCM token
async function requestNotificationPermission() {
try {
console.log('Requesting notification permission...');
const permission = await Notification.requestPermission();
console.log('Permission result:', permission);
if (permission === 'granted') {
console.log('Notification permission granted.');
showStatus('Getting FCM token...', 'info');
try {
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
console.log('FCM Token generated:', token);
if (!token) {
throw new Error('getToken() returned empty — check VAPID key and service worker');
}
fcmToken = token;
// Send token to server
await registerToken(currentUser, token);
showStatus('Notifications enabled successfully!', 'success');
} catch (tokenError) {
console.error('Error getting FCM token:', tokenError);
showStatus('Failed to get FCM token: ' + tokenError.message, 'error');
}
} else {
console.log('Notification permission denied.');
showStatus('Notification permission denied.', 'error');
}
} catch (error) {
console.error('Error requesting notification permission:', error);
showStatus('Failed to enable notifications: ' + error.message, 'error');
}
}
// Register FCM token with server
async function registerToken(username, token) {
try {
console.log('Attempting to register token:', { username, token: token.substring(0, 20) + '...' });
const response = await fetch('/register-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, token })
});
console.log('Registration response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Server returned ${response.status}: ${errorText}`);
}
const result = await response.json();
console.log('Token registered successfully:', result);
showStatus(`Token registered for ${username}`, 'success');
} catch (error) {
console.error('Error registering token:', error);
showStatus('Failed to register token with server: ' + error.message, 'error');
}
}
// Handle foreground messages (guard against duplicate registration)
function handleForegroundMessages() {
if (foregroundHandlerSetup) return;
foregroundHandlerSetup = true;
messaging.onMessage(function(payload) {
console.log('Received foreground message: ', payload);
// Show notification in foreground
const notificationTitle = payload.notification.title;
const notificationOptions = {
body: payload.notification.body,
icon: '/icon-192.png',
badge: '/icon-192.png'
};
new Notification(notificationTitle, notificationOptions);
showStatus(`New notification: ${payload.notification.body}`, 'info');
});
}
// Login function
async function login(event) {
if (event) event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (!users[username] || users[username].password !== password) {
showStatus('Invalid username or password', 'error');
return;
}
currentUser = username;
localStorage.setItem('currentUser', username);
showUserInfo();
showStatus(`Logged in as ${users[username].name}`, 'success');
// Initialize Firebase and request notifications
if (typeof firebase !== 'undefined') {
await initializeFirebase();
await requestNotificationPermission();
handleForegroundMessages();
} else {
showStatus('Firebase not loaded. Please check your connection.', 'error');
}
}
// Logout function
function logout() {
currentUser = null;
fcmToken = null;
localStorage.removeItem('currentUser');
document.getElementById('loginForm').style.display = 'block';
document.getElementById('userInfo').style.display = 'none';
document.getElementById('username').value = '';
document.getElementById('password').value = '';
showStatus('Logged out successfully.', 'info');
}
// Send notification function
async function sendNotification() {
if (!currentUser) {
showStatus('Please login first.', 'error');
return;
}
try {
// First check registered users
const usersResponse = await fetch('/users');
const users = await usersResponse.json();
console.log('Registered users:', users);
const response = await fetch('/send-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
fromUser: currentUser,
title: 'Test Notification',
body: `Notification sent from ${currentUser} at ${new Date().toLocaleTimeString()}`
})
});
if (!response.ok) {
throw new Error('Failed to send notification');
}
const result = await response.json();
console.log('Send result:', result);
if (result.recipients === 0) {
showStatus('No other users have registered tokens. Open the app on other devices and enable notifications.', 'error');
} else {
showStatus(`Notification sent to ${result.recipients} user(s)!`, 'success');
}
} catch (error) {
console.error('Error sending notification:', error);
showStatus('Failed to send notification.', 'error');
}
}
// Show status message
function showStatus(message, type) {
const statusEl = document.getElementById('status');
statusEl.textContent = message;
statusEl.className = `status ${type}`;
statusEl.style.display = 'block';
setTimeout(() => {
statusEl.style.display = 'none';
}, 5000);
}
// Register service worker and handle PWA installation
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
// Handle PWA installation
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
console.log('beforeinstallprompt fired');
e.preventDefault();
deferredPrompt = e;
// Show install button or banner
showInstallButton();
});
function showInstallButton() {
const installBtn = document.createElement('button');
installBtn.textContent = 'Install App';
installBtn.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
cursor: pointer;
z-index: 1000;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
installBtn.addEventListener('click', async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response to the install prompt: ${outcome}`);
deferredPrompt = null;
installBtn.remove();
}
});
document.body.appendChild(installBtn);
}
})
.catch(function(error) {
console.log('ServiceWorker registration failed: ', error);
});
});
}

View File

@@ -0,0 +1,48 @@
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
firebase.initializeApp({
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
authDomain: "fcmtest-push.firebaseapp.com",
projectId: "fcmtest-push",
storageBucket: "fcmtest-push.firebasestorage.app",
messagingSenderId: "439263996034",
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
});
const messaging = firebase.messaging();
messaging.onBackgroundMessage(function(payload) {
console.log('Received background message:', payload);
const notificationTitle = payload.notification.title;
const notificationOptions = {
body: payload.notification.body,
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'fcm-test',
data: payload.data
};
self.registration.showNotification(notificationTitle, notificationOptions);
});
self.addEventListener('notificationclick', function(event) {
console.log('Notification clicked:', event);
event.notification.close();
if (event.action === 'close') return;
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
});

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

111
fcm-app/public/index.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FCM Test PWA</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2196F3">
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 400px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.login-form {
display: block;
}
.user-info {
display: none;
}
input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 5px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 12px;
background-color: #2196F3;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
margin: 10px 0;
}
button:hover {
background-color: #1976D2;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
text-align: center;
}
.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.user-display {
background-color: #e3f2fd;
padding: 10px;
border-radius: 5px;
margin: 10px 0;
text-align: center;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<h1>FCM Test PWA</h1>
<div id="status" class="status" style="display: none;"></div>
<div id="loginForm" class="login-form">
<h2>Login</h2>
<input type="text" id="username" placeholder="Username (pwau1, pwau2, or pwau3)" required>
<input type="password" id="password" placeholder="Password" required>
<button onclick="login()">Login</button>
</div>
<div id="userInfo" class="user-info">
<div class="user-display">
Logged in as: <span id="currentUser"></span>
</div>
<button id="sendNotificationBtn" onclick="sendNotification()">Send Notification</button>
<button id="logoutBtn" onclick="logout()">Logout</button>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"name": "FCM Test PWA",
"short_name": "FCM Test",
"description": "PWA for testing Firebase Cloud Messaging",
"start_url": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"purpose": "any maskable",
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"purpose": "any maskable",
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"categories": ["utilities", "productivity"],
"lang": "en-US"
}

82
fcm-app/public/sw.js Normal file
View File

@@ -0,0 +1,82 @@
const CACHE_NAME = 'fcm-test-pwa-v1';
const urlsToCache = [
'/',
'/index.html',
'/app.js',
'/manifest.json'
];
// Install event
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
return cache.addAll(urlsToCache);
})
);
});
// Fetch event
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
// Background sync for FCM
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
// Initialize Firebase in service worker
firebase.initializeApp({
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
authDomain: "fcmtest-push.firebaseapp.com",
projectId: "fcmtest-push",
storageBucket: "fcmtest-push.firebasestorage.app",
messagingSenderId: "439263996034",
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
});
const messaging = firebase.messaging();
// Handle notification clicks
self.addEventListener('notificationclick', function(event) {
console.log('Notification clicked:', event);
event.notification.close();
if (event.action === 'close') return;
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
});
// Handle background messages
messaging.onBackgroundMessage(function(payload) {
console.log('Received background message ', payload);
const notificationTitle = payload.notification.title;
const notificationOptions = {
body: payload.notification.body,
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'fcm-test'
};
return self.registration.showNotification(notificationTitle, notificationOptions);
});

244
fcm-app/server.js Normal file
View File

@@ -0,0 +1,244 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const cors = require('cors');
const admin = require('firebase-admin');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// In-memory storage for FCM tokens (in production, use a database)
const userTokens = new Map();
// Load tokens from file on startup (for persistence)
const fs = require('fs');
const TOKENS_FILE = './data/tokens.json';
function loadTokens() {
try {
if (fs.existsSync(TOKENS_FILE)) {
const data = fs.readFileSync(TOKENS_FILE, 'utf8');
const tokens = JSON.parse(data);
for (const [user, tokenArray] of Object.entries(tokens)) {
userTokens.set(user, new Set(tokenArray));
}
console.log(`Loaded tokens for ${userTokens.size} users from file`);
}
} catch (error) {
console.log('No existing tokens file found, starting fresh');
}
}
function saveTokens() {
const tokens = {};
for (const [user, tokenSet] of userTokens.entries()) {
tokens[user] = Array.from(tokenSet);
}
fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
}
// Load existing tokens on startup
loadTokens();
// Auto-save tokens every 30 seconds
setInterval(() => {
try {
saveTokens();
} catch (error) {
console.error('Auto-save tokens failed:', error);
}
}, 30000);
// Initialize Firebase Admin
if (process.env.FIREBASE_PRIVATE_KEY) {
const serviceAccount = {
projectId: process.env.FIREBASE_PROJECT_ID,
privateKeyId: process.env.FIREBASE_PRIVATE_KEY_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
clientId: process.env.FIREBASE_CLIENT_ID,
authUri: process.env.FIREBASE_AUTH_URI,
tokenUri: process.env.FIREBASE_TOKEN_URI,
authProviderX509CertUrl: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL,
clientC509CertUrl: process.env.FIREBASE_CLIENT_X509_CERT_URL
};
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
console.log('Firebase Admin initialized successfully');
} else {
console.log('Firebase Admin not configured. Please set up .env file');
}
// Routes
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Register FCM token
app.post('/register-token', (req, res) => {
const { username, token } = req.body;
console.log(`Token registration request:`, { username, token: token?.substring(0, 20) + '...' });
if (!username || !token) {
console.log('Token registration failed: missing username or token');
return res.status(400).json({ error: 'Username and token are required' });
}
// Store token for user
if (!userTokens.has(username)) {
userTokens.set(username, new Set());
}
const userTokenSet = userTokens.get(username);
if (userTokenSet.has(token)) {
console.log(`Token already registered for user: ${username}`);
} else {
userTokenSet.add(token);
console.log(`New token registered for user: ${username}`);
// Save immediately after new registration
try {
saveTokens();
} catch (saveError) {
console.error('Failed to persist tokens to disk:', saveError);
}
}
console.log(`Total tokens for ${username}: ${userTokenSet.size}`);
console.log(`Total registered users: ${userTokens.size}`);
res.json({ success: true, message: 'Token registered successfully' });
});
// Send notification to all other users
app.post('/send-notification', async (req, res) => {
const { fromUser, title, body } = req.body;
if (!fromUser || !title || !body) {
return res.status(400).json({ error: 'fromUser, title, and body are required' });
}
if (!admin.apps.length) {
return res.status(500).json({ error: 'Firebase Admin not initialized' });
}
try {
let totalRecipients = 0;
const promises = [];
// Send to all users except the sender
for (const [username, tokens] of userTokens.entries()) {
if (username === fromUser) continue; // Skip sender
for (const token of tokens) {
const message = {
token: token,
notification: {
title: title,
body: body
},
webpush: {
headers: {
'Urgency': 'high'
},
notification: {
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: 'fcm-test'
},
fcm_options: {
link: '/'
}
},
android: {
priority: 'high',
notification: {
sound: 'default',
click_action: '/'
}
},
apns: {
payload: {
aps: {
sound: 'default',
badge: 1
}
}
}
};
promises.push(
admin.messaging().send(message)
.then(() => {
console.log(`Notification sent to ${username} successfully`);
totalRecipients++;
})
.catch((error) => {
console.error(`Error sending notification to ${username}:`, error);
// Remove invalid token
if (error.code === 'messaging/registration-token-not-registered') {
tokens.delete(token);
}
})
);
}
}
await Promise.all(promises);
res.json({
success: true,
recipients: totalRecipients,
message: `Notification sent to ${totalRecipients} recipient(s)`
});
} catch (error) {
console.error('Error sending notifications:', error);
res.status(500).json({ error: 'Failed to send notifications' });
}
});
// Get all registered users (for debugging)
app.get('/users', (req, res) => {
const users = {};
console.log('Current userTokens map:', userTokens);
console.log('Number of registered users:', userTokens.size);
for (const [username, tokens] of userTokens.entries()) {
users[username] = {
tokenCount: tokens.size,
tokens: Array.from(tokens)
};
}
res.json(users);
});
// Debug endpoint to check server status
app.get('/debug', (req, res) => {
res.json({
firebaseAdminInitialized: admin.apps.length > 0,
registeredUsers: userTokens.size,
userTokens: Object.fromEntries(
Array.from(userTokens.entries()).map(([user, tokens]) => [user, {
count: tokens.size,
tokens: Array.from(tokens)
}])
),
timestamp: new Date().toISOString()
});
});
// Start server
app.listen(PORT, '0.0.0.0', () => {
console.log(`FCM Test PWA server running on port ${PORT}`);
console.log(`Open http://localhost:${PORT} in your browser`);
console.log(`Server listening on all interfaces (0.0.0.0:${PORT})`);
});

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

View File

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

View File

@@ -0,0 +1,59 @@
// ── Unified Push Handler (Optimized for Mobile) ──────────────────────────────
self.addEventListener('push', (event) => {
console.log('[SW] Push event received. Messaging Ready:', !!messaging);
// event.waitUntil is the "Keep-Alive" signal for mobile OS
event.waitUntil(
(async () => {
try {
let payload;
// 1. Try to parse the data directly from the push event (Fastest/Reliable)
if (event.data) {
try {
payload = event.data.json();
console.log('[SW] Raw push data parsed:', JSON.stringify(payload));
} catch (e) {
console.warn('[SW] Could not parse JSON, using text fallback');
payload = { notification: { body: event.data.text() } };
}
}
// 2. If the payload is empty, check if Firebase can catch it
// (This happens if your server sends "Notification" instead of "Data" messages)
if (!payload && messaging) {
// This is a last-resort wait for the SDK
payload = await new Promise((resolve) => {
const timeout = setTimeout(() => resolve(null), 2000);
messaging.onBackgroundMessage((bgPayload) => {
clearTimeout(timeout);
resolve(bgPayload);
});
});
}
// 3. Construct and show the notification
if (payload) {
const n = payload.notification || {};
const d = payload.data || {};
// Use the specific function you already defined
await showRosterChirpNotification({
title: n.title || d.title || 'New Message',
body: n.body || d.body || '',
url: d.url || d.link || '/', // some SDKs use 'link'
groupId: d.groupId || '',
});
} else {
// Fallback if we woke up for a "ghost" push with no data
await self.registration.showNotification('RosterChirp', {
body: 'You have a new update.',
tag: 'rosterchirp-fallback'
});
}
} catch (error) {
console.error('[SW] Critical Push Error:', error);
}
})()
);
});

View File

@@ -0,0 +1,82 @@
The Consolidated "Bulletproof" Push Listener
To fix the "hit or miss" behavior on mobile, we need to move away from relying on the Firebase SDK's internal listener (which is a black box that doesn't always play nice with mobile power management) and instead wrap everything in the native push event using event.waitUntil.
Replace your current messaging.onBackgroundMessage and self.addEventListener('push') blocks with this unified version:
JavaScript
// ── Unified Push Handler (Optimized for Mobile) ──────────────────────────────
self.addEventListener('push', (event) => {
console.log('[SW] Push event received. Messaging Ready:', !!messaging);
// event.waitUntil is the "Keep-Alive" signal for mobile OS
event.waitUntil(
(async () => {
try {
let payload;
// 1. Try to parse the data directly from the push event (Fastest/Reliable)
if (event.data) {
try {
payload = event.data.json();
console.log('[SW] Raw push data parsed:', JSON.stringify(payload));
} catch (e) {
console.warn('[SW] Could not parse JSON, using text fallback');
payload = { notification: { body: event.data.text() } };
}
}
// 2. If the payload is empty, check if Firebase can catch it
// (This happens if your server sends "Notification" instead of "Data" messages)
if (!payload && messaging) {
// This is a last-resort wait for the SDK
payload = await new Promise((resolve) => {
const timeout = setTimeout(() => resolve(null), 2000);
messaging.onBackgroundMessage((bgPayload) => {
clearTimeout(timeout);
resolve(bgPayload);
});
});
}
// 3. Construct and show the notification
if (payload) {
const n = payload.notification || {};
const d = payload.data || {};
// Use the specific function you already defined
await showRosterChirpNotification({
title: n.title || d.title || 'New Message',
body: n.body || d.body || '',
url: d.url || d.link || '/', // some SDKs use 'link'
groupId: d.groupId || '',
});
} else {
// Fallback if we woke up for a "ghost" push with no data
await self.registration.showNotification('RosterChirp', {
body: 'You have a new update.',
tag: 'rosterchirp-fallback'
});
}
} catch (error) {
console.error('[SW] Critical Push Error:', error);
}
})()
);
});
Why this fixes your issues:
Eliminates the Race Condition: By wrapping the logic in an async function inside event.waitUntil, you tell Android/iOS: "Do not kill this process until the notification is actually rendered on the screen."
Bypasses SDK "Sleep": Sometimes the Firebase SDK takes too long to initialize. This code tries to parse the event.data directly first. Direct parsing is almost instantaneous, which is critical when a phone is in a deep sleep state.
Handles "Ghost" Pushes: Sometimes FCM sends a "heartbeat" or an empty push to wake the device. This script ensures that even if data is missing, the worker stays alive long enough to handle it gracefully rather than crashing.
One Final Server-Side Check
Since you are using HAProxy, check your server logs for the HTTP status code when you send a message to a mobile device.
If you see 200 OK but no notification: The issue was definitely this Service Worker "sleep" issue.
If you see 401 or 403: HAProxy might be stripping the Authorization header from your backend's outbound request to Google.
If you see 400: Ensure your backend is sending priority: "high" in the FCM JSON.
Would you like me to provide a Python or Node.js snippet to test sending a "High Priority" message with the correct v1 API headers?

View File

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

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext.jsx';
import { SocketProvider } from './contexts/SocketContext.jsx';
@@ -6,6 +7,50 @@ import Login from './pages/Login.jsx';
import Chat from './pages/Chat.jsx';
import ChangePassword from './pages/ChangePassword.jsx';
// ── iOS "Add to Home Screen" banner ───────────────────────────────────────────
// iOS Safari does not fire beforeinstallprompt. Push notifications require the
// app to be installed as a PWA. This banner is shown to any iOS Safari user who
// has not yet added the app to their Home Screen.
const IOS_BANNER_KEY = 'rc_ios_install_dismissed';
function IOSInstallBanner() {
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone = window.navigator.standalone === true;
const [dismissed, setDismissed] = useState(() => localStorage.getItem(IOS_BANNER_KEY) === '1');
if (!isIOS || isStandalone || dismissed) return null;
const dismiss = () => {
localStorage.setItem(IOS_BANNER_KEY, '1');
setDismissed(true);
};
return (
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 9999,
background: 'var(--primary, #1a73e8)', color: '#fff',
padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12,
boxShadow: '0 -2px 12px rgba(0,0,0,0.25)',
}}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 2 }}>Add to Home Screen</div>
<div style={{ fontSize: 12, lineHeight: 1.4, opacity: 0.9 }}>
To receive push notifications, tap the{' '}
<svg style={{ display: 'inline', verticalAlign: 'middle', margin: '0 2px' }} width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
<polyline points="16 6 12 2 8 6"/>
<line x1="12" y1="2" x2="12" y2="15"/>
</svg>
{' '}Share button, then select <strong>"Add to Home Screen"</strong>.
</div>
</div>
<button onClick={dismiss} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#fff', padding: 4, flexShrink: 0, opacity: 0.9 }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
);
}
function ProtectedRoute({ children }) {
const { user, loading, mustChangePassword } = useAuth();
if (loading) return (
@@ -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>
);

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

View File

@@ -0,0 +1,434 @@
import { useState, useEffect } from 'react';
import { useToast } from '../contexts/ToastContext.jsx';
import { useAuth } from '../contexts/AuthContext.jsx';
import { api } from '../utils/api.js';
export default function AddChildAliasModal({ features = {}, onClose }) {
const toast = useToast();
const { user: currentUser } = useAuth();
const loginType = features.loginType || 'guardian_only';
const isMixedAge = loginType === 'mixed_age';
// ── Guardian-only state (alias form) ──────────────────────────────────────
const [aliases, setAliases] = useState([]);
const [editingAlias, setEditingAlias] = useState(null);
const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
const [avatarFile, setAvatarFile] = useState(null);
const [saving, setSaving] = useState(false);
// ── Mixed-age state (real minor users) ────────────────────────────────────
const [minorPlayers, setMinorPlayers] = useState([]); // available + already-mine
const [selectedMinorId, setSelectedMinorId] = useState('');
const [childDob, setChildDob] = useState('');
const [addingMinor, setAddingMinor] = useState(false);
// ── Partner state (shared) ────────────────────────────────────────────────
const [partner, setPartner] = useState(null);
const [selectedPartnerId, setSelectedPartnerId] = useState('');
const [respondSeparately, setRespondSeparately] = useState(false);
const [allUsers, setAllUsers] = useState([]);
const [savingPartner, setSavingPartner] = useState(false);
useEffect(() => {
const loads = [api.getPartner(), api.searchUsers('')];
if (isMixedAge) {
loads.push(api.getMinorPlayers());
} else {
loads.push(api.getAliases());
}
Promise.all(loads).then(([partnerRes, usersRes, thirdRes]) => {
const p = partnerRes.partner || null;
setPartner(p);
setSelectedPartnerId(p?.id?.toString() || '');
setRespondSeparately(p?.respond_separately || false);
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id && !u.is_default_admin));
if (isMixedAge) {
setMinorPlayers(thirdRes.users || []);
} else {
setAliases(thirdRes.aliases || []);
}
}).catch(() => {});
}, [isMixedAge]);
// Pre-populate DOB when a minor is selected from the dropdown
useEffect(() => {
if (!selectedMinorId) { setChildDob(''); return; }
const minor = availableMinors.find(u => u.id === parseInt(selectedMinorId));
setChildDob(minor?.date_of_birth ? minor.date_of_birth.slice(0, 10) : '');
}, [selectedMinorId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Helpers ───────────────────────────────────────────────────────────────
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
const resetForm = () => {
setEditingAlias(null);
setForm({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
setAvatarFile(null);
};
const lbl = (text, required) => (
<label className="text-sm" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
{text}{required && <span style={{ color: 'var(--error)', marginLeft: 2 }}>*</span>}
</label>
);
// ── Partner handlers ──────────────────────────────────────────────────────
const handleSavePartner = async () => {
setSavingPartner(true);
try {
if (!selectedPartnerId) {
await api.removePartner();
setPartner(null);
setRespondSeparately(false);
if (!isMixedAge) {
const { aliases: fresh } = await api.getAliases();
setAliases(fresh || []);
resetForm();
} else {
const { users: fresh } = await api.getMinorPlayers();
setMinorPlayers(fresh || []);
}
toast('Spouse/Partner/Co-Parent removed', 'success');
} else {
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately);
setPartner(p);
setRespondSeparately(p?.respond_separately || false);
if (!isMixedAge) {
const { aliases: fresh } = await api.getAliases();
setAliases(fresh || []);
}
toast('Spouse/Partner/Co-Parent saved', 'success');
}
} catch (e) {
toast(e.message, 'error');
} finally {
setSavingPartner(false);
}
};
// ── Guardian-only alias handlers ──────────────────────────────────────────
const handleSelectAlias = (a) => {
if (editingAlias?.id === a.id) { resetForm(); return; }
setEditingAlias(a);
setForm({
firstName: a.first_name || '',
lastName: a.last_name || '',
dob: a.date_of_birth ? a.date_of_birth.slice(0, 10) : '',
phone: a.phone || '',
email: a.email || '',
});
setAvatarFile(null);
};
const handleSaveAlias = async () => {
if (!form.firstName.trim() || !form.lastName.trim())
return toast('First and last name required', 'error');
setSaving(true);
try {
if (editingAlias) {
await api.updateAlias(editingAlias.id, {
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
dateOfBirth: form.dob || null,
phone: form.phone || null,
email: form.email || null,
});
if (avatarFile) await api.uploadAliasAvatar(editingAlias.id, avatarFile);
toast('Child alias updated', 'success');
} else {
const { alias } = await api.createAlias({
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
dateOfBirth: form.dob || null,
phone: form.phone || null,
email: form.email || null,
});
if (avatarFile) await api.uploadAliasAvatar(alias.id, avatarFile);
toast('Child alias added', 'success');
}
const { aliases: fresh } = await api.getAliases();
setAliases(fresh || []);
resetForm();
} catch (e) {
toast(e.message, 'error');
} finally {
setSaving(false);
}
};
const handleDeleteAlias = async (e, aliasId) => {
e.stopPropagation();
try {
await api.deleteAlias(aliasId);
setAliases(prev => prev.filter(a => a.id !== aliasId));
if (editingAlias?.id === aliasId) resetForm();
toast('Child alias removed', 'success');
} catch (err) { toast(err.message, 'error'); }
};
// ── Mixed-age minor handlers ──────────────────────────────────────────────
const myMinors = minorPlayers.filter(u => u.guardian_user_id === currentUser?.id);
const availableMinors = minorPlayers.filter(u => !u.guardian_user_id);
const handleAddMinor = async () => {
if (!selectedMinorId) return;
if (!childDob.trim()) return toast('Date of Birth is required', 'error');
setAddingMinor(true);
try {
await api.addGuardianChild(parseInt(selectedMinorId), childDob.trim());
const { users: fresh } = await api.getMinorPlayers();
setMinorPlayers(fresh || []);
setSelectedMinorId('');
setChildDob('');
toast('Child added and account activated', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
setAddingMinor(false);
}
};
const handleRemoveMinor = async (e, minorId) => {
e.stopPropagation();
try {
await api.removeGuardianChild(minorId);
const { users: fresh } = await api.getMinorPlayers();
setMinorPlayers(fresh || []);
toast('Child removed', 'success');
} catch (err) { toast(err.message, 'error'); }
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal">
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Family Manager</h2>
<button className="btn-icon" onClick={onClose} aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/* Spouse/Partner/Co-Parent section */}
<div style={{ marginBottom: 16 }}>
{lbl('Spouse/Partner/Co-Parent')}
<div style={{ display: 'flex', gap: 8 }}>
<select
className="input"
style={{ flex: 1 }}
value={selectedPartnerId}
onChange={e => setSelectedPartnerId(e.target.value)}
>
<option value=""> None </option>
{allUsers.map(u => (
<option key={u.id} value={u.id}>{u.display_name || u.name}</option>
))}
</select>
<button
className="btn btn-primary"
onClick={handleSavePartner}
disabled={savingPartner}
style={{ whiteSpace: 'nowrap' }}
>
{savingPartner ? 'Saving…' : 'Save'}
</button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, cursor: 'pointer', fontSize: 13, color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={respondSeparately}
onChange={e => setRespondSeparately(e.target.checked)}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: 'var(--primary)' }}
/>
Respond separately to events
</label>
{partner && (
<div className="text-sm" style={{ color: 'var(--text-secondary)', marginTop: 6 }}>
Linked with {partner.display_name || partner.name}
</div>
)}
</div>
{/* ── Mixed Age: link real minor users ── */}
{isMixedAge && (
<>
{/* Current children list */}
{myMinors.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
Your Children
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
{myMinors.map((u, i) => (
<div
key={u.id}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 12px',
borderBottom: i < myMinors.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<span style={{ flex: 1, fontSize: 14 }}>{u.first_name} {u.last_name}</span>
{u.date_of_birth && (
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{u.date_of_birth.slice(0, 10)}
</span>
)}
<button
onClick={e => handleRemoveMinor(e, u.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
aria-label="Remove"
>×</button>
</div>
))}
</div>
</div>
)}
{/* Add minor from players group */}
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
Add Child
</div>
<select
className="input"
style={{ marginBottom: 8 }}
value={selectedMinorId}
onChange={e => setSelectedMinorId(e.target.value)}
>
<option value=""> Select a player </option>
{availableMinors.map(u => (
<option key={u.id} value={u.id}>
{u.first_name} {u.last_name}
</option>
))}
</select>
<div style={{ marginBottom: 8 }}>
{lbl('Date of Birth', true)}
<input
className="input"
type="text"
placeholder="YYYY-MM-DD"
value={childDob}
onChange={e => setChildDob(e.target.value)}
autoComplete="off"
style={childDob === '' && selectedMinorId ? { borderColor: 'var(--error)' } : {}}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
className="btn btn-primary"
onClick={handleAddMinor}
disabled={addingMinor || !selectedMinorId || !childDob.trim()}
style={{ whiteSpace: 'nowrap' }}
>
{addingMinor ? 'Adding…' : 'Add'}
</button>
</div>
{availableMinors.length === 0 && myMinors.length === 0 && (
<p className="text-sm" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
No minor players available to link.
</p>
)}
</>
)}
{/* ── Guardian Only: alias form ── */}
{!isMixedAge && (
<>
{/* Existing aliases list */}
{aliases.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
Your Children click to edit
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
{aliases.map((a, i) => (
<div
key={a.id}
onClick={() => handleSelectAlias(a)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 12px', cursor: 'pointer',
borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none',
background: editingAlias?.id === a.id ? 'var(--primary-light)' : 'transparent',
}}
>
<span style={{ flex: 1, fontSize: 14, fontWeight: editingAlias?.id === a.id ? 600 : 400 }}>
{a.first_name} {a.last_name}
</span>
{a.date_of_birth && (
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{a.date_of_birth.slice(0, 10)}
</span>
)}
<button
onClick={e => handleDeleteAlias(e, a.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
aria-label="Remove"
>×</button>
</div>
))}
</div>
</div>
)}
{/* Form section label */}
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>
{editingAlias
? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}`
: 'Add Child'}
</div>
{/* Form */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
{lbl('First Name', true)}
<input className="input" value={form.firstName} onChange={set('firstName')}
autoComplete="off" autoCapitalize="words" />
</div>
<div>
{lbl('Last Name', true)}
<input className="input" value={form.lastName} onChange={set('lastName')}
autoComplete="off" autoCapitalize="words" />
</div>
<div>
{lbl('Date of Birth')}
<input className="input" placeholder="YYYY-MM-DD" value={form.dob} onChange={set('dob')}
autoComplete="off" />
</div>
<div>
{lbl('Phone')}
<input className="input" type="tel" value={form.phone} onChange={set('phone')}
autoComplete="off" />
</div>
</div>
<div>
{lbl('Email (optional)')}
<input className="input" type="email" value={form.email} onChange={set('email')}
autoComplete="off" />
</div>
<div>
{lbl('Avatar (optional)')}
<input type="file" accept="image/*"
onChange={e => setAvatarFile(e.target.files?.[0] || null)} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
{editingAlias && (
<button className="btn btn-secondary" onClick={resetForm}>Cancel Edit</button>
)}
<button className="btn btn-primary" onClick={handleSaveAlias} disabled={saving}>
{saving ? 'Saving…' : editingAlias ? 'Update Alias' : 'Add Alias'}
</button>
</div>
</div>
</>
)}
</div>
</div>
);
}

View File

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

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

View File

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

View File

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

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

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

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