Compare commits

...

213 Commits
1.0.1 ... 1.2.1

Author SHA1 Message Date
adb5e3e137 Merge pull request 'Fixed styling issue on firefox' (#204) from Calendar-Firefox-Tweak into main
All checks were successful
Testing Site CD / Update Development (push) Successful in 3m52s
Live Site CD / Update Deployment (push) Successful in 4m1s
Reviewed-on: #204
2026-03-08 11:00:10 -05:00
1749c3e617 Fixed styling issue on firefox
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m47s
2026-03-08 12:00:25 -04:00
52ee36be44 Merge pull request 'Added cache busting option for devs' (#203) from Developer-Page into main
All checks were successful
Testing Site CD / Update Development (push) Successful in 3m48s
Reviewed-on: #203
2026-03-08 09:34:27 -05:00
0cc327a9c4 Added cache busting option for devs
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m44s
2026-03-08 10:34:29 -04:00
ef3cbbf370 Merge pull request 'Fixed member name filter to work by displayname or actualname' (#202) from Member-Management-Name-Fixes into main
All checks were successful
Testing Site CD / Update Development (push) Successful in 4m8s
Reviewed-on: #202
2026-03-08 09:01:51 -05:00
209d0cdf0f Fixed member name filter to work by displayname or actualname
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 4m40s
2026-03-08 09:57:56 -04:00
ab6c6f9acd Possible fix for user card display crashing
All checks were successful
Testing Site CD / Update Development (push) Successful in 4m14s
Live Site CD / Update Deployment (push) Successful in 4m43s
2026-03-03 21:51:46 -05:00
95200b7970 Merge pull request 'Implemented admin assign unit UI' (#201) from Administrative-Transfer into main
All checks were successful
Testing Site CD / Update Development (push) Successful in 3m43s
Reviewed-on: #201
2026-03-02 19:30:04 -06:00
ae2c4d27bc Tweaked show condition for admin trasnsfer
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m25s
2026-03-02 20:30:53 -05:00
adc9da6a40 added support for integrated rank changes
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m27s
2026-03-02 20:30:01 -05:00
a988545dda Implemented admin assign unit UI
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m55s
2026-03-02 20:16:28 -05:00
54dcb9d389 Merge pull request 'Fixed #194 issues' (#200) from Discharge-Fixes into main
All checks were successful
Testing Site CD / Update Development (push) Successful in 3m50s
Reviewed-on: #200
2026-03-01 20:29:21 -06:00
c34f8beea9 Fixed #194 issues
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m43s
2026-03-01 21:11:32 -05:00
a239b7e204 Merge pull request 'LOA-Self-Extend' (#195) from LOA-Self-Extend into main
All checks were successful
Testing Site CD / Update Development (push) Successful in 3m32s
Reviewed-on: #195
2026-02-19 23:15:08 -06:00
19db5a8ca5 Merge branch 'main' into LOA-Self-Extend
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m15s
2026-02-19 23:12:44 -06:00
4611de4b0d Added descriptor to dropdown menu to explain why its disabled for self extension
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m38s
2026-02-20 00:13:42 -05:00
86d069651c Implemented self extension 2026-02-20 00:10:08 -05:00
82d746fee1 Merge pull request 'Improved HTTP log messages so they dont all just say "HTTP Request completed"' (#193) from Improve-HTTP-log-messages into main
All checks were successful
Testing Site CD / Update Development (push) Successful in 4m33s
Reviewed-on: #193
2026-02-19 21:57:23 -06:00
ae13cdebb3 Improved HTTP log messages so they dont all just say "HTTP Request completed"
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 4m1s
2026-02-19 00:39:59 -05:00
90db7de843 Merge branch 'main' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4
All checks were successful
Testing Site CD / Update Development (push) Successful in 3m46s
2026-02-13 11:03:30 -06:00
a1996419d6 fix database migration caller on deploy scripts
annoying niggle of using containerized setup is binaries you'd think would be in a path across all user contexts... just isn't
2026-02-13 11:03:23 -06:00
4d87ff4925 Merge pull request 'Limited most member search inputs to only display active members' (#191) from #167-Get-Active-Members into main
Some checks failed
Testing Site CD / Update Development (push) Failing after 3m20s
Reviewed-on: #191
2026-02-12 22:19:50 -06:00
2e944231a5 Limited most member search inputs to only display active members
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m28s
2026-02-12 22:53:13 -05:00
947c657e92 Merge pull request 'audit-log' (#190) from audit-log into main
Some checks failed
Testing Site CD / Update Development (push) Failing after 3m20s
Reviewed-on: #190
2026-02-12 21:05:48 -06:00
f1695e3a00 Merge branch 'main' into audit-log
All checks were successful
Pull Request CI / Merge Check (pull_request) Successful in 3m8s
2026-02-12 21:02:36 -06:00
c7d79ae586 integrated audit log into pretty everything hopefully
All checks were successful
Pull Request CI / Update Deployment (pull_request) Successful in 3m28s
2026-02-12 22:04:14 -05:00
545b317caa Merge branch 'main' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4
Some checks failed
Testing Site CD / Update Development (push) Failing after 3m15s
2026-02-12 14:12:17 -06:00
bd8f6ba84b rename task in CI script 2026-02-12 14:12:05 -06:00
9be1d953bf add forgotten database migrations to CD scripts 2026-02-12 14:11:49 -06:00
5106b72e24 Integrated audit log into applications 2026-02-12 14:48:27 -05:00
34ce7d1e14 Implemented audit log system 2026-02-12 14:48:19 -05:00
ab9bb99987 Merge pull request 'Fixed roles array query that returned unexpected null' (#189) from #187-Member-Card-Crash into main
All checks were successful
Testing Site CD / Update Development (push) Successful in 3m24s
Reviewed-on: #189
2026-02-12 10:32:21 -06:00
69c7e7ed7e Fixed roles array query that returned unexpected null
All checks were successful
Pull Request CI / Update Deployment (pull_request) Successful in 3m13s
2026-02-12 09:48:45 -05:00
5d2ad6099c Merge pull request '#179-suspensions' (#188) from #179-suspensions into main
All checks were successful
Testing Site CD / Update Development (push) Successful in 3m34s
Reviewed-on: #188
2026-02-12 08:32:03 -06:00
dc10f05254 Merge branch 'main' into #179-suspensions
All checks were successful
Pull Request CI / Update Deployment (pull_request) Successful in 3m30s
2026-02-11 23:52:03 -06:00
2759167ce6 fixed the filter pagination bug 2026-02-12 00:53:36 -05:00
0f29dabeee added detailed reason to member discharge 2026-02-12 00:53:15 -05:00
1372d4d285 added writing initial state history 2026-02-12 00:03:05 -05:00
c27cd80dfd update ci /cd system
All checks were successful
Testing Site CD / Update Development (push) Successful in 3m58s
simplify setup and add a merge check
2026-02-11 21:57:26 -06:00
410daafa9e Triggered list refresh on suspension change 2026-02-08 13:57:40 -05:00
921e74f188 Integrated new member state into manage members page
Implemented suspend/unsuspend
2026-02-08 13:54:23 -05:00
cf880ed124 Tied in new state system into members filters 2026-02-08 01:06:44 -05:00
f77f5b5a7f Fixed application acceptance not setting state correctly 2026-02-08 00:49:23 -05:00
2789b79b82 rewrote the application form using the new forms system (because somehow member states broke it) 2026-02-07 14:34:21 -05:00
76bf93b790 tweaked a few things to mitigate errors 2026-02-07 13:39:38 -05:00
d6bb2863c2 Merge branch 'main' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4 into #179-suspensions 2026-02-07 13:25:35 -05:00
1101f0eb59 Fixed a whole lotta broken stuff by changing state from a string to a number 2026-02-07 13:25:15 -05:00
d321c83f49 updated db to support state history 2026-02-07 13:24:49 -05:00
2a64577e2d Merge pull request 'Fixed application form error' (#186) from Fixed-critical-application-form-bug into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 3m38s
Reviewed-on: #186
2026-02-06 22:55:31 -06:00
59783ee93a Fixed age calculator visibility 2026-02-06 23:57:20 -05:00
bb01d08622 Fixed application form error 2026-02-06 23:55:09 -05:00
3dc5461783 Merge pull request 'Fixed annoying bullshit' (#184) from devcontainers into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m51s
Reviewed-on: #184
2026-02-03 21:42:21 -06:00
d8455ccaa3 Merge branch 'main' into devcontainers 2026-02-03 21:42:12 -06:00
7ca617a51c Fixed annoying bullshit 2026-02-03 22:41:37 -05:00
0e2c5f8318 Merge pull request 'devcontainers' (#183) from devcontainers into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m49s
Reviewed-on: #183
2026-02-03 21:27:50 -06:00
6811dc461c Merge branch 'main' into devcontainers 2026-02-03 21:27:30 -06:00
6f11bdb01d Merge branch 'devcontainers' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4 into devcontainers 2026-02-03 22:27:03 -05:00
dd440a4e75 Cleaned up unused tables 2026-02-03 22:27:02 -05:00
2f7276a6c6 Merge pull request 'devcontainers' (#182) from devcontainers into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 3m1s
Reviewed-on: #182
2026-02-03 21:23:10 -06:00
c18ef9aa8d Merge branch 'main' into devcontainers 2026-02-03 21:22:57 -06:00
3a5f9eb6f0 Merge branch 'devcontainers' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4 into devcontainers 2026-02-03 22:22:52 -05:00
ab31b6e9f2 Corrected SP update handling 2026-02-03 22:22:50 -05:00
9ec30be6fb Merge pull request 'devcontainers' (#181) from devcontainers into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 3m1s
Reviewed-on: #181
2026-02-03 20:40:22 -06:00
0c58e4045f Merge branch 'main' into devcontainers 2026-02-03 20:40:11 -06:00
ca23675dd1 Merge branch 'devcontainers' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4 into devcontainers 2026-02-03 21:39:48 -05:00
e8805616c7 Fixed view creation breaking 2026-02-03 21:39:47 -05:00
1f9511139f Merge pull request 'Fixed stored procs trying to overwrite themselves' (#180) from devcontainers into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m59s
Reviewed-on: #180
2026-02-03 20:31:58 -06:00
d8fbaed538 Merge branch 'main' into devcontainers 2026-02-03 20:31:46 -06:00
edbd18744d Fixed stored procs trying to overwrite themselves 2026-02-03 21:28:27 -05:00
76ca516bf6 Merge pull request 'devcontainers' (#176) from devcontainers into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 3m43s
Reviewed-on: #176
2026-02-03 20:16:12 -06:00
c4f46eeffd Merge branch 'main' into devcontainers 2026-02-03 19:09:30 -06:00
a7c8380c16 Merge branch 'main' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m45s
2026-02-02 00:39:06 -05:00
ea23589162 WHOOPS FIXED WRONG TEXT COLOR IN BANNER 2026-02-02 00:38:50 -05:00
32933f195e Merge pull request '#177-Overdue-LOA-not-showing' (#178) from #177-Overdue-LOA-not-showing into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 3m17s
Reviewed-on: #178
2026-02-01 23:34:59 -06:00
6f57e12a42 Improved ovedue message 2026-02-02 00:35:57 -05:00
321cb80c06 fixed overdue LOA's not being sent to the client 2026-02-02 00:28:31 -05:00
d0839ed51d Removed logging 2026-02-01 11:44:02 -05:00
ec4a35729f Fixed member role parsing 2026-02-01 11:42:01 -05:00
686838e9bf Merge remote-tracking branch 'Origin/main' into devcontainers 2026-02-01 11:35:47 -05:00
7445dbf9f8 Integrated db-migrate package 2026-02-01 11:35:24 -05:00
bd0820ffc8 I dun fucked up lol, forgot to turn off a calendar feature I was testing
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m33s
2026-01-30 21:43:09 -05:00
a95b36da21 Merge pull request 'Fixed later weeks not being accessable on smaller desktop screens' (#174) from Calendar-Scaling-Fix into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 3m5s
Reviewed-on: #174
2026-01-30 20:39:27 -06:00
fb8b82724d Fixed later weeks not being accessable on smaller desktop screens 2026-01-30 21:40:54 -05:00
efbc845ee2 Merge pull request 'Members-Page-Revival' (#171) from Members-Page-Revival into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 3m17s
Reviewed-on: #171
2026-01-28 14:53:34 -06:00
e022a33b69 Improved pagination display for large page counts 2026-01-28 15:53:51 -05:00
dd95adec3f tweaked state dropdown to reflect member as default state 2026-01-28 15:50:24 -05:00
9728a6c09a Made default state filter "member" instead of "all" 2026-01-28 15:48:20 -05:00
6e189796fa fixed discharge form button spacing 2026-01-28 15:44:38 -05:00
2a187e65ed Used member card in member display 2026-01-28 15:41:46 -05:00
22eaba6f90 Wrapped up discharge form close #159 2026-01-28 15:39:45 -05:00
c646254616 first pass of discharge form 2026-01-27 15:00:29 -05:00
67562f56aa Revived the page! He's baaaaack 2026-01-27 10:01:41 -05:00
8415e27ff3 added readme 2026-01-26 20:41:25 -05:00
083ddc345b overhauled mock auth solution 2026-01-26 01:14:19 -05:00
b4fcb1a366 finalized migration scripts 2026-01-25 20:14:24 -05:00
7017c2427c Updated db scripts 2026-01-25 10:49:29 -05:00
7c7cbef3f3 tweaked member page sizing 2026-01-21 17:52:43 -05:00
1d6f17b725 reimplemented member page 2026-01-21 16:34:35 -05:00
f9f1593b46 Merge branch 'main' into devcontainers 2026-01-21 12:35:14 -05:00
f087461e09 Merge pull request 'Implemented user deserialize cache' (#163) from Auth-Optimizations into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m57s
Reviewed-on: #163
2026-01-21 09:59:00 -06:00
a0a405de85 Implemented user deserialize cache 2026-01-21 01:03:46 -05:00
f26b285a88 Fixed launch script 2026-01-19 19:27:11 -05:00
d9732830bb added auth mode changes 2026-01-19 19:22:15 -05:00
2c2936b01f Merge remote-tracking branch 'Origin/main' into devcontainers 2026-01-19 19:07:13 -05:00
ce093af58e Merge pull request '155-Prevent-multi-submit' (#156) from 155-Prevent-multi-submit into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m59s
Reviewed-on: #156
2026-01-19 18:03:51 -06:00
9baf2b97b9 Added spinner reference 2026-01-19 19:04:53 -05:00
4069b7274d added submit guard to manage groups 2026-01-19 19:04:45 -05:00
30a97082a1 added submit guard to LOA form 2026-01-19 19:02:43 -05:00
aa7f11cb97 implemented submission guard for promotion form 2026-01-17 23:11:23 -05:00
b8c6590159 Merge pull request 'Fix #147 prevent double clicking submit button' (#154) from #147-Training-Report-Double-Posting into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m29s
Continuous Deployment / Update Deployment (push) Successful in 2m53s
Reviewed-on: #154
2026-01-17 10:13:26 -06:00
52bea200c8 Fix #147 prevent double clicking submit button 2026-01-17 11:14:31 -05:00
7fff220053 Merge pull request '#120-LOA-Extension-Bug' (#153) from #120-LOA-Extension-Bug into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m40s
Reviewed-on: #153
2026-01-17 09:24:05 -06:00
afbb771061 improved full details panel 2026-01-17 10:25:18 -05:00
cdf8f57eb5 Added support for extended visuals 2026-01-17 10:25:06 -05:00
3ff28de269 Merge pull request 'removed crash causing log line' (#152) from #151-logging-crash into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m38s
Reviewed-on: #152
2026-01-17 00:02:25 -06:00
f26a334487 removed crash causing log line 2026-01-17 01:03:30 -05:00
c14475258d Merge pull request '#134-Calendar-Upgrades' (#150) from #134-Calendar-Upgrades into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m33s
Reviewed-on: #150
2026-01-16 18:37:02 -06:00
dd21d12dd5 Merge branch 'main' into #134-Calendar-Upgrades 2026-01-16 18:36:54 -06:00
a4f762e793 Merge pull request 'Promotions-Fixes' (#149) from Promotions-Fixes into main
Some checks failed
Continuous Integration / Update Development (push) Has been cancelled
Reviewed-on: #149
2026-01-16 18:36:38 -06:00
b60e5ae28b a few mobile improvements 2026-01-16 19:37:26 -05:00
fafacbefc3 Wrapped up approval visuals and refresh behaviour 2026-01-16 19:17:43 -05:00
19eb2be252 Added "approved by" system 2026-01-16 16:26:20 -05:00
1c1358f9d0 updated timezone/date handling for fields that dont require time data 2026-01-15 22:48:39 -05:00
5fdb0b45f0 Improved spacing on attendees list to reduce panel width issues as mentioned in #134 2026-01-15 20:34:20 -05:00
f58d0114eb Mobile UX improvements for calendar 2026-01-15 20:22:31 -05:00
f4abc51198 Tweaked banner width because it was annoying me 2026-01-15 20:00:03 -05:00
7d5e9c33bf Fixed calendar layout reactivity issue 2026-01-15 19:57:29 -05:00
81bac9bcfb Merge pull request '#94-Application-Improvements' (#143) from #94-Application-Improvements into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m34s
Reviewed-on: #143
2026-01-02 13:59:07 -06:00
d9c721791e Added age preview to application view 2026-01-02 14:59:37 -05:00
0a3ed7b569 fixed copy link appearing when it shouldnt 2026-01-02 14:44:30 -05:00
ec886f986f Added warning if application disocrd webhook is not defined
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m24s
2026-01-02 10:38:57 -05:00
82e3140499 Merge pull request 'webhooks/integrations' (#142) from webhooks/integrations into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m27s
Reviewed-on: #142
2026-01-01 21:03:33 -06:00
2fc11a0589 fixed applications webhook example 2026-01-01 16:04:21 -05:00
318762e1b4 implemented integrations and events system 2026-01-01 16:04:04 -05:00
d962b88d73 Merge pull request '#136-Link-Copy' (#140) from #136-Link-Copy into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m30s
Reviewed-on: #140
2026-01-01 10:42:40 -06:00
1348d01b9d Merge branch 'main' into #136-Link-Copy 2026-01-01 10:42:23 -06:00
dbca679964 added copy link to applications 2026-01-01 11:41:42 -05:00
ab247d720d added copy link button 2026-01-01 02:35:36 -05:00
b94504bb69 added copy link utility 2026-01-01 02:29:13 -05:00
9b16ff429a tweaked dev build header 2026-01-01 00:58:45 -05:00
8b1f30611c tweaked dev build header
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m5s
2026-01-01 00:53:46 -05:00
08aa87de5b Merge pull request 'logging-overhaul-2' (#139) from logging-overhaul-2 into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 3m2s
Reviewed-on: #139
2025-12-31 21:46:46 -06:00
ac82a80c1c removed a forced exception from testing error logging 2025-12-31 21:46:13 -05:00
9f895a202d integrated the new logger across the entire API 2025-12-31 21:45:38 -05:00
510d4a13ac updated env example 2025-12-31 13:14:52 -05:00
6139f12e13 added error handling system in logger 2025-12-31 13:14:47 -05:00
cf8f0fbc34 implemented performance profiling into some areas 2025-12-31 12:01:03 -05:00
d101bf9686 implemented new logging system in first iteration 2025-12-31 11:26:44 -05:00
46988f1921 fixed some errors preventing request logging 2025-12-31 10:13:57 -05:00
42b96d58a0 added new logging system 2025-12-31 09:51:40 -05:00
0e6a3c4a01 Restructured services to support more... services 2025-12-31 09:51:31 -05:00
c02e4e2851 fixed promotion form visibility
All checks were successful
Continuous Integration / Update Development (push) Successful in 1m52s
2025-12-30 22:45:19 -05:00
5f3d78afde Resolved a NaN error in role management
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m2s
2025-12-30 22:29:10 -05:00
aae47003cf Removed deprecated transfer pages
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m8s
2025-12-30 22:07:07 -05:00
dcf3b208d8 Merge pull request 'Promotion Form' (#118) from promotions into main
Some checks failed
Continuous Integration / Update Development (push) Failing after 1m35s
Reviewed-on: #118
2025-12-30 20:57:24 -06:00
40e53c52b4 Merge branch 'main' into promotions 2025-12-30 20:57:18 -06:00
eb92dcafda Merge pull request 'Role-Management-Overhaul' (#122) from Role-Management-Overhaul into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 1m52s
Reviewed-on: #122
2025-12-30 20:54:50 -06:00
7fb8852ac0 Merge pull request 'LOA-Fix' (#123) from LOA-Fix into main
Some checks failed
Continuous Integration / Update Development (push) Has been cancelled
Reviewed-on: #123
2025-12-30 20:53:42 -06:00
275fba292c Merge pull request 'Training-Report-Visuals' (#131) from Training-Report-Visuals into main
Some checks failed
Continuous Integration / Update Development (push) Has been cancelled
Reviewed-on: #131
2025-12-30 20:53:31 -06:00
17018b4bc9 Merge branch 'main' into Training-Report-Visuals 2025-12-30 20:53:22 -06:00
3a09030193 Merge pull request '132-view-empty-application-fix' (#137) from 132-view-empty-application-fix into main
Some checks failed
Continuous Integration / Update Development (push) Has been cancelled
Reviewed-on: #137
2025-12-30 20:53:11 -06:00
d872d342b2 Merge branch 'main' into 132-view-empty-application-fix 2025-12-30 20:53:01 -06:00
569902e11c Merge pull request 'logging-overhaul' (#138) from logging-overhaul into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m37s
Reviewed-on: #138
2025-12-30 19:59:45 -06:00
6acd423557 removed chalk dependency and patched vulnerability 2025-12-30 20:58:31 -05:00
e6e09f8c3e redesigned http logger to output json objects 2025-12-30 20:58:07 -05:00
e177723767 Update data migrations and removed redundant env 2025-12-30 20:41:14 -05:00
dae6d142f2 Merge branch 'main' into devcontainers 2025-12-30 20:00:35 -05:00
593b91aa7d removed old todo message 2025-12-30 15:07:54 -05:00
af723c99c9 improved visuals for notfound application state 2025-12-30 14:35:27 -05:00
efb5508a8d fixed server error when trying to view nonexistend application 2025-12-30 14:24:25 -05:00
4fbbed446e improved attendee remarks rendering 2025-12-28 20:10:32 -05:00
3c689fbb30 Close #124 fixed checkmark visual state and added tooltip 2025-12-28 14:46:04 -05:00
7661b3c8d5 Moved tooltip to a reusable component 2025-12-28 14:27:37 -05:00
3848eb939a Tweaked LOA API RBAC to allow full command group access
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m48s
Continuous Deployment / Update Deployment (push) Successful in 2m32s
2025-12-22 21:36:10 -05:00
a52f5cd31a Merge pull request 'added tracking for course book/qual states at report time' (#128) from Training-Report-Improvements into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 3m28s
Reviewed-on: #128
2025-12-21 18:33:11 -06:00
46d1a0c286 added tracking for course book/qual states at report time 2025-12-21 19:21:12 -05:00
6c2b88352d Merge pull request 'Increased session longevity and implemented refresh system' (#126) from Session-length-extension into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m40s
Reviewed-on: #126
2025-12-20 10:12:32 -06:00
71f9240088 Merge branch 'main' into Session-length-extension 2025-12-20 10:12:26 -06:00
e35b61d06b Increased session longevity and implemented refresh system
also added type support for express-session
2025-12-20 11:13:28 -05:00
dc3430aa2e Merge pull request 'Removed hard dependency on discord ID for auth system' (#125) from Login-discord-decouple into main
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m51s
Reviewed-on: #125
2025-12-19 22:20:52 -06:00
ff5371d867 Removed hard dependency on discord ID for auth system 2025-12-19 22:46:53 -05:00
8903baef52 Upgraded handling of duplicate member roles Close #56 2025-12-18 22:38:36 -05:00
871277882d imporoved readability of LOA reason 2025-12-18 22:08:34 -05:00
bb4d6a3a1a Fixed button sizing on the loa table 2025-12-18 22:05:51 -05:00
8f16d5190c fixed incorrect label for extended LOAs when past their original end data 2025-12-18 21:57:43 -05:00
80786f996f added unit colored dot next to unit display 2025-12-18 17:43:36 -05:00
f124e41630 added roles to member card 2025-12-18 17:39:44 -05:00
a699c20f9b fixed members list overflow handling 2025-12-18 16:57:56 -05:00
e01e742c07 hooked up add and remove member 2025-12-18 16:55:05 -05:00
1db75ee773 implemented group member search 2025-12-18 16:21:34 -05:00
f0624a64bd locked down delete role functionality 2025-12-18 16:21:06 -05:00
8c04d2cf05 first pass of new UI 2025-12-18 15:12:22 -05:00
3b261bc18e fixed loading spinner position 2025-12-18 00:36:09 -05:00
9a9cbc323e Merge branch 'promotions' of https://gitea.iceberg-gaming.com/17th-Ranger-Battalion-ORG/milsim-site-v4 into promotions 2025-12-18 00:35:41 -05:00
072c82f578 Merge branch 'main' into promotions 2025-12-17 23:33:10 -06:00
5e06e38a0d Merge remote-tracking branch 'Origin/main' into promotions 2025-12-17 23:45:58 -05:00
b8750f1e8e implemented "today" button for quickly setting promotion dates 2025-12-17 22:58:46 -05:00
00f8d583cc finished hooking up promotion history 2025-12-17 22:36:51 -05:00
f3e35f3f6a improved robustness of logout function
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m29s
2025-12-17 19:46:30 -05:00
d7b099ac75 fixed for reals this time
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m26s
Continuous Deployment / Update Deployment (push) Successful in 2m26s
2025-12-17 17:20:28 -05:00
a6b521a89c Fixed hardcoded database name
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m27s
Continuous Deployment / Update Deployment (push) Successful in 2m24s
2025-12-17 17:15:33 -05:00
6d83a2d342 first pass of promotions list view 2025-12-17 17:14:22 -05:00
43763853f8 Finished list render system 2025-12-17 16:02:20 -05:00
ad4d28b5dd Made calendar cancel button red
All checks were successful
Continuous Integration / Update Development (push) Successful in 2m23s
2025-12-17 13:11:24 -05:00
278690e094 Began implementation for getting promotion history 2025-12-16 22:28:32 -05:00
f9e5dacda8 tweaked error message for empty promotion batch 2025-12-16 19:31:32 -05:00
e0d9eeae92 Rank change system UI first pass 2025-12-16 18:47:56 -05:00
7990c86a86 disabled transfer request navigation 2025-12-16 14:22:24 -05:00
67ce112934 added database seed function 2025-10-29 00:34:34 -04:00
33eca18e82 added database migration system, reference package.json for commands 2025-10-28 21:31:14 -04:00
6b29501d59 created .env manager system and prod/dev run commands 2025-10-28 17:48:55 -04:00
8670b50b56 ignored dev database files 2025-10-28 17:47:56 -04:00
4445f5dd92 created docker compose dev 2025-10-28 16:24:22 -04:00
110 changed files with 120212 additions and 1748 deletions

View File

@@ -1,11 +1,11 @@
name: Continuous Integration name: Testing Site CD
on: on:
push: push:
branches: branches:
- main - main
jobs: jobs:
Deploy: deploy-testing-cd:
name: Update Development name: Update Development
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@@ -40,14 +40,20 @@ jobs:
- name: Token Copy - name: Token Copy
run: | run: |
cd /var/www/html/milsim-site-v4 cd /var/www/html/milsim-site-v4
cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config cp ${{ gitea.workspace }}/.git/config .git/config
chown nginx:nginx .git/config chown nginx:nginx .git/config
- name: Update Application Code - name: Update Application Code
run: | run: |
cd /var/www/html/milsim-site-v4 cd /var/www/html/milsim-site-v4
version=`git log -1 --format=%H`
echo "Current Revision: $version"
echo "Updating to: ${{ github.sha }}"
sudo -u nginx git reset --hard sudo -u nginx git reset --hard
sudo -u nginx git fetch --tags
sudo -u nginx git pull origin main sudo -u nginx git pull origin main
new_version=`git log -1 --format=%H`
echo "Successfully updated to: $new_version"
- name: Update Shared Dependencies and Fix Permissions - name: Update Shared Dependencies and Fix Permissions
run: | run: |
@@ -83,6 +89,12 @@ jobs:
sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx . chown -R nginx:nginx .
- name: Run Database Migrations
run: |
cd /var/www/html/milsim-site-v4/api
npx db-migrate up -e prod
chown -R nginx:nginx .
- name: Reset File Permissions - name: Reset File Permissions
run: | run: |
sudo chown -R nginx:nginx /var/www/html/milsim-site-v4 sudo chown -R nginx:nginx /var/www/html/milsim-site-v4

View File

@@ -1,11 +1,11 @@
name: Continuous Deployment name: Live Site CD
on: on:
push: push:
tags: tags:
- '*' - '*'
jobs: jobs:
Deploy: deploy-live-cd:
name: Update Deployment name: Update Deployment
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@@ -40,7 +40,7 @@ jobs:
- name: Token Copy - name: Token Copy
run: | run: |
cd /var/www/html/milsim-site-v4 cd /var/www/html/milsim-site-v4
cp /workspace/17th-Ranger-Battalion-ORG/milsim-site-v4/.git/config .git/config cp ${{ gitea.workspace }}/.git/config .git/config
chown nginx:nginx .git/config chown nginx:nginx .git/config
- name: Update Application Code - name: Update Application Code
@@ -89,6 +89,12 @@ jobs:
sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env sed -i "s/APPLICATION_VERSION=.*/APPLICATION_VERSION=$version/" .env
chown -R nginx:nginx . chown -R nginx:nginx .
- name: Run Database Migrations
run: |
cd /var/www/html/milsim-site-v4/api
npx db-migrate up -e prod
chown -R nginx:nginx .
- name: Reset File Permissions - name: Reset File Permissions
run: | run: |
sudo chown -R nginx:nginx /var/www/html/milsim-site-v4 sudo chown -R nginx:nginx /var/www/html/milsim-site-v4

View File

@@ -0,0 +1,58 @@
name: Pull Request CI
on:
pull_request:
branches:
- main
types:
- opened
- synchronize
- reopened
jobs:
build:
name: Merge Check
runs-on: ubuntu-latest
container:
steps:
- name: Update Node Environment
uses: actions/setup-node@v6
with:
node-version: 20.19
- name: Verify Local Environment
run: |
which npm
npm -v
which node
node -v
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: 'main'
- name: Install Shared Dependencies
run: |
cd ${{ gitea.workspace }}/shared
npm install
- name: Install UI Dependencies
run: |
cd ${{ gitea.workspace }}/ui
npm install
- name: Install API Dependencies
run: |
cd ${{ gitea.workspace }}/api
npm install
- name: Build UI
run: |
cd ${{ gitea.workspace }}/ui
npm run build
- name: Build API
run: |
cd ${{ gitea.workspace }}/api
npm run build

2
.gitignore vendored
View File

@@ -32,3 +32,5 @@ coverage
*.sql *.sql
.env .env
*.db *.db
db_data

View File

@@ -21,7 +21,13 @@ CLIENT_URL= # This is whatever URL the client web app is served on
CLIENT_DOMAIN= #whatever.com CLIENT_DOMAIN= #whatever.com
APPLICATION_VERSION= # Should match release tag APPLICATION_VERSION= # Should match release tag
APPLICATION_ENVIRONMENT= # dev / prod APPLICATION_ENVIRONMENT= # dev / prod
CONFIG_ID= # configures CONFIG_ID= # config version
# webhooks/integrations
DISCORD_APPLICATIONS_WEBHOOK=
# Logger
LOG_DEPTH= # normal / verbose / profiling
# Glitchtip # Glitchtip
GLITCHTIP_DSN= GLITCHTIP_DSN=

2
api/.gitignore vendored
View File

@@ -1 +1,3 @@
built built
!migrations/*/*.sql

20
api/database.json Normal file
View File

@@ -0,0 +1,20 @@
{
"dev": {
"driver": "mysql",
"user": "root",
"password": "root",
"host": "localhost",
"database": "ranger_unit_tracker",
"port": "3306",
"multipleStatements": true
},
"prod": {
"driver": "mysql",
"user": {"ENV" : "DB_USERNAME"},
"password": {"ENV" : "DB_PASSWORD"},
"host": {"ENV" : "DB_HOST"},
"database": {"ENV" : "DB_DATABASE"},
"port": {"ENV" : "DB_PORT"},
"multipleStatements": true
}
}

View File

@@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260201154439-initial-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260201154439-initial-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260204025935-remove-unused-tables-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260204025935-remove-unused-tables-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260204140912-state-history-suspensions-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260204140912-state-history-suspensions-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260212052346-state-reason-detailed-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260212052346-state-reason-detailed-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260212165353-audit-log-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20260212165353-audit-log-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

112185
api/migrations/seed.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
/* Replace with your SQL commands */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
/* Replace with your SQL commands */

View File

@@ -0,0 +1,5 @@
/* Replace with your SQL commands */
DROP PROCEDURE `sp_update_member_rank_Backup_1-27-2026`;
DROP PROCEDURE `sp_update_member_status_Backup_1-27-2026`;
DROP PROCEDURE `sp_update_member_unit_Backup_1-27-2026`;

View File

@@ -0,0 +1,14 @@
/* Replace with your SQL commands */
UPDATE members m
JOIN account_states s ON m.state_id = s.id
SET m.state_legacy = s.name;
ALTER TABLE members DROP FOREIGN KEY fk_members_state_id,
DROP INDEX idx_members_state_id,
DROP COLUMN state_id;
ALTER TABLE members
RENAME COLUMN state_legacy TO state;
DROP TABLE IF EXISTS member_state_history;
DROP TABLE IF EXISTS account_states;

View File

@@ -0,0 +1,57 @@
/* Replace with your SQL commands */
CREATE TABLE IF NOT EXISTS account_states (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_account_states_name (name)
);
INSERT IGNORE INTO account_states (name)
VALUES ('guest'),
('applicant'),
('member'),
('retired'),
('discharged'),
('suspended'),
('banned'),
('denied');
ALTER TABLE members
RENAME COLUMN state TO state_legacy;
ALTER TABLE members
ADD COLUMN state INT NOT NULL DEFAULT 1,
ADD INDEX idx_members_state (state),
ADD CONSTRAINT fk_members_state_id FOREIGN KEY (state) REFERENCES account_states(id);
CREATE TABLE IF NOT EXISTS member_state_history (
id INT AUTO_INCREMENT PRIMARY KEY,
member_id INT NOT NULL,
state_id INT NOT NULL,
reason VARCHAR(255),
created_by_id INT,
start_date DATE,
end_date DATE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_member_state_history_member_id (member_id),
CONSTRAINT fk_member_state_history_member FOREIGN KEY (member_id) REFERENCES members(id),
CONSTRAINT fk_member_state_type FOREIGN KEY (state_id) REFERENCES account_states(id),
CONSTRAINT fk_member_state_history_created_by FOREIGN KEY (created_by_id) REFERENCES members(id)
);
-- Convert member states to new system
UPDATE members m
JOIN account_states s ON m.state_legacy = s.name
SET m.state = s.id;
-- Initial history population
INSERT INTO member_state_history (
member_id,
state_id,
reason,
start_date,
created_at
)
SELECT id,
state,
'history start',
CURDATE(),
NOW()
FROM members;

View File

@@ -0,0 +1 @@
/* Replace with your SQL commands */

View File

@@ -0,0 +1,3 @@
/* Replace with your SQL commands */
ALTER TABLE member_state_history ADD reason_detailed TEXT;

View File

@@ -0,0 +1 @@
/* Replace with your SQL commands */

View File

@@ -0,0 +1,17 @@
CREATE TABLE audit_log (
id INT PRIMARY KEY AUTO_INCREMENT,
-- "area.action" (e.g., 'calendarEvent.create', 'member.update_rank')
action_type VARCHAR(100) NOT NULL,
-- The JSON blob containing detailed information
payload JSON DEFAULT NULL,
-- Identifying the actor
created_by INT,
-- The ID of the resource being acted upon
target_id INT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_created_by FOREIGN KEY (created_by) REFERENCES members(id) ON DELETE
SET NULL,
INDEX idx_action (action_type),
INDEX idx_target (target_id)
);

911
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,26 +9,33 @@
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsc && tsc-alias && node ./built/api/src/index.js", "dev": "tsc && tsc-alias && node ./built/api/src/index.js",
"build": "tsc && tsc-alias" "prod": "tsc && tsc-alias && node ./built/api/src/index.js",
"build": "tsc && tsc-alias",
"seed": "node ./scripts/seed.js"
}, },
"dependencies": { "dependencies": {
"@rsol/hashmig": "^1.0.7",
"@sentry/node": "^10.27.0", "@sentry/node": "^10.27.0",
"chalk": "^5.6.2", "@types/express-session": "^1.18.2",
"connect-sqlite3": "^0.9.16", "connect-sqlite3": "^0.9.16",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.1", "db-migrate": "^0.11.14",
"db-migrate-mysql": "^3.0.0",
"dotenv": "16.6.1",
"express": "^5.1.0", "express": "^5.1.0",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"mariadb": "^3.4.5", "mariadb": "^3.4.5",
"morgan": "^1.10.1", "morgan": "^1.10.1",
"mysql2": "^3.14.3", "mysql2": "^3.14.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-custom": "^1.1.1",
"passport-openidconnect": "^0.1.2" "passport-openidconnect": "^0.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/morgan": "^1.9.10", "@types/morgan": "^1.9.10",
"@types/node": "^24.8.1", "@types/node": "^24.8.1",
"cross-env": "^10.1.0",
"tsc-alias": "^1.8.16", "tsc-alias": "^1.8.16",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

29
api/scripts/migrate.js Normal file
View File

@@ -0,0 +1,29 @@
const dotenv = require('dotenv');
const path = require('path');
const { execSync } = require('child_process');
dotenv.config({ path: path.resolve(process.cwd(), `.env`) });
const db = {
user: process.env.DB_USERNAME,
pass: process.env.DB_PASSWORD,
host: process.env.DB_MIGRATION_HOST,
port: process.env.DB_PORT,
name: process.env.DB_DATABASE,
};
const dbUrl = `mysql://${db.user}:${db.pass}@tcp(${db.host}:${db.port})/${db.name}`;
const args = process.argv.slice(2).join(" ");
const migrations = path.join(process.cwd(), "migrations");
const cmd = [
"docker run --rm",
`-v "${migrations}:/migrations"`,
"migrate/migrate",
"-path=/migrations",
`-database "mysql://${db.user}:${db.pass}@tcp(${db.host}:${db.port})/${db.name}"`, // Use double quotes
args,
].join(" ");
console.log(cmd);
execSync(cmd, { stdio: "inherit" });

33
api/scripts/seed.js Normal file
View File

@@ -0,0 +1,33 @@
const dotenv = require("dotenv");
const path = require("path");
const mariadb = require("mariadb");
const fs = require("fs");
dotenv.config({ path: path.resolve(process.cwd(), `.env`) });
const { DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE, APPLICATION_ENVIRONMENT } = process.env;
//do not accidentally seed prod pls
if (APPLICATION_ENVIRONMENT !== "dev") {
console.log("PLEASE DO NOT SEED PROD!!!!");
process.exit(0);
}
(async () => {
const conn = await mariadb.createConnection({
host: DB_HOST,
port: DB_PORT,
user: DB_USERNAME,
password: DB_PASSWORD,
database: DB_DATABASE,
multipleStatements: true,
});
const seedFile = path.join(process.cwd(), "migrations", "seed.sql");
const sql = fs.readFileSync(seedFile, "utf8");
await conn.query(sql);
await conn.end();
console.log("Seeded");
})();

View File

@@ -1,8 +1,5 @@
// const mariadb = require('mariadb') // const mariadb = require('mariadb')
import * as mariadb from 'mariadb'; import * as mariadb from 'mariadb';
const dotenv = require('dotenv')
dotenv.config();
const pool = mariadb.createPool({ const pool = mariadb.createPool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
@@ -12,7 +9,7 @@ const pool = mariadb.createPool({
connectionLimit: 5, connectionLimit: 5,
connectTimeout: 10000, // give it more breathing room connectTimeout: 10000, // give it more breathing room
acquireTimeout: 15000, acquireTimeout: 15000,
database: 'ranger_unit_tracker', database: process.env.DB_DATABASE,
ssl: false, ssl: false,
}); });

View File

@@ -1,31 +1,41 @@
import dotenv = require('dotenv'); import dotenv = require('dotenv');
dotenv.config(); dotenv.config({ quiet: true });
import express = require('express'); import express = require('express');
import cors = require('cors'); import cors = require('cors');
import morgan = require('morgan'); import morgan = require('morgan');
import { logger, LogHeader, LogPayload } from './services/logging/logger';
const app = express() const app = express()
import chalk from 'chalk';
app.use(morgan((tokens: morgan.TokenIndexer, req: express.Request, res: express.Response) => { app.use(morgan((tokens: morgan.TokenIndexer, req: express.Request, res: express.Response) => {
const status = Number(tokens.status(req, res));
// Colorize status code const head: LogHeader = {
const statusColor = status >= 500 ? chalk.red type: 'http',
: status >= 400 ? chalk.yellow level: 'info',
: status >= 300 ? chalk.cyan depth: 'normal',
: chalk.green; timestamp: new Date().toISOString(),
}
return [ const payload: LogPayload = {
chalk.gray(`[${new Date().toISOString()}]`), message: `${tokens.method(req, res)} ${tokens.url(req, res)}`,
chalk.blue.bold(tokens.method(req, res)), // message: 'HTTP request completed',
tokens.url(req, res), data: {
statusColor(status), method: tokens.method(req, res),
chalk.magenta(tokens['response-time'](req, res) + ' ms'), path: tokens.url(req, res),
chalk.yellow(`- User: ${req.user?.name ? `${req.user.name} (${req.user.id})` : 'Unauthenticated'}`), status: Number(tokens.status(req, res)),
].join(' '); response_time_ms: Number(tokens['response-time'](req, res)),
user_id: req.user?.id,
user_name: req.user?.name,
user_agent: req.headers['user-agent'],
},
}
logger.log(head.level, head.type, payload.message, payload.data, head.depth)
return '';
}, { }, {
skip: (req: express.Request) => { skip: (req: express.Request) => {
return req.originalUrl === '/members/me'; return req.originalUrl === '/members/me' || req.originalUrl === '/ping';
} }
})) }))
@@ -43,33 +53,43 @@ const port = process.env.SERVER_PORT;
//glitchtip setup //glitchtip setup
import sentry = require('@sentry/node'); import sentry = require('@sentry/node');
if (process.env.DISABLE_GLITCHTIP === "true") { if (process.env.DISABLE_GLITCHTIP === "true") {
console.log("Glitchtip disabled") logger.info('app', 'Glitchtip disabled', null, 'normal')
} else { } else {
let dsn = process.env.GLITCHTIP_DSN; let dsn = process.env.GLITCHTIP_DSN;
let release = process.env.APPLICATION_VERSION; let release = process.env.APPLICATION_VERSION;
let environment = process.env.APPLICATION_ENVIRONMENT; let environment = process.env.APPLICATION_ENVIRONMENT;
console.log(release, environment)
sentry.init({ dsn: dsn, release: release, environment: environment, integrations: [sentry.captureConsoleIntegration({ levels: ['error'] })] }); sentry.init({ dsn: dsn, release: release, environment: environment, integrations: [sentry.captureConsoleIntegration({ levels: ['error'] })] });
console.log("Glitchtip initialized"); logger.info('app', 'Glitchtip initialized', null, 'normal')
} }
//session setup //session setup
import path = require('path'); import path = require('path');
// import session = require('express-session');
import session = require('express-session'); import session = require('express-session');
import passport = require('passport'); import passport = require('passport');
const SQLiteStore = require('connect-sqlite3')(session); const SQLiteStore = require('connect-sqlite3')(session);
app.use(session({ const cookieOptions: session.CookieOptions = {
httpOnly: true,
sameSite: 'lax',
domain: process.env.CLIENT_DOMAIN,
maxAge: 1000 * 60 * 60 * 24 * 30, //30 days
}
const sessionOptions: session.SessionOptions = {
secret: 'whatever', secret: 'whatever',
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
store: new SQLiteStore({ db: 'sessions.db', dir: './' }), store: new SQLiteStore({ db: 'sessions.db', dir: './' }),
cookie: { rolling: true,
httpOnly: true, cookie: cookieOptions
sameSite: 'lax',
domain: process.env.CLIENT_DOMAIN
} }
}));
import { initializeDiscordIntegrations } from './services/integrations/discord';
//event bus setup
initializeDiscordIntegrations();
app.use(session(sessionOptions));
app.use(passport.authenticate('session')); app.use(passport.authenticate('session'));
// Mount route modules // Mount route modules
@@ -83,6 +103,7 @@ import { roles, memberRoles } from './routes/roles';
import { courseRouter, eventRouter } from './routes/course'; import { courseRouter, eventRouter } from './routes/course';
import { calendarRouter } from './routes/calendar'; import { calendarRouter } from './routes/calendar';
import { docsRouter } from './routes/docs'; import { docsRouter } from './routes/docs';
import { memberUnits, units } from './routes/units';
app.use('/application', applicationRouter); app.use('/application', applicationRouter);
app.use('/ranks', ranks); app.use('/ranks', ranks);
@@ -96,6 +117,8 @@ app.use('/memberRoles', memberRoles)
app.use('/course', courseRouter) app.use('/course', courseRouter)
app.use('/courseEvent', eventRouter) app.use('/courseEvent', eventRouter)
app.use('/calendar', calendarRouter) app.use('/calendar', calendarRouter)
app.use('/units', units)
app.use('/memberUnits', memberUnits);
app.use('/docs', docsRouter) app.use('/docs', docsRouter)
app.use('/', authRouter) app.use('/', authRouter)
@@ -104,5 +127,5 @@ app.get('/ping', (req, res) => {
}); });
app.listen(port, () => { app.listen(port, () => {
console.log(`Example app listening on port ${port} `) logger.info('app', `Example app listening on port ${port} `)
}) })

View File

@@ -2,59 +2,100 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
import pool from '../db'; import pool from '../db';
import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/applicationService'; import { approveApplication, createApplication, denyApplication, getAllMemberApplications, getApplicationByID, getApplicationComments, getApplicationList, getMemberApplication } from '../services/db/applicationService';
import { setUserState } from '../services/memberService'; import { setUserState } from '../services/db/memberService';
import { MemberState } from '@app/shared/types/member'; import { MemberState } from '@app/shared/types/member';
import { getRankByName, insertMemberRank } from '../services/rankService'; import { getRankByName, insertMemberRank } from '../services/db/rankService';
import { ApplicationFull, CommentRow } from "@app/shared/types/application" import { ApplicationFull, CommentRow } from "@app/shared/types/application"
import { assignUserToStatus } from '../services/statusService'; import { assignUserToStatus } from '../services/db/statusService';
import { Request, response, Response } from 'express'; import { Request, response, Response } from 'express';
import { getUserRoles } from '../services/rolesService'; import { getUserRoles } from '../services/db/rolesService';
import { requireLogin, requireRole } from '../middleware/auth'; import { requireLogin, requireRole } from '../middleware/auth';
import { logger } from '../services/logging/logger';
import { audit, AuditContext } from '../services/logging/auditLog';
import { bus } from '../services/events/eventBus';
//get CoC //get CoC
router.get('/coc', async (req: Request, res: Response) => { router.get('/coc', async (req: Request, res: Response) => {
const output = await fetch(`${process.env.DOC_HOST}/api/pages/714`, { try {
const response = await fetch(`${process.env.DOC_HOST}/api/pages/714`, {
headers: { headers: {
Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`,
} },
}) });
if (output.ok) { if (!response.ok) {
const out = await output.json(); const text = await response.text();
logger.error('app', 'Failed to fetch LOA policy from Bookstack', {
status: response.status,
statusText: response.statusText,
body: text,
});
return res.sendStatus(500);
}
const out = await response.json();
res.status(200).json(out.html); res.status(200).json(out.html);
} else {
console.error("Failed to fetch LOA policy from bookstack"); } catch (error) {
logger.error('app', 'Error fetching LOA policy from Bookstack', {
error: error instanceof Error ? error.message : String(error),
});
res.sendStatus(500); res.sendStatus(500);
} }
}) });
// POST /application // POST /application
router.post('/', [requireLogin], async (req, res) => { router.post('/', [requireLogin], async (req: Request, res: Response) => {
try {
const App = req.body?.App || {};
const memberID = req.user.id; const memberID = req.user.id;
const App = req.body?.App || {};
const appVersion = 1; const appVersion = 1;
await createApplication(memberID, appVersion, JSON.stringify(App)) try {
await setUserState(memberID, MemberState.Applicant); let appID = await createApplication(memberID, appVersion, JSON.stringify(App));
await setUserState(memberID, MemberState.Applicant, "Application Submitted", memberID);
res.sendStatus(201); res.sendStatus(201);
audit.application('created', { actorId: memberID, targetId: appID });
bus.emit("application.create", { application: appID, member_name: req.user.name, member_discord_id: req.user.discord_id || null })
logger.info('app', 'Application Posted', {
user: memberID,
app: appID
})
} catch (err) { } catch (err) {
console.error('Failed to create application: \n', err); logger.error(
'app',
'Failed to create application',
{
memberID,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
}
);
res.status(500).json({ error: 'Failed to create application' }); res.status(500).json({ error: 'Failed to create application' });
} }
}); });
// GET /application/all // GET /application/all
router.get('/all', [requireLogin, requireRole("Recruiter")], async (req, res) => { router.get('/all', [requireLogin, requireRole("Recruiter")], async (req, res) => {
try { try {
const rows = await getApplicationList(); const rows = await getApplicationList();
res.status(200).json(rows); res.status(200).json(rows);
} catch (err) { } catch (err) {
console.error(err); logger.error(
'app',
'Failed to get applications',
{
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
}
);
res.status(500); res.status(500);
} }
}); });
@@ -68,8 +109,16 @@ router.get('/meList', async (req, res) => {
return res.status(200).json(application); return res.status(200).json(application);
} catch (error) { } catch (error) {
console.error('Failed to load applications: \n', error); logger.error(
return res.status(500).json(error); 'app',
'Failed to get applications for user',
{
user: userID,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
return res.status(500);
} }
}) })
@@ -80,8 +129,10 @@ router.get('/me', [requireLogin], async (req, res) => {
try { try {
let application = await getMemberApplication(userID); let application = await getMemberApplication(userID);
if (application === undefined) if (application === undefined) {
res.sendStatus(204); res.sendStatus(204);
return;
}
const comments: CommentRow[] = await getApplicationComments(application.id); const comments: CommentRow[] = await getApplicationComments(application.id);
@@ -92,12 +143,20 @@ router.get('/me', [requireLogin], async (req, res) => {
return res.status(200).json(output); return res.status(200).json(output);
} catch (error) { } catch (error) {
console.error('Failed to load application:', error); logger.error(
'app',
'Failed to load application',
{
user: userID,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
return res.status(500).json(error); return res.status(500).json(error);
} }
}) })
// GET /application/:id // GET /me/:id
router.get('/me/:id', [requireLogin], async (req: Request, res: Response) => { router.get('/me/:id', [requireLogin], async (req: Request, res: Response) => {
let appID = Number(req.params.id); let appID = Number(req.params.id);
let member = req.user.id; let member = req.user.id;
@@ -117,9 +176,18 @@ router.get('/me/:id', [requireLogin], async (req: Request, res: Response) => {
} }
return res.status(200).json(output); return res.status(200).json(output);
} }
catch (err) { catch (error) {
console.error('Query failed:', err); logger.error(
return res.status(500).json({ error: 'Failed to load application' }); 'app',
'Failed to load application',
{
application: appID,
user: member,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
return res.status(500);
} }
}); });
@@ -141,9 +209,17 @@ router.get('/:id', [requireLogin, requireRole("Recruiter")], async (req: Request
} }
return res.status(200).json(output); return res.status(200).json(output);
} }
catch (err) { catch (error) {
console.error('Query failed:', err); logger.error(
return res.status(500).json({ error: 'Failed to load application' }); 'app',
'Failed to load application',
{
application: appID,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
return res.status(500);
} }
}); });
@@ -152,19 +228,46 @@ router.post('/approve/:id', [requireLogin, requireRole("Recruiter")], async (req
const appID = Number(req.params.id); const appID = Number(req.params.id);
const approved_by = req.user.id; const approved_by = req.user.id;
try {
const app = await getApplicationByID(appID); const app = await getApplicationByID(appID);
await approveApplication(appID, approved_by);
try {
var con = await pool.getConnection();
con.beginTransaction();
await approveApplication(appID, approved_by, con);
//update user profile //update user profile
await setUserState(app.member_id, MemberState.Member); await setUserState(app.member_id, MemberState.Member, "Application Accepted", approved_by, con);
await pool.query('CALL sp_accept_new_recruit_validation(?, ?, ?, ?)', [Number(process.env.CONFIG_ID), app.member_id, approved_by, approved_by]) await con.query('CALL sp_accept_new_recruit_validation(?, ?, ?, ?)', [Number(process.env.CONFIG_ID), app.member_id, approved_by, approved_by])
con.commit();
logger.info('app', "Member application approved", {
application: app.id,
applicant: app.member_id,
approver: approved_by
})
audit.application('approved', { actorId: approved_by, targetId: appID }, { applicantId: app.member_id });
res.sendStatus(200); res.sendStatus(200);
} catch (err) { } catch (error) {
console.error('Approve failed:', err);
con.rollback();
logger.error(
'app',
'Failed to approve application',
{
application: appID,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json({ error: 'Failed to approve application' }); res.status(500).json({ error: 'Failed to approve application' });
} finally {
if (con) con.release();
} }
}); });
@@ -176,17 +279,32 @@ router.post('/deny/:id', [requireLogin, requireRole("Recruiter")], async (req: R
try { try {
const app = await getApplicationByID(appID); const app = await getApplicationByID(appID);
await denyApplication(appID, approver); await denyApplication(appID, approver);
await setUserState(app.member_id, MemberState.Denied); await setUserState(app.member_id, MemberState.Denied, "Application Denied", approver);
logger.info('app', "Member application approved", {
application: app.id,
applicant: app.member_id,
approver: approver
})
audit.application('denied', { actorId: approver, targetId: appID }, { applicantId: app.member_id });
res.sendStatus(200); res.sendStatus(200);
} catch (err) { } catch (error) {
console.error('Approve failed:', err); logger.error(
'app',
'Failed to deny application',
{
application: appID,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json({ error: 'Failed to deny application' }); res.status(500).json({ error: 'Failed to deny application' });
} }
}); });
// POST /application/:id/comment // POST /application/:id/comment
router.post('/:id/comment', [requireLogin], async (req: Request, res: Response) => { router.post('/:id/comment', [requireLogin], async (req: Request, res: Response) => {
const appID = req.params.id; const appID = Number(req.params.id);
const data = req.body.message; const data = req.body.message;
const user = req.user; const user = req.user;
@@ -217,10 +335,27 @@ VALUES(?, ?, ?);`
INNER JOIN members AS member ON member.id = app.poster_id INNER JOIN members AS member ON member.id = app.poster_id
WHERE app.id = ?; `; WHERE app.id = ?; `;
const comment = await conn.query(getSQL, [result.insertId]) const comment = await conn.query(getSQL, [result.insertId])
audit.record('application', 'comment_added', { actorId: user.id, targetId: appID }, { commentId: Number(result.insertId) });
logger.info('app', "Application comment posted", {
application: appID,
poster: user.id,
comment: Number(result.insertId),
})
res.status(201).json(comment[0]); res.status(201).json(comment[0]);
} catch (err) { } catch (error) {
console.error('Comment failed:', err); logger.error(
'app',
'Failed to post comment',
{
application: appID,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json({ error: 'Could not post comment' }); res.status(500).json({ error: 'Could not post comment' });
} finally { } finally {
conn.release(); conn.release();
@@ -229,7 +364,7 @@ VALUES(?, ?, ?);`
// POST /application/:id/comment // POST /application/:id/comment
router.post('/:id/adminComment', [requireLogin, requireRole("Recruiter")], async (req: Request, res: Response) => { router.post('/:id/adminComment', [requireLogin, requireRole("Recruiter")], async (req: Request, res: Response) => {
const appID = req.params.id; const appID = Number(req.params.id);
const data = req.body.message; const data = req.body.message;
const user = req.user; const user = req.user;
@@ -261,10 +396,24 @@ VALUES(?, ?, ?, 1);`
INNER JOIN members AS member ON member.id = app.poster_id INNER JOIN members AS member ON member.id = app.poster_id
WHERE app.id = ?; `; WHERE app.id = ?; `;
const comment = await conn.query(getSQL, [result.insertId]) const comment = await conn.query(getSQL, [result.insertId])
res.status(201).json(comment[0]); audit.record('application', 'comment_added', { actorId: user.id, targetId: appID }, { commentId: result.insertId });
logger.info('app', "Admin application comment posted", {
application: appID,
poster: user.id,
comment: result.insertId,
})
} catch (err) { res.status(201).json(comment[0]);
console.error('Comment failed:', err); } catch (error) {
logger.error(
'app',
'Failed to post comment',
{
application: appID,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json({ error: 'Could not post comment' }); res.status(500).json({ error: 'Could not post comment' });
} finally { } finally {
conn.release(); conn.release();
@@ -274,10 +423,24 @@ VALUES(?, ?, ?, 1);`
router.post('/restart', async (req: Request, res: Response) => { router.post('/restart', async (req: Request, res: Response) => {
const user = req.user.id; const user = req.user.id;
try { try {
await setUserState(user, MemberState.Guest); await setUserState(user, MemberState.Guest, "Restarted Application", user);
audit.application('restarted', { actorId: user, targetId: user });
logger.info('app', "Member restarted application", {
user: user
})
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error('Comment failed:', error); logger.error(
'app',
'Failed to restart application',
{
user: user,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json({ error: 'Could not rester application' }); res.status(500).json({ error: 'Could not rester application' });
} }
}) })

View File

@@ -1,7 +1,5 @@
const passport = require('passport'); const passport = require('passport');
const OpenIDConnectStrategy = require('passport-openidconnect'); const OpenIDConnectStrategy = require('passport-openidconnect');
const dotenv = require('dotenv');
dotenv.config();
const express = require('express'); const express = require('express');
const { param } = require('./applications'); const { param } = require('./applications');
@@ -9,17 +7,40 @@ const router = express.Router();
import { Role } from '@app/shared/types/roles'; import { Role } from '@app/shared/types/roles';
import pool from '../db'; import pool from '../db';
import { requireLogin } from '../middleware/auth'; import { requireLogin } from '../middleware/auth';
import { getUserRoles } from '../services/rolesService'; import { getUserRoles } from '../services/db/rolesService';
import { getUserState, mapDiscordtoID } from '../services/memberService'; import { getUserState, mapDiscordtoID } from '../services/db/memberService';
import { MemberState } from '@app/shared/types/member'; import { MemberState } from '@app/shared/types/member';
import { toDateTime } from '@app/shared/utils/time'; import { toDateTime } from '@app/shared/utils/time';
import { logger } from '../services/logging/logger';
const querystring = require('querystring'); const querystring = require('querystring');
import { performance } from 'perf_hooks';
import { CacheService } from '../services/cache/cache';
import { Strategy as CustomStrategy } from 'passport-custom';
function parseJwt(token) { function parseJwt(token) {
return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
} }
passport.use(new OpenIDConnectStrategy({ const devLogin = (req: any, res: any, next: any) => {
// The object here must match what your 'verify' function returns: { memberId }
const devUser = { memberId: 1 }; // Hardcoded ID
req.logIn(devUser, (err: any) => {
if (err) return next(err);
const redirectTo = req.session.redirectTo || process.env.CLIENT_URL;
delete req.session.redirectTo;
return res.redirect(redirectTo);
});
};
if (process.env.AUTH_MODE === "mock") {
passport.use('mock', new CustomStrategy(async (req, done) => {
const mockUser = { memberId: 1 };
return done(null, mockUser);
}))
} else {
passport.use('oidc', new OpenIDConnectStrategy({
issuer: process.env.AUTH_ISSUER, issuer: process.env.AUTH_ISSUER,
authorizationURL: process.env.AUTH_DOMAIN + '/authorize/', authorizationURL: process.env.AUTH_DOMAIN + '/authorize/',
tokenURL: process.env.AUTH_DOMAIN + '/token/', tokenURL: process.env.AUTH_DOMAIN + '/token/',
@@ -37,41 +58,61 @@ passport.use(new OpenIDConnectStrategy({
// console.log('profile:', profile); // console.log('profile:', profile);
// console.log('jwt: ', parseJwt(jwtClaims)); // console.log('jwt: ', parseJwt(jwtClaims));
// console.log('params:', params); // console.log('params:', params);
let con;
try { try {
var con = await pool.getConnection(); con = await pool.getConnection();
await con.beginTransaction(); await con.beginTransaction();
//lookup existing user //lookup existing user
const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]); const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]);
let memberId: number; let memberId: number | null = null;
//if member exists //if member exists
if (existing.length > 0) { if (existing.length > 0) {
//login
memberId = existing[0].id; memberId = existing[0].id;
logger.info('auth', `Existing member login`, {
memberId,
issuer,
});
} else { } else {
//otherwise: create account //otherwise: create account mode
const jwt = parseJwt(jwtClaims); const jwt = parseJwt(jwtClaims);
const discordID = jwt.discord.id as number; const discordID = jwt.discord?.id as number;
//check if account is available to claim //check if account is available to claim
if (discordID)
memberId = await mapDiscordtoID(discordID); memberId = await mapDiscordtoID(discordID);
if (memberId === null) { if (discordID && memberId) {
// create new account // claim account
const result = await con.query(
`UPDATE members SET authentik_sub = ?, authentik_issuer = ? WHERE id = ?;`,
[sub, issuer, memberId]
)
logger.info('auth', `Existing member claimed via Discord`, {
memberId,
discordID,
issuer,
});
} else {
// new account
const username = sub.username; const username = sub.username;
const result = await con.query( const result = await con.query(
`INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`, `INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`,
[username, sub, issuer] [username, sub, issuer]
) )
memberId = Number(result.insertId); memberId = Number(result.insertId);
} else {
// claim existing account logger.info('auth', `New member account created`, {
const result = await con.query( memberId,
`UPDATE members SET authentik_sub = ?, authentik_issuer = ? WHERE id = ?;`, username,
[sub, issuer, memberId] issuer,
) });
} }
} }
@@ -80,24 +121,50 @@ passport.use(new OpenIDConnectStrategy({
await con.commit(); await con.commit();
return cb(null, { memberId }); return cb(null, { memberId });
} catch (error) { } catch (error) {
logger.error('auth', `Authentication transaction failed`, {
issuer,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
if (con) {
try {
await con.rollback(); await con.rollback();
} catch (rollbackError) {
logger.error('auth', `Rollback failed`, {
error: rollbackError instanceof Error
? rollbackError.message
: String(rollbackError),
});
}
}
return cb(error); return cb(error);
} finally { } finally {
con.release(); if (con) con.release();
} }
})); }));
}
router.get('/login', (req, res, next) => { router.get('/login', (req, res, next) => {
// Store redirect target in session if provided req.session.redirectTo = req.query.redirect as string;
req.session.redirectTo = req.query.redirect;
next(); const strategy = process.env.AUTH_MODE === 'mock' ? 'mock' : 'oidc';
}, passport.authenticate('openidconnect'));
passport.authenticate(strategy, {
successRedirect: (req.session.redirectTo || process.env.CLIENT_URL),
failureRedirect: '/login'
})(req, res, next);
});
router.get('/callback', (req, res, next) => { router.get('/callback', (req, res, next) => {
//escape if mocked
if (process.env.AUTH_MODE === 'mock') {
return res.redirect(process.env.CLIENT_URL || '/');
}
const redirectURI = req.session.redirectTo; const redirectURI = req.session.redirectTo;
passport.authenticate('openidconnect', (err, user) => { passport.authenticate('oidc', (err, user) => {
if (err) return next(err); if (err) return next(err);
if (!user) return res.redirect(process.env.CLIENT_URL); if (!user) return res.redirect(process.env.CLIENT_URL);
@@ -114,12 +181,35 @@ router.get('/callback', (req, res, next) => {
router.get('/logout', [requireLogin], function (req, res, next) { router.get('/logout', [requireLogin], function (req, res, next) {
req.logout(function (err) { req.logout(function (err) {
if (err) { return next(err); } if (err) { return next(err); }
req.session.destroy((err) => {
if (err) { return next(err); }
res.clearCookie('connect.sid', {
path: '/',
domain: process.env.CLIENT_DOMAIN,
httpOnly: true,
sameSite: 'lax'
});
if (process.env.AUTH_MODE === 'mock') {
return res.redirect(process.env.CLIENT_URL || '/');
}
var params = { var params = {
client_id: process.env.AUTH_CLIENT_ID, client_id: process.env.AUTH_CLIENT_ID,
returnTo: process.env.CLIENT_URL returnTo: process.env.CLIENT_URL
}; };
res.redirect(process.env.AUTH_END_SESSION_URI + '?' + querystring.stringify(params));
const endSessionUri = process.env.AUTH_END_SESSION_URI;
if (endSessionUri) {
return res.redirect(endSessionUri + '?' + querystring.stringify(params));
} else {
return res.redirect(process.env.CLIENT_URL || '/');
}
})
}); });
}); });
@@ -130,25 +220,94 @@ passport.serializeUser(function (user, cb) {
}); });
passport.deserializeUser(function (user, cb) { passport.deserializeUser(function (user, cb) {
const start = performance.now();
const timings: Record<string, number> = {};
process.nextTick(async function () { process.nextTick(async function () {
const memberID = user.memberId as number; const memberID = user.memberId as number;
let con;
var userData: { id: number, name: string, roles: Role[], state: MemberState };
try { try {
var con = await pool.getConnection(); //cache lookup
let userResults = await con.query(`SELECT id, name FROM members WHERE id = ?;`, [memberID]) let t = performance.now();
userData = userResults[0]; const cachedData: UserData | undefined = userCache.Get(memberID);
let userRoles = await getUserRoles(memberID); timings.cache_lookup = performance.now() - t;
userData.roles = userRoles || [];
userData.state = await getUserState(memberID); if (cachedData) {
} catch (error) { timings.total = performance.now() - start;
console.error(error)
} finally { logger.info(
con.release(); 'profiling',
'passport.deserializeUser (cache hit)',
{
memberId: memberID,
cache_hit: true,
source: 'cache',
total_ms: timings.total,
breakdown_ms: timings,
},
'profiling'
);
return cb(null, cachedData);
} }
//cache miss, db load
t = performance.now();
con = await pool.getConnection();
timings.getConnection = performance.now() - t;
t = performance.now();
const userResults = await con.query(
`SELECT id, name, discord_id FROM members WHERE id = ?;`,
[memberID]
);
timings.memberQuery = performance.now() - t;
const userData: UserData = userResults[0];
t = performance.now();
userData.roles = await getUserRoles(memberID) || [];
timings.roles = performance.now() - t;
t = performance.now();
userData.state = await getUserState(memberID);
timings.state = performance.now() - t;
t = performance.now();
userCache.Set(userData.id, userData);
timings.cache_set = performance.now() - t;
timings.total = performance.now() - start;
logger.info(
'profiling',
'passport.deserializeUser (db load)',
{
memberId: memberID,
cache_hit: false,
source: 'db',
total_ms: timings.total,
breakdown_ms: timings,
},
'profiling'
);
return cb(null, userData); return cb(null, userData);
} catch (error) {
logger.error(
'profiling',
'passport.deserializeUser failed',
{
memberId: memberID,
error: error instanceof Error ? error.message : String(error),
}
);
return cb(error);
} finally {
if (con) con.release();
}
}); });
}); });
@@ -158,6 +317,7 @@ declare global {
user: { user: {
id: number; id: number;
name: string; name: string;
discord_id: string;
roles: Role[]; roles: Role[];
state: MemberState; state: MemberState;
}; };
@@ -165,5 +325,15 @@ declare global {
} }
} }
export interface UserData {
id: number;
name: string;
roles: Role[];
state: MemberState;
discord_id?: string;
}
const userCache = new CacheService<number, UserData>();
export const authRouter = router; export const authRouter = router;
export const memberCache = userCache;

View File

@@ -1,8 +1,10 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/calendarService"; import { createEvent, getEventAttendance, getEventDetails, getShortEventsInRange, setAttendanceStatus, setEventCancelled, updateEvent } from "../services/db/calendarService";
import { CalendarAttendance, CalendarEvent } from "@app/shared/types/calendar"; import { CalendarAttendance, CalendarEvent } from "@app/shared/types/calendar";
import { requireLogin, requireMemberState, requireRole } from "../middleware/auth"; import { requireLogin, requireMemberState, requireRole } from "../middleware/auth";
import { MemberState } from "@app/shared/types/member"; import { MemberState } from "@app/shared/types/member";
import { logger } from "../services/logging/logger";
import { audit } from "../services/logging/auditLog";
const express = require('express'); const express = require('express');
const r = express.Router(); const r = express.Router();
@@ -28,7 +30,14 @@ r.get('/', async (req, res) => {
res.status(200).json(events); res.status(200).json(events);
} catch (error) { } catch (error) {
console.error('Error fetching calendar events:', error); logger.error(
'app',
'Failed to get calendar events',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).send('Error fetching calendar events'); res.status(500).send('Error fetching calendar events');
} }
}); });
@@ -38,22 +47,50 @@ r.get('/upcoming', async (req, res) => {
}) })
r.post('/:id/cancel', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => { r.post('/:id/cancel', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
let member = req.user.id;
try { try {
const eventID = Number(req.params.id); const eventID = Number(req.params.id);
setEventCancelled(eventID, true); await setEventCancelled(eventID, true);
audit.calendar('cancelled', { actorId: member, targetId: eventID });
logger.info('app', 'Calendar event cancelled', {
event: eventID,
user: req.user.id
})
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error('Error setting cancel status:', error); logger.error(
'app',
'Failed to get cancel calendar event',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).send('Error setting cancel status'); res.status(500).send('Error setting cancel status');
} }
}) })
r.post('/:id/uncancel', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => { r.post('/:id/uncancel', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
let member = req.user.id;
try { try {
const eventID = Number(req.params.id); const eventID = Number(req.params.id);
setEventCancelled(eventID, false); setEventCancelled(eventID, false);
audit.calendar('un-cancelled', { actorId: member, targetId: eventID });
logger.info('app', 'Calendar event un-cancelled', {
event: eventID,
user: req.user.id
})
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error('Error setting cancel status:', error); logger.error(
'app',
'Failed to uncancel calendar event',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).send('Error setting cancel status'); res.status(500).send('Error setting cancel status');
} }
}) })
@@ -64,13 +101,29 @@ r.post('/:id/attendance', [requireLogin, requireMemberState(MemberState.Member)]
let member = req.user.id; let member = req.user.id;
let event = Number(req.params.id); let event = Number(req.params.id);
let state = req.query.state as CalendarAttendance; let state = req.query.state as CalendarAttendance;
setAttendanceStatus(member, event, state); await setAttendanceStatus(member, event, state);
audit.calendar('attendance_set', { actorId: member, targetId: event }, { attendanceState: state });
logger.info('app', 'Member set calendar event attendance', {
event: event,
user: req.user.id,
state: state
})
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error('Failed to set attendance:', error); logger.error(
'app',
'Failed to set attendance',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}) })
//get event details //get event details
r.get('/:id', async (req: Request, res: Response) => { r.get('/:id', async (req: Request, res: Response) => {
try { try {
@@ -79,9 +132,16 @@ r.get('/:id', async (req: Request, res: Response) => {
let details: CalendarEvent = await getEventDetails(eventID); let details: CalendarEvent = await getEventDetails(eventID);
details.eventSignups = await getEventAttendance(eventID); details.eventSignups = await getEventAttendance(eventID);
res.status(200).json(details); res.status(200).json(details);
} catch (err) { } catch (error) {
console.error('Insert failed:', err); logger.error(
res.status(500).json(err); 'app',
'Failed to get calendar event details',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500);
} }
}) })
@@ -94,23 +154,51 @@ r.post('/', [requireLogin, requireMemberState(MemberState.Member)], async (req:
event.creator_id = member; event.creator_id = member;
event.start = new Date(event.start); event.start = new Date(event.start);
event.end = new Date(event.end); event.end = new Date(event.end);
createEvent(event); let eventID = await createEvent(event);
audit.calendar('event_created', { actorId: member, targetId: eventID });
logger.info('app', 'Calendar event posted', {
event: event.id,
user: req.user.id
})
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error('Failed to create event:', error); logger.error(
'app',
'Failed to create calendar event',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}) })
r.put('/', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => { r.put('/', [requireLogin, requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
let member = req.user.id;
try { try {
let event: CalendarEvent = req.body; let event: CalendarEvent = req.body;
event.start = new Date(event.start); event.start = new Date(event.start);
event.end = new Date(event.end); event.end = new Date(event.end);
updateEvent(event); updateEvent(event);
audit.calendar('event_updated', { actorId: member, targetId: event.id });
logger.info('app', 'Calendar event updated', {
event: event.id,
user: req.user.id
})
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error('Failed to update event:', error); logger.error(
'app',
'Failed to update calendar event',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}) })

View File

@@ -1,8 +1,10 @@
import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course"; import { CourseAttendee, CourseEventDetails } from "@app/shared/types/course";
import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/CourseSerivce"; import { getAllCourses, getCourseEventAttendees, getCourseEventDetails, getCourseEventRoles, getCourseEvents, insertCourseEvent } from "../services/db/CourseSerivce";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
import { requireLogin, requireMemberState } from "../middleware/auth"; import { requireLogin, requireMemberState } from "../middleware/auth";
import { MemberState } from "@app/shared/types/member"; import { MemberState } from "@app/shared/types/member";
import { logger } from "../services/logging/logger";
import { audit } from "../services/logging/auditLog";
const cr = Router(); const cr = Router();
const er = Router(); const er = Router();
@@ -16,9 +18,16 @@ cr.get('/', async (req, res) => {
try { try {
const courses = await getAllCourses(); const courses = await getAllCourses();
res.status(200).json(courses); res.status(200).json(courses);
} catch (err) { } catch (error) {
console.error('failed to fetch courses', err); logger.error(
res.status(500).json('failed to fetch courses\n' + err); 'app',
'Failed to fetch courses',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json('failed to fetch courses');
} }
}) })
@@ -26,12 +35,20 @@ cr.get('/roles', async (req, res) => {
try { try {
const roles = await getCourseEventRoles(); const roles = await getCourseEventRoles();
res.status(200).json(roles); res.status(200).json(roles);
} catch (err) { } catch (error) {
console.error('failed to fetch course roles', err); logger.error(
res.status(500).json('failed to fetch course roles\n' + err); 'app',
'Failed to fetch course roles',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json('failed to fetch course roles');
} }
}) })
//get event list
er.get('/', async (req: Request, res: Response) => { er.get('/', async (req: Request, res: Response) => {
try { try {
const allowedSorts = new Map([ const allowedSorts = new Map([
@@ -55,7 +72,14 @@ er.get('/', async (req: Request, res: Response) => {
let events = await getCourseEvents(sortDir, search, page, pageSize); let events = await getCourseEvents(sortDir, search, page, pageSize);
res.status(200).json(events); res.status(200).json(events);
} catch (error) { } catch (error) {
console.error('failed to fetch reports', error); logger.error(
'app',
'Failed to fetch course events',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}); });
@@ -65,7 +89,14 @@ er.get('/:id', async (req: Request, res: Response) => {
let out = await getCourseEventDetails(Number(req.params.id)); let out = await getCourseEventDetails(Number(req.params.id));
res.status(200).json(out); res.status(200).json(out);
} catch (error) { } catch (error) {
console.error('failed to fetch report', error); logger.error(
'app',
'Failed to fetch course event',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}); });
@@ -74,9 +105,16 @@ er.get('/attendees/:id', async (req: Request, res: Response) => {
try { try {
const attendees: CourseAttendee[] = await getCourseEventAttendees(Number(req.params.id)); const attendees: CourseAttendee[] = await getCourseEventAttendees(Number(req.params.id));
res.status(200).json(attendees); res.status(200).json(attendees);
} catch (err) { } catch (error) {
console.error('failed to fetch attendees', err); logger.error(
res.status(500).json("failed to fetch attendees\n" + err); 'app',
'Failed to fetch course event attendees',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json("failed to fetch attendees");
} }
}) })
@@ -87,9 +125,20 @@ er.post('/', async (req: Request, res: Response) => {
data.created_by = posterID; data.created_by = posterID;
data.event_date = new Date(data.event_date); data.event_date = new Date(data.event_date);
const id = await insertCourseEvent(data); const id = await insertCourseEvent(data);
audit.course('report_created', { actorId: posterID, targetId: id });
logger.info('app', 'Training report posted', { user: posterID, report: id })
res.status(201).json(id); res.status(201).json(id);
} catch (error) { } catch (error) {
console.error('failed to post training', error); logger.error(
'app',
'Failed to post training report',
{
user: posterID,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json("failed to post training\n" + error) res.status(500).json("failed to post training\n" + error)
} }
}) })

View File

@@ -3,22 +3,55 @@ const router = express.Router();
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { requireLogin } from '../middleware/auth'; import { requireLogin } from '../middleware/auth';
import { logger } from '../services/logging/logger';
// GET /welcome
router.get('/welcome', [requireLogin], async (req: Request, res: Response) => { router.get('/welcome', [requireLogin], async (req: Request, res: Response) => {
const output = await fetch(`${process.env.DOC_HOST}/api/pages/717`, { const t0 = performance.now(); // optional profiling start
try {
const response = await fetch(`${process.env.DOC_HOST}/api/pages/717`, {
headers: { headers: {
Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`,
} },
}) });
if (output.ok) { if (!response.ok) {
const out = await output.json(); const text = await response.text();
logger.error('app', 'Failed to fetch welcome page from Bookstack', {
status: response.status,
statusText: response.statusText,
body: text,
userId: req.user?.id,
});
return res.sendStatus(500);
}
const out = await response.json();
res.status(200).json(out.html); res.status(200).json(out.html);
} else {
console.error("Failed to fetch LOA policy from bookstack"); // optional profiling log
const duration = performance.now() - t0;
logger.info(
'profiling',
'GET /welcome completed',
{
userId: req.user?.id,
total_ms: duration,
},
'profiling'
);
} catch (error) {
logger.error('app', 'Error fetching welcome page from Bookstack', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
userId: req.user?.id,
});
res.sendStatus(500); res.sendStatus(500);
} }
}) });
export const docsRouter = router; export const docsRouter = router;

View File

@@ -3,9 +3,11 @@ const router = express.Router();
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import pool from '../db'; import pool from '../db';
import { closeLOA, createNewLOA, getAllLOA, getLOAbyID, getLoaTypes, getUserLOA, setLOAExtension } from '../services/loaService'; import { closeLOA, createNewLOA, getAllLOA, getLOAbyID, getLoaTypes, getUserLOA, setLOAExtension } from '../services/db/loaService';
import { LOARequest } from '@app/shared/types/loa'; import { LOARequest } from '@app/shared/types/loa';
import { requireLogin, requireRole } from '../middleware/auth'; import { requireLogin, requireRole } from '../middleware/auth';
import { logger } from '../services/logging/logger';
import { audit } from '../services/logging/auditLog';
router.use(requireLogin); router.use(requireLogin);
@@ -17,25 +19,43 @@ router.post("/", async (req: Request, res: Response) => {
LOARequest.filed_date = new Date(); LOARequest.filed_date = new Date();
try { try {
await createNewLOA(LOARequest); let loaID = await createNewLOA(LOARequest);
audit.leaveOfAbsence('created', { actorId: req.user.id, targetId: loaID })
logger.info('app', 'LOA Posted', { poster: req.user.id, user: LOARequest.member_id })
res.sendStatus(201); res.sendStatus(201);
} catch (error) { } catch (error) {
console.error(error); logger.error(
'app',
'Failed to post LOA',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).send(error); res.status(500).send(error);
} }
}); });
//admin posts LOA //admin posts LOA
router.post("/admin", [requireRole("17th Administrator")], async (req: Request, res: Response) => { router.post("/admin", [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => {
let LOARequest = req.body as LOARequest; let LOARequest = req.body as LOARequest;
LOARequest.created_by = req.user.id; LOARequest.created_by = req.user.id;
LOARequest.filed_date = new Date(); LOARequest.filed_date = new Date();
try { try {
await createNewLOA(LOARequest); let loaID = await createNewLOA(LOARequest);
audit.leaveOfAbsence('admin_created', { actorId: req.user.id, targetId: loaID }, { for: LOARequest.member_id })
logger.info('app', 'LOA Posted', { poster: req.user.id, user: LOARequest.member_id })
res.sendStatus(201); res.sendStatus(201);
} catch (error) { } catch (error) {
console.error(error); logger.error(
res.status(500).send(error); 'app',
'Failed to post LOA',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
); res.status(500).send(error);
} }
}); });
@@ -46,7 +66,14 @@ router.get("/me", async (req: Request, res: Response) => {
const result = await getUserLOA(user); const result = await getUserLOA(user);
res.status(200).json(result) res.status(200).json(result)
} catch (error) { } catch (error) {
console.error(error); logger.error(
'app',
'Failed to get user current LOA',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).send(error); res.status(500).send(error);
} }
}) })
@@ -62,19 +89,33 @@ router.get("/history", async (req: Request, res: Response) => {
const result = await getUserLOA(user, page, pageSize); const result = await getUserLOA(user, page, pageSize);
res.status(200).json(result) res.status(200).json(result)
} catch (error) { } catch (error) {
console.error(error); logger.error(
'app',
'Failed to get user LOA history',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).send(error); res.status(500).send(error);
} }
}) })
router.get('/all', [requireRole("17th Administrator")], async (req: Request, res: Response) => { router.get('/all', [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => {
try { try {
const page = Number(req.query.page) || undefined; const page = Number(req.query.page) || undefined;
const pageSize = Number(req.query.pageSize) || undefined; const pageSize = Number(req.query.pageSize) || undefined;
const result = await getAllLOA(page, pageSize); const result = await getAllLOA(page, pageSize);
res.status(200).json(result) res.status(200).json(result)
} catch (error) { } catch (error) {
console.error(error); logger.error(
'app',
'Failed to get full LOA history',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).send(error); res.status(500).send(error);
} }
}) })
@@ -84,8 +125,15 @@ router.get('/types', async (req: Request, res: Response) => {
let out = await getLoaTypes(); let out = await getLoaTypes();
res.status(200).json(out); res.status(200).json(out);
} catch (error) { } catch (error) {
logger.error(
'app',
'Failed to get LOA types',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
console.error(error);
} }
}) })
@@ -99,27 +147,92 @@ router.post('/cancel/:id', async (req: Request, res: Response) => {
} }
await closeLOA(Number(req.params.id), closer); await closeLOA(Number(req.params.id), closer);
audit.leaveOfAbsence('ended', { actorId: req.user.id, targetId: id });
logger.info('app', 'LOA Closed', { closed_by: closer, LOA: id })
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error(error); logger.error(
'app',
'Failed to cancel LOA',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}) })
//TODO: enforce admin only //TODO: enforce admin only
router.post('/adminCancel/:id', [requireRole("17th Administrator")], async (req: Request, res: Response) => { router.post('/adminCancel/:id', [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => {
let closer = req.user.id; let closer = req.user.id;
try { try {
await closeLOA(Number(req.params.id), closer); await closeLOA(Number(req.params.id), closer);
audit.leaveOfAbsence('admin_ended', { actorId: req.user.id, targetId: Number(req.params.id) });
logger.info('app', 'LOA Closed', { closed_by: closer, LOA: req.params.id })
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error(error); logger.error(
'app',
'Failed to cancel LOA',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}) })
// TODO: Enforce admin only // extend LOA
router.post('/extend/:id', [requireRole("17th Administrator")], async (req: Request, res: Response) => { router.post('/extend/:id', async (req: Request, res: Response) => {
const to: Date = req.body.to;
const member = req.user.id;
let LOA = await getLOAbyID(Number(req.params.id));
if (!LOA) {
return res.status(404).send("LOA not found");
}
if (LOA.member_id !== member) {
return res.status(403).send("You do not have permission to extend this LOA");
}
if (LOA.extended_till !== null) {
return res.status(409).send("You must contact the administration team to extend your LOA again");
}
if (!to) {
return res.status(400).send("Extension length is required");
}
try {
await setLOAExtension(Number(req.params.id), to);
audit.leaveOfAbsence('extended', { actorId: req.user.id, targetId: Number(req.params.id) });
logger.info('app', 'LOA Extended', { extended_by: req.user.id, LOA: req.params.id })
res.sendStatus(200);
} catch (error) {
logger.error(
'app',
'Failed to extend LOA',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error);
}
})
// admin extend LOA
router.post('/extendAdmin/:id', [requireRole(['17th Administrator', '17th HQ', '17th Command'])], async (req: Request, res: Response) => {
const to: Date = req.body.to; const to: Date = req.body.to;
if (!to) { if (!to) {
@@ -128,27 +241,73 @@ router.post('/extend/:id', [requireRole("17th Administrator")], async (req: Requ
try { try {
await setLOAExtension(Number(req.params.id), to); await setLOAExtension(Number(req.params.id), to);
audit.leaveOfAbsence('extended', { actorId: req.user.id, targetId: Number(req.params.id) });
logger.info('app', 'LOA Extended', { extended_by: req.user.id, LOA: req.params.id })
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error(error) logger.error(
'app',
'Failed to extend LOA',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}) })
// GET /policy
router.get('/policy', async (req: Request, res: Response) => { router.get('/policy', async (req: Request, res: Response) => {
const output = await fetch(`${process.env.DOC_HOST}/api/pages/42`, { const t0 = performance.now();
try {
const response = await fetch(`${process.env.DOC_HOST}/api/pages/42`, {
headers: { headers: {
Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`, Authorization: `Token ${process.env.DOC_TOKEN_ID}:${process.env.DOC_TOKEN_SECRET}`,
} },
}) });
if (output.ok) { if (!response.ok) {
const out = await output.json(); const text = await response.text();
logger.error('app', 'Failed to fetch policy page from Bookstack', {
pageId: 42,
status: response.status,
statusText: response.statusText,
body: text,
userId: req.user?.id,
});
return res.sendStatus(500);
}
const out = await response.json();
res.status(200).json(out.html); res.status(200).json(out.html);
} else {
console.error("Failed to fetch LOA policy from bookstack"); logger.info(
'profiling',
'GET /policy completed',
{
pageId: 42,
total_ms: performance.now() - t0,
},
'profiling'
);
} catch (error) {
logger.error('app', 'Error fetching policy page from Bookstack', {
pageId: 42,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
userId: req.user?.id,
});
res.sendStatus(500); res.sendStatus(500);
} }
}) });
export const loaRouter = router; export const loaRouter = router;

View File

@@ -4,10 +4,18 @@ const router = express.Router();
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import pool from '../db'; import pool from '../db';
import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; import { requireLogin, requireMemberState, requireRole } from '../middleware/auth';
import { getUserActiveLOA } from '../services/loaService'; import { getUserActiveLOA } from '../services/db/loaService';
import { getAllMembersLite, getMemberSettings, getMembersFull, getMembersLite, getUserData, getUserState, setUserSettings } from '../services/memberService'; import { getAllMembersLite, getMemberSettings, getMembersFull, getMembersLite, getUserData, getUserState, setUserSettings, getFilteredMembers, setUserState, getLastNonSuspendedState } from '../services/db/memberService';
import { getUserRoles } from '../services/rolesService'; import { getUserRoles, stripUserRoles } from '../services/db/rolesService';
import { memberSettings, MemberState, myData } from '@app/shared/types/member'; import { memberSettings, MemberState, myData, UserCacheBustResult } from '@app/shared/types/member';
import { Discharge } from '@app/shared/schemas/dischargeSchema';
import { Performance } from 'perf_hooks';
import { logger } from '../services/logging/logger';
import { memberCache } from './auth';
import { cancelLatestRank } from '../services/db/rankService';
import { cancelLatestUnit } from '../services/db/unitService';
import { audit } from '../services/logging/auditLog';
//get all users //get all users
router.get('/', [requireLogin, requireMemberState(MemberState.Member)], async (req, res) => { router.get('/', [requireLogin, requireMemberState(MemberState.Member)], async (req, res) => {
@@ -26,29 +34,90 @@ router.get('/', [requireLogin, requireMemberState(MemberState.Member)], async (r
END AS on_loa END AS on_loa
FROM view_member_rank_unit_status_latest v;`); FROM view_member_rank_unit_status_latest v;`);
return res.status(200).json(result); return res.status(200).json(result);
} catch (err) { } catch (error) {
console.error('Error fetching users:', err); logger.error(
'app',
'Failed to get all users',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
return res.status(500).json({ error: 'Failed to fetch users' }); return res.status(500).json({ error: 'Failed to fetch users' });
} }
}); });
router.get('/me', [requireLogin], async (req, res) => { router.get('/filtered', [requireLogin, requireMemberState(MemberState.Member)], async (req, res) => {
if (req.user === undefined) try {
return res.sendStatus(401) // Extract Query Parameters
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 15;
const search = req.query.search as string | undefined;
const status = req.query.status as string | undefined;
const unitId = req.query.unitId as string | undefined;
// Call the service function
const result = await getFilteredMembers(page, pageSize, search, status, unitId);
return res.status(200).json(result);
} catch (error) {
logger.error('app', 'Failed to get filtered users', {
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({ error: 'Failed to fetch users' });
}
});
router.get('/me', [requireLogin], async (req: Request, res) => {
if (!req.user) return res.sendStatus(401);
const routeStart = performance.now();
const timings: Record<string, number> = {};
try { try {
const memData = await getUserData(req.user.id); let t;
const LOAData = await getUserActiveLOA(req.user.id);
const memState = await getUserState(req.user.id); t = performance.now();
const roleData = await getUserRoles(req.user.id); const memData = await getUserData(req.user.id);
timings.member = performance.now() - t;
t = performance.now();
const LOAData = await getUserActiveLOA(req.user.id);
timings.loa = performance.now() - t;
t = performance.now();
const memState = await getUserState(req.user.id);
timings.state = performance.now() - t;
t = performance.now();
const roleData = await getUserRoles(req.user.id);
timings.roles = performance.now() - t;
const userDataFull: myData = {
member: memData,
LOAs: LOAData,
roles: roleData,
state: memState,
};
const userDataFull: myData = { member: memData, LOAs: LOAData, roles: roleData, state: memState };
res.status(200).json(userDataFull); res.status(200).json(userDataFull);
logger.info('profiling', 'GET /members/me completed', {
userId: req.user.id,
total_ms: performance.now() - routeStart,
breakdown_ms: timings,
}, 'profiling');
} catch (error) { } catch (error) {
console.error('Error fetching user data:', error); logger.error('profiling', 'GET /members/me failed', {
userId: req.user?.id,
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({ error: 'Failed to fetch user data' }); return res.status(500).json({ error: 'Failed to fetch user data' });
} }
}) });
router.get('/settings', [requireLogin], async (req: Request, res: Response) => { router.get('/settings', [requireLogin], async (req: Request, res: Response) => {
try { try {
@@ -56,7 +125,14 @@ router.get('/settings', [requireLogin], async (req: Request, res: Response) => {
let output = await getMemberSettings(user); let output = await getMemberSettings(user);
res.status(200).json(output); res.status(200).json(output);
} catch (error) { } catch (error) {
console.error(error); logger.error(
'app',
'Failed to get member settings',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}) })
@@ -66,19 +142,35 @@ router.put('/settings', [requireLogin], async (req: Request, res: Response) => {
let user = req.user.id; let user = req.user.id;
let settings: memberSettings = req.body; let settings: memberSettings = req.body;
await setUserSettings(user, settings); await setUserSettings(user, settings);
logger.info('app', 'User updated profile settings', { user: user })
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error(error); logger.error(
res.status(500).json(error); 'app',
'Failed to update user settings',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
); res.status(500).json(error);
} }
}) })
router.get('/lite', [requireLogin], async (req: Request, res: Response) => { router.get('/lite', [requireLogin], async (req: Request, res: Response) => {
try { try {
let out = await getAllMembersLite(); let activeOnly = Boolean(req.query.active);
console.log(activeOnly);
let out = await getAllMembersLite(activeOnly);
res.status(200).json(out); res.status(200).json(out);
} catch (error) { } catch (error) {
console.error(error); logger.error(
'app',
'Failed to get lite users',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}) })
@@ -89,7 +181,14 @@ router.post('/lite/bulk', async (req: Request, res: Response) => {
let out = await getMembersLite(ids); let out = await getMembersLite(ids);
res.status(200).json(out); res.status(200).json(out);
} catch (error) { } catch (error) {
console.error(error); logger.error(
'app',
'Failed to get batch lite users',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json(error); res.status(500).json(error);
} }
}) })
@@ -101,22 +200,62 @@ router.post('/full/bulk', async (req: Request, res: Response) => {
let out = await getMembersFull(ids); let out = await getMembersFull(ids);
res.status(200).json(out); res.status(200).json(out);
} catch (error) { } catch (error) {
console.error(error); logger.error(
res.status(500).json(error); 'app',
'Failed to get batch full users',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
); res.status(500).json(error);
}
})
router.post('/cache/user/bust', [requireLogin, requireMemberState(MemberState.Member), requireRole('dev')], async (req: Request, res: Response) => {
try {
const clearedEntries = memberCache.Clear();
const payload: UserCacheBustResult = {
success: true,
clearedEntries,
bustedAt: new Date().toISOString(),
};
logger.info('app', 'User cache manually busted', {
actor: req.user.id,
clearedEntries,
});
return res.status(200).json(payload);
} catch (error) {
logger.error('app', 'Failed to bust user cache', {
caller: req.user?.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
return res.status(500).json({ error: 'Failed to bust user cache' });
} }
}) })
router.get('/:id', [requireLogin], async (req, res) => { router.get('/:id', [requireLogin], async (req, res) => {
try {
const userId = req.params.id; const userId = req.params.id;
try {
const result = await pool.query('SELECT * FROM view_member_rank_unit_status_latest WHERE id = $1;', [userId]); const result = await pool.query('SELECT * FROM view_member_rank_unit_status_latest WHERE id = $1;', [userId]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' }); return res.status(404).json({ error: 'User not found' });
} }
return res.status(200).json(result.rows[0]); return res.status(200).json(result.rows[0]);
} catch (err) { } catch (error) {
console.error('Error fetching user:', err); logger.error(
return res.status(500).json({ error: 'Failed to fetch user' }); 'app',
'Failed to get user',
{
user: userId,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
); return res.status(500).json({ error: 'Failed to fetch user' });
} }
}); });
@@ -126,5 +265,80 @@ router.put('/:id/displayname', async (req, res) => {
return res.status(501); return res.status(501);
}); });
//discharge member
router.post('/discharge', [requireLogin, requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res: Response) => {
try {
var con = await pool.getConnection();
let author = req.user.id;
con.beginTransaction();
var data: Discharge = req.body;
setUserState(data.userID, MemberState.Discharged, "Member Discharged", author, con, data.reason);
stripUserRoles(data.userID, con);
cancelLatestRank(data.userID, con);
cancelLatestUnit(data.userID, con);
con.commit();
memberCache.Invalidate(data.userID);
audit.member('discharged', { actorId: req.user.id, targetId: data.userID }, { reason: data.reason });
res.sendStatus(200);
} catch (error) {
logger.error('app', 'Failed to discharge user', {
data: data,
caller: req.user.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
res.sendStatus(500);
} finally {
if (con)
con.release();
}
});
//suspend member
router.post('/suspend', [requireLogin, requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res: Response) => {
let author = req.user.id;
let target = Number(req.query.target);
try {
await setUserState(target, MemberState.Suspended, "Member Suspended", author, null);
audit.member('suspension_added', { actorId: author, targetId: target });
res.sendStatus(200);
} catch (error) {
logger.error('app', 'Failed to suspend user', {
target: target,
caller: req.user.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
res.sendStatus(500);
}
})
//unsuspend member
router.post('/unsuspend', [requireLogin, requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res: Response) => {
let author = req.user.id;
let target = Number(req.query.target);
try {
let prevState = await getLastNonSuspendedState(target);
await setUserState(target, prevState, "Member Suspension Removed", author, null);
audit.member('suspension_removed', { actorId: author, targetId: target });
res.sendStatus(200);
} catch (error) {
logger.error('app', 'Failed to suspend user', {
target: target,
caller: req.user.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
res.sendStatus(500);
}
})
export const memberRouter = router; export const memberRouter = router;

View File

@@ -1,8 +1,11 @@
import { MemberState } from "@app/shared/types/member"; import { MemberState } from "@app/shared/types/member";
import { requireLogin, requireMemberState, requireRole } from "../middleware/auth"; import { requireLogin, requireMemberState, requireRole } from "../middleware/auth";
import { getAllRanks, insertMemberRank } from "../services/rankService"; import { batchInsertMemberRank, getAllRanks, getPromotionHistorySummary, getPromotionsOnDay, insertMemberRank } from "../services/db/rankService";
import { BatchPromotion, BatchPromotionMember } from '@app/shared/schemas/promotionSchema'
import express = require('express'); import express = require('express');
import { logger } from "../services/logging/logger";
import { audit } from "../services/logging/auditLog";
const r = express.Router(); const r = express.Router();
const ur = express.Router(); const ur = express.Router();
@@ -11,26 +14,83 @@ r.use(requireLogin)
ur.use(requireLogin) ur.use(requireLogin)
//insert a new latest rank for a user //insert a new latest rank for a user
ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), requireMemberState(MemberState.Member)], async (req, res) => { ur.post('/', [requireRole(["17th Command", "17th Administrator", "17th HQ"]), requireMemberState(MemberState.Member)], async (req: express.Request, res: express.Response) => {
3
try { try {
const change = req.body?.change; const change = req.body.promotions as BatchPromotionMember[];
await insertMemberRank(change.member_id, change.rank_id, change.date); const approver = req.body.approver as number;
const author = req.user.id;
if (!change) res.sendStatus(400);
await batchInsertMemberRank(change, author, approver);
audit.member('update_rank', { actorId: author, targetId: null }, { changes: change.length });
logger.info('app', 'Promotion batch submitted', { author: author })
res.sendStatus(201); res.sendStatus(201);
} catch (err) { } catch (error) {
console.error('Insert failed:', err); logger.error(
'app',
'Failed to post rank change',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json({ error: 'Failed to update ranks' }); res.status(500).json({ error: 'Failed to update ranks' });
} }
}); });
ur.get('/', async (req: express.Request, res: express.Response) => {
try {
const promos = await getPromotionHistorySummary();
res.status(200).json(promos);
} catch (error) {
logger.error(
'app',
'Failed to get rank change history',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.sendStatus(500);
}
});
ur.get('/:day', async (req: express.Request, res: express.Response) => {
try {
if (!req.params.day) res.sendStatus(400);
var day = new Date(req.params.day)
const promos = await getPromotionsOnDay(day);
res.status(200).json(promos);
} catch (error) {
logger.error(
'app',
'Failed to get rank change history on day',
{
day: day,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.sendStatus(500);
}
})
//get all ranks //get all ranks
r.get('/', async (req, res) => { r.get('/', async (req, res) => {
try { try {
const ranks = await getAllRanks(); const ranks = await getAllRanks();
res.json(ranks); res.json(ranks);
} catch (err) { } catch (error) {
console.error(err); logger.error(
'app',
'Failed to get all ranks',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });

View File

@@ -5,84 +5,138 @@ const ur = express.Router();
import { MemberState } from '@app/shared/types/member'; import { MemberState } from '@app/shared/types/member';
import pool from '../db'; import pool from '../db';
import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; import { requireLogin, requireMemberState, requireRole } from '../middleware/auth';
import { assignUserGroup, createGroup } from '../services/rolesService'; import { assignUserGroup, createGroup, getAllRoles, getRole, getUsersWithRole } from '../services/db/rolesService';
import { Request, Response } from 'express';
import { logger } from '../services/logging/logger';
import { audit } from '../services/logging/auditLog';
r.use(requireLogin) r.use(requireLogin)
ur.use(requireLogin) ur.use(requireLogin)
//manually assign a member to a group //manually assign a member to a group
ur.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { ur.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res) => {
try {
const body = req.body; const body = req.body;
assignUserGroup(body.member_id, body.role_id); try {
await assignUserGroup(body.member_id, body.role_id);
logger.info('app', 'User assigned role', { user: body.member_id, role: body.role_id, assigner: req.user.id })
res.sendStatus(201); res.sendStatus(201);
} catch (err) { audit.roles('add_member', { actorId: req.user.id, targetId: body.role_id }, { member: body.member_id, role: body.role_id });
console.error('Insert failed:', err);
} catch (error) {
if (error?.code === 'ER_DUP_ENTRY') {
return res.status(400).json({
error: 'Member already has this role',
});
}
logger.error(
'app',
'Failed to assign role',
{
user: body.member_id,
role: body.role_id,
assigner: req.user.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json({ error: 'Failed to add to group' }); res.status(500).json({ error: 'Failed to add to group' });
} }
}); });
//manually remove member from group //manually remove member from group
ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res: Response) => {
try {
const body = req.body; const body = req.body;
try {
const sql = 'DELETE FROM members_roles WHERE member_id = ? AND role_id = ?' const sql = 'DELETE FROM members_roles WHERE member_id = ? AND role_id = ?'
await pool.query(sql, [body.member_id, body.role_id]) await pool.query(sql, [body.member_id, body.role_id])
logger.info('app', 'User removed role', { user: body.member_id, role: body.role_id, assigner: req.user.id })
audit.roles('remove_member', { actorId: req.user.id, targetId: body.role_id }, { member: body.member_id, role: body.role_id });
res.sendStatus(200); res.sendStatus(200);
} }
catch (err) { catch (error) {
console.error("delete failed: ", err) logger.error(
'app',
'Failed to remove role',
{
user: body.member_id,
role: body.role_id,
assigner: req.user.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.status(500).json({ error: 'Failed to remove from group' }); res.status(500).json({ error: 'Failed to remove from group' });
} }
}) })
//get all roles //get all roles
r.get('/', [requireMemberState(MemberState.Member)], async (req, res) => { r.get('/', [requireMemberState(MemberState.Member)], async (req, res) => {
try { try {
var con = await pool.getConnection(); const roles = await getAllRoles();
// Get all roles res.status(200).json(roles);
const roles = await con.query('SELECT * FROM roles;'); } catch (error) {
logger.error(
// Get all members for each role 'app',
const membersRoles = await con.query(` 'Failed to get all roles',
SELECT mr.role_id, v.* {
FROM members_roles mr error: error instanceof Error ? error.message : String(error),
JOIN view_member_rank_unit_status_latest v ON mr.member_id = v.member_id stack: error instanceof Error ? error.stack : undefined,
`);
// Group members by role_id
const roleIdToMembers = {};
for (const row of membersRoles) {
if (!roleIdToMembers[row.role_id]) roleIdToMembers[row.role_id] = [];
// Remove role_id from member object
const { role_id, ...member } = row;
roleIdToMembers[role_id].push(member);
} }
);
// Attach members to each role res.sendStatus(500);
const result = roles.map(role => ({
...role,
members: roleIdToMembers[role.id] || []
}));
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
} finally {
con.release();
} }
}); });
r.get('/:id/members', [requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
try {
const members = await getUsersWithRole(Number(req.params.id));
res.status(200).json(members);
} catch (error) {
logger.error(
'app',
'Failed to get role members',
{
role: req.params.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.sendStatus(500);
}
})
r.get('/:id', [requireMemberState(MemberState.Member)], async (req: Request, res: Response) => {
try {
const role = await getRole(Number(req.params.id));
res.status(200).json(role);
} catch (error) {
logger.error(
'app',
'Failed to get role members',
{
role: req.params.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.sendStatus(500);
}
})
//create a new role //create a new role
r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { r.post('/', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => {
try { try {
const { name, color, description } = req.body; const { name, color, description } = req.body;
if (!name || !color) { if (!name || !color) {
@@ -94,7 +148,8 @@ r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administr
return res.status(400).json({ error: 'Color must be a valid hex color (#ffffff)' }); return res.status(400).json({ error: 'Color must be a valid hex color (#ffffff)' });
} }
await createGroup(name, color, description); let out = await createGroup(name, color, description);
audit.roles('create', { actorId: req.user.id, targetId: out.id });
res.sendStatus(201); res.sendStatus(201);
} catch (err) { } catch (err) {
@@ -103,12 +158,15 @@ r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administr
} }
}) })
r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => {
try { try {
const id = req.params.id; const id = req.params.id;
const sql = 'DELETE FROM roles WHERE id = ?'; const sql = 'DELETE FROM roles WHERE id = ?';
const res = await pool.query(sql, [id]); const res = await pool.query(sql, [id]);
audit.roles('delete', { actorId: req.user.id, targetId: id });
res.sendStatus(200); res.sendStatus(200);
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -4,6 +4,7 @@ const memberStatusR = express.Router();
import pool from '../db'; import pool from '../db';
import { requireLogin } from '../middleware/auth'; import { requireLogin } from '../middleware/auth';
import { logger } from '../services/logging/logger';
statusR.use(requireLogin); statusR.use(requireLogin);
memberStatusR.use(requireLogin); memberStatusR.use(requireLogin);
@@ -38,9 +39,16 @@ statusR.get('/', async (req, res) => {
try { try {
const result = await pool.query('SELECT * FROM statuses;'); const result = await pool.query('SELECT * FROM statuses;');
res.json(result); res.json(result);
} catch (err) { } catch (error) {
console.error(err); logger.error(
res.status(500).json({ error: 'Internal server error' }); 'app',
'Failed to get all statuses',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.sendStatus(500);
} }
}); });

73
api/src/routes/units.ts Normal file
View File

@@ -0,0 +1,73 @@
import express = require('express');
const unitsRouter = express.Router();
const memberUnitsRouter = express.Router();
import { Request, Response } from 'express';
import pool from '../db';
import { requireLogin, requireMemberState, requireRole } from '../middleware/auth';
import { logger } from '../services/logging/logger';
import { Unit } from '@app/shared/types/units';
import { MemberState } from '@app/shared/types/member';
import { assignNewUnit } from '../services/db/unitService';
import { audit } from '../services/logging/auditLog';
import { forceInsertMemberRank, insertMemberRank } from '../services/db/rankService';
unitsRouter.use(requireLogin);
//get all units
unitsRouter.get('/', async (req, res) => {
try {
const result: Unit[] = await pool.query('SELECT * FROM units WHERE active = 1;');
res.json(result);
} catch (error) {
logger.error(
'app',
'Failed to get all units',
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}
);
res.sendStatus(500);
}
});
memberUnitsRouter.post('/admin', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res: Response) => {
const memberId = Number(req.query.memberId);
const unitId = Number(req.query.unitId);
const rankId = Number(req.query.rankId);
const reason = req.query.reason as string;
try {
if (!memberId || !unitId) {
return res.status(400).json({ error: 'memberId and unitId query parameters are required' });
}
await assignNewUnit(memberId, unitId, req.user.id, req.user.id, reason);
await forceInsertMemberRank(memberId, rankId, req.user.id, req.user.id, reason);
logger.info('app', 'Member force assigned unit', {
member: memberId,
unit: unitId,
rank: rankId,
caller: req.user.id,
});
audit.member('update_unit', { actorId: req.user.id, targetId: memberId }, { unit: unitId, rank: rankId, reason: reason });
res.sendStatus(200);
} catch (error) {
logger.error('app', 'Failed to force assign unit', {
member: memberId,
unit: unitId,
caller: req.user.id,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
res.sendStatus(500);
}
});
export const units = unitsRouter;
export const memberUnits = memberUnitsRouter;

29
api/src/services/cache/cache.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
export class CacheService<Key, Value> {
private cacheMap: Map<Key, Value>
constructor() {
this.cacheMap = new Map<Key, Value>();
}
public Get(key: Key): Value {
return this.cacheMap.get(key)
}
public Set(key: Key, value: Value) {
this.cacheMap.set(key, value);
}
public Invalidate(key: Key): boolean {
return this.cacheMap.delete(key);
}
public Size(): number {
return this.cacheMap.size;
}
public Clear(): number {
const priorSize = this.cacheMap.size;
this.cacheMap.clear();
return priorSize;
}
}

View File

@@ -1,4 +1,4 @@
import pool from "../db" import pool from "../../db"
import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course" import { Course, CourseAttendee, CourseAttendeeRole, CourseEventDetails, CourseEventSummary, RawAttendeeRow } from "@app/shared/types/course"
import { PagedData } from "@app/shared/types/pagination"; import { PagedData } from "@app/shared/types/pagination";
import { toDateTime } from "@app/shared/utils/time"; import { toDateTime } from "@app/shared/utils/time";
@@ -83,8 +83,10 @@ export async function insertCourseEvent(event: CourseEventDetails): Promise<numb
try { try {
var con = await pool.getConnection(); var con = await pool.getConnection();
let course: Course = await getCourseByID(event.course_id);
await con.beginTransaction(); await con.beginTransaction();
const res = await con.query("INSERT INTO course_events (course_id, event_date, remarks, created_by) VALUES (?, ?, ?, ?);", [event.course_id, toDateTime(event.event_date), event.remarks, event.created_by]); const res = await con.query("INSERT INTO course_events (course_id, event_date, remarks, created_by, hasBookwork, hasQual) VALUES (?, ?, ?, ?, ?, ?);", [event.course_id, toDateTime(event.event_date), event.remarks, event.created_by, course.hasBookwork, course.hasQual]);
var eventID: number = res.insertId; var eventID: number = res.insertId;
for (const attendee of event.attendees) { for (const attendee of event.attendees) {

View File

@@ -1,11 +1,22 @@
import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application"; import { ApplicationListRow, ApplicationRow, CommentRow } from "@app/shared/types/application";
import pool from "../db"; import pool from "../../db";
import { error } from "console"; import { error } from "console";
import * as mariadb from 'mariadb';
export async function createApplication(memberID: number, appVersion: number, app: string) {
/**
* Create an application in the db
* @param memberID
* @param appVersion
* @param app
* @returns ID of the created application
*/
export async function createApplication(memberID: number, appVersion: number, app: string): Promise<number> {
const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`; const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`;
const params = [memberID, appVersion, JSON.stringify(app)] const params = [memberID, appVersion, JSON.stringify(app)]
return await pool.query(sql, params);
let result = await pool.query(sql, params);
return Number(result.insertId);
} }
export async function getMemberApplication(memberID: number): Promise<ApplicationRow> { export async function getMemberApplication(memberID: number): Promise<ApplicationRow> {
@@ -63,7 +74,7 @@ export async function getAllMemberApplications(memberID: number): Promise<Applic
} }
export async function approveApplication(id: number, approver: number) { export async function approveApplication(id: number, approver: number, con: mariadb.Connection | mariadb.Pool = pool) {
const sql = ` const sql = `
UPDATE applications UPDATE applications
SET approved_at = NOW(), approved_by = ? SET approved_at = NOW(), approved_by = ?
@@ -72,7 +83,7 @@ export async function approveApplication(id: number, approver: number) {
AND denied_at IS NULL AND denied_at IS NULL
`; `;
const result = await pool.execute(sql, [approver, id]); const result = await con.query(sql, [approver, id]);
if (result.affectedRows == 1) { if (result.affectedRows == 1) {
return return
} else { } else {

View File

@@ -1,4 +1,4 @@
import pool from '../db'; import pool from '../../db';
import { CalendarEventShort, CalendarSignup, CalendarEvent, CalendarAttendance } from "@app/shared/types/calendar" import { CalendarEventShort, CalendarSignup, CalendarEvent, CalendarAttendance } from "@app/shared/types/calendar"
import { toDateTime } from "@app/shared/utils/time" import { toDateTime } from "@app/shared/utils/time"
@@ -19,7 +19,8 @@ export async function createEvent(eventObject: Omit<CalendarEvent, 'id' | 'creat
]; ];
const result = await pool.query(sql, params); const result = await pool.query(sql, params);
return { id: result.insertId, ...eventObject }; let id = Number(result.insertId);
return id;
} }
export async function updateEvent(eventObject: CalendarEvent) { export async function updateEvent(eventObject: CalendarEvent) {

View File

@@ -1,5 +1,5 @@
import { toDateTime } from "@app/shared/utils/time"; import { toDateTime } from "@app/shared/utils/time";
import pool from "../db"; import pool from "../../db";
import { LOARequest, LOAType } from '@app/shared/types/loa' import { LOARequest, LOAType } from '@app/shared/types/loa'
import { PagedData } from '@app/shared/types/pagination' import { PagedData } from '@app/shared/types/pagination'
@@ -69,17 +69,18 @@ export async function getUserActiveLOA(userId: number): Promise<LOARequest[]> {
FROM leave_of_absences FROM leave_of_absences
WHERE member_id = ? WHERE member_id = ?
AND closed IS NULL AND closed IS NULL
AND UTC_TIMESTAMP() BETWEEN start_date AND end_date;` AND UTC_TIMESTAMP() > start_date;`
const LOAData = await pool.query(sql, [userId]); const LOAData = await pool.query(sql, [userId]);
return LOAData; return LOAData;
} }
export async function createNewLOA(data: LOARequest) { export async function createNewLOA(data: LOARequest): Promise<number> {
const sql = `INSERT INTO leave_of_absences const sql = `INSERT INTO leave_of_absences
(member_id, filed_date, start_date, end_date, type_id, reason) (member_id, filed_date, start_date, end_date, type_id, reason)
VALUES (?, ?, ?, ?, ?, ?)`; VALUES (?, ?, ?, ?, ?, ?)`;
await pool.query(sql, [data.member_id, toDateTime(data.filed_date), toDateTime(data.start_date), toDateTime(data.end_date), data.type_id, data.reason]) let out = await pool.query(sql, [data.member_id, toDateTime(data.filed_date), toDateTime(data.start_date), toDateTime(data.end_date), data.type_id, data.reason])
return;
return Number(out.insertId);
} }
export async function closeLOA(id: number, closer: number) { export async function closeLOA(id: number, closer: number) {

View File

@@ -0,0 +1,286 @@
import { Role } from "@app/shared/types/roles";
import pool from "../../db";
import { Member, MemberCardDetails, MemberLight, memberSettings, MemberState, PaginatedMembers } from '@app/shared/types/member'
import { logger } from "../logging/logger";
import { memberCache } from "../../routes/auth";
import * as mariadb from 'mariadb';
export async function getFilteredMembers(
page: number = 1,
pageSize: number = 15,
search?: string,
status?: string,
unitId?: string
): Promise<PaginatedMembers> {
try {
const offset = (page - 1) * pageSize;
const whereClauses: string[] = [];
const params: any[] = [];
if (status && status !== 'all') {
whereClauses.push(`m.state = ?`);
params.push(status);
}
if (search) {
whereClauses.push(`(v.member_name LIKE ? OR v.displayName LIKE ?)`);
params.push(`%${search}%`);
params.push(`%${search}%`);
}
if (unitId && unitId !== 'all') {
whereClauses.push(`v.unit = ?`);
params.push(unitId);
}
const whereClause = whereClauses.length > 0
? ` WHERE ${whereClauses.join(' AND ')}`
: '';
// COUNT QUERY
const countQuery = `SELECT COUNT(*) as total FROM view_member_rank_unit_status_latest v INNER JOIN members m ON v.member_id = m.id ${whereClause}`;
const [countResults]: any[] = await pool.query(countQuery, params);
const total = Number(countResults?.total) || 0;
// DATA QUERY
const dataQuery = `
SELECT
v.*,
CASE
WHEN EXISTS (
SELECT 1 FROM leave_of_absences l
WHERE l.member_id = v.member_id
AND l.deleted = 0
AND UTC_TIMESTAMP() BETWEEN l.start_date AND l.end_date
) THEN 1 ELSE 0
END AS on_loa
FROM view_member_rank_unit_status_latest v
INNER JOIN members m ON v.member_id = m.id
${whereClause} -- Added back correctly
ORDER BY v.member_name ASC
LIMIT ? OFFSET ?
`;
const rows: any[] = await pool.query(dataQuery, [...params, pageSize, offset]);
// Map rows to Member type
const members: Member[] = rows.map(row => ({
member_id: Number(row.member_id),
member_name: row.member_name,
displayName: row.displayName,
rank: row.rank,
rank_date: row.rank_date,
unit: row.unit,
unit_date: row.unit_date,
status: row.status,
status_date: row.status_date,
loa_until: row.loa_until ? new Date(row.loa_until) : undefined,
member_state: row.member_state
}));
return {
data: members,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
} catch (error) {
logger.error('app', 'Error fetching filtered members', {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
export async function getUserData(userID: number): Promise<Member> {
const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`;
const res: Member = await pool.query(sql, [userID]);
return res[0] ?? null;
}
export async function setUserState(userID: number, state: MemberState, reason: string, creatorID: number, externalCon?: mariadb.PoolConnection, details: string = "", endPrevious: boolean = true, createHistory: boolean = true) {
const isInternalConn = !externalCon;
if (isInternalConn)
var con = await pool.getConnection();
else
var con = externalCon;
try {
if (isInternalConn) await con.beginTransaction();
if (endPrevious)
await endLatestMemberState(userID, con);
const sql = `UPDATE members SET state = ? WHERE id = ?;`;
await con.query(sql, [state, userID]);
if (createHistory) {
const insertHistorySql = `INSERT INTO member_state_history
(member_id, state_id, reason, created_by_id, start_date, end_date, reason_detailed)
VALUES (?, ?, ?, ?, NOW(), NULL, ?);`;
await con.query(insertHistorySql, [userID, state, reason, creatorID, details]);
}
if (isInternalConn) await con.commit();
} catch (error) {
if (isInternalConn) {
await con.rollback();
}
logger.error('app', 'Error setting user state', error);
throw error;
} finally {
memberCache.Invalidate(userID);
if (isInternalConn && con) con.release();
}
}
export async function getUserState(user: number): Promise<MemberState> {
let out = await pool.query(`SELECT state FROM members WHERE id = ?`, [user]);
return (out[0].state as MemberState);
}
export async function getMemberSettings(id: number): Promise<memberSettings> {
const sql = `SELECT * FROM view_member_settings WHERE id = ?`;
let out: memberSettings[] = await pool.query(sql, [id]);
if (out.length != 1)
throw new Error("Could not get user settings");
return out[0];
}
export async function setUserSettings(id: number, settings: memberSettings) {
const sql = `UPDATE view_member_settings SET
displayName = ?
WHERE id = ?;`;
let result = await pool.query(sql, [settings.displayName, id])
}
export async function getMembersLite(ids: number[]): Promise<MemberLight[]> {
const sql = `SELECT m.member_id AS id,
m.member_name AS username,
m.displayName,
u.color
FROM view_member_rank_unit_status_latest m
LEFT JOIN units u ON u.name = m.unit
WHERE member_id IN (?);`;
const res: MemberLight[] = await pool.query(sql, [ids]);
return res;
}
export async function getAllMembersLite(activeOnly: boolean): Promise<MemberLight[]> {
const filter = activeOnly ? `\nWHERE member_state = ${MemberState.Member}` : ''
const sql = `SELECT m.member_id AS id,
m.member_name AS username,
m.displayName,
u.color
FROM view_member_rank_unit_status_latest m
LEFT JOIN units u ON u.name = m.unit ${filter};`;
console.log(sql);
const res: MemberLight[] = await pool.query(sql);
return res;
}
export async function getMembersFull(ids: number[]): Promise<MemberCardDetails[]> {
const sql = `
SELECT
m.*,
(
SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT(
'id', r.id,
'name', r.name,
'color', r.color,
'description', r.description
)), JSON_ARRAY())
FROM members_roles mr
JOIN roles r ON mr.role_id = r.id
WHERE mr.member_id = m.member_id
) AS roles
FROM view_member_rank_unit_status_latest m
WHERE m.member_id IN (?);
`;
const rows: any[] = await pool.query(sql, [ids]);
return rows.map(row => {
const member: Member = {
member_id: row.member_id,
member_name: row.member_name,
displayName: row.displayName,
rank: row.rank,
rank_date: row.rank_date,
unit: row.unit,
unit_date: row.unit_date,
status: row.status,
status_date: row.status_date,
loa_until: row.loa_until ? new Date(row.loa_until) : undefined,
};
// roles comes as array of strings; parse each one
const roles: Role[] =
typeof row.roles === "string"
? JSON.parse(row.roles)
: row.roles;
return { member, roles };
});
}
export async function mapDiscordtoID(id: number): Promise<number | null> {
const sql = `SELECT id FROM members WHERE discord_id = ?;`
let res = await pool.query(sql, [id]);
return res.length > 0 ? res[0].id : null;
}
export async function endLatestMemberState(memberID: number, con: mariadb.Pool | mariadb.Connection = pool) {
const sql = `UPDATE member_state_history
SET end_date = NOW(),
updated_at = NOW()
WHERE id = (
SELECT id
FROM (
SELECT id
FROM member_state_history
WHERE member_id = ?
AND end_date IS NULL
ORDER BY start_date DESC,
created_at DESC
LIMIT 1
) AS x
);`;
try {
let res = await con.query(sql, [memberID]);
console.log(res);
return;
} catch (error) {
logger.error('app', 'Error ending latest member state', {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
// let res = await pool.query(sql, [memberID]);
// console.log(res);
}
export async function getLastNonSuspendedState(memberID: number): Promise<MemberState> {
try {
const sql = `SELECT state_id
FROM member_state_history
WHERE member_id = ?
AND state_id != ?
ORDER BY start_date DESC, id DESC
LIMIT 1;`
const res = await pool.query(sql, [memberID, MemberState.Suspended]);
console.log(res as MemberState[])
if (res.length)
return res[0].state_id as MemberState;
} catch (error) {
logger.error('app', 'Error ending latest member state', {
error: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -0,0 +1,129 @@
import { BatchPromotion, BatchPromotionMember } from "@app/shared/schemas/promotionSchema";
import { PromotionDetails, PromotionSummary } from "@app/shared/types/rank"
import pool from "../../db";
import { PagedData } from "@app/shared/types/pagination";
import { toDate, toDateIgnoreZone, toDateTime } from "@app/shared/utils/time";
import * as mariadb from 'mariadb';
export async function getAllRanks() {
const rows = await pool.query(
'SELECT id, name, short_name, sort_id FROM ranks;'
);
return rows;
}
export async function getRankByName(name: string) {
const rows = await pool.query(`SELECT id, name, short_name, sort_id FROM ranks WHERE name = ?`, [name]);
if (rows.length === 0)
throw new Error("Could not find rank: " + name);
return rows[0];
}
export async function insertMemberRank(member_id: number, rank_id: number, date: Date): Promise<void>;
export async function insertMemberRank(member_id: number, rank_id: number): Promise<void>;
export async function insertMemberRank(member_id: number, rank_id: number, date?: Date): Promise<void> {
const sql = date
? `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, ?);`
: `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, NOW());`;
const params = date
? [member_id, rank_id, date]
: [member_id, rank_id];
await pool.query(sql, params);
}
export async function forceInsertMemberRank(member_id: number, rank_id: number, authorized: number, creator: number, reason: string) {
const sql = `CALL sp_update_member_rank(?, ?, ?, ?, ?, NOW())`;
const result = await pool.query(sql, [member_id, rank_id, authorized, creator, reason]);
if (!result || result.affectedRows === 0) {
throw new Error("Failed to update member rank");
}
}
export async function batchInsertMemberRank(promos: BatchPromotionMember[], author: number, approver: number) {
try {
var con = await pool.getConnection();
promos.forEach(p => {
con.query(`CALL sp_update_member_rank(?, ?, ?, ?, ?, ?)`, [p.member_id, p.rank_id, approver, author, "Rank Change", toDateIgnoreZone(new Date(p.start_date))])
});
con.commit();
return
} catch (error) {
throw error; //pass it up
} finally {
con.release();
}
}
export async function getPromotionHistorySummary(page: number = 1, pageSize: number = 15): Promise<PagedData<PromotionSummary>> {
const offset = (page - 1) * pageSize;
let sql = `SELECT
DATE(start_date) AS entry_day
FROM
members_ranks
WHERE reason = 'Rank Change'
GROUP BY
entry_day
ORDER BY
entry_day DESC
LIMIT ? OFFSET ?;`
let promoList: PromotionSummary[] = await pool.query(sql, [pageSize, offset]) as PromotionSummary[];
let rowCount = Number((await pool.query(`SELECT
COUNT(*) AS total_grouped_days_count
FROM
(
SELECT DISTINCT DATE(start_date)
FROM members_ranks
WHERE reason = 'Rank Change'
) AS grouped_days;`))[0]);
let pageCount = rowCount / pageSize;
let output: PagedData<PromotionSummary> = { data: promoList, pagination: { page: page, pageSize: pageSize, total: rowCount, totalPages: pageCount } }
return output;
}
export async function getPromotionsOnDay(day: Date): Promise<PromotionDetails[]> {
const dayString = toDateTime(day);
// SQL query to fetch all records from members_unit for the specified day
let sql = `
SELECT
mr.id AS promo_id,
mr.member_id,
mr.created_by_id,
mr.authorized_by_id,
r.short_name
FROM members_ranks AS mr
LEFT JOIN ranks AS r ON r.id = mr.rank_id
WHERE DATE(mr.start_date) = ? && mr.reason = 'Rank Change'
ORDER BY mr.start_date ASC;
`;
let batchPromotion = await pool.query(sql, [dayString]) as PromotionDetails[];
return batchPromotion;
}
export async function cancelLatestRank(userID: number, con: mariadb.Pool | mariadb.Connection = pool): Promise<boolean> {
try {
let sql = `CALL sp_end_member_rank(?,NOW())`;
con.query(sql, [userID]);
return true;
} catch (error) {
throw error;
}
}

View File

@@ -0,0 +1,78 @@
import { MemberLight } from '@app/shared/types/member';
import pool from '../../db';
import { Role, RoleSummary } from '@app/shared/types/roles'
import { logger } from '../logging/logger';
import { memberCache } from '../../routes/auth';
import * as mariadb from 'mariadb';
export async function assignUserGroup(userID: number, roleID: number) {
try {
const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`;
const params = [userID, roleID];
return await pool.query(sql, params);
} catch (error) {
logger.error('app', 'Failed to assign user group', error);
} finally {
memberCache.Invalidate(userID);
}
}
export async function createGroup(name: string, color: string, description: string) {
const sql = `INSERT INTO roles (name, color, description) VALUES (?, ?, ?)`;
const params = [name, color, description];
const result = await pool.query(sql, params);
return { id: result.insertId, name, color, description };
}
export async function getUserRoles(userID: number): Promise<Role[]> {
const sql = `SELECT r.id, r.name
FROM members_roles mr
INNER JOIN roles r ON mr.role_id = r.id
WHERE mr.member_id = ?;`;
return await pool.query(sql, [userID]);
}
export async function getRole(id: number): Promise<Role> {
let res = await pool.query(`SELECT * FROM roles WHERE id = ?`, [id])
return res[0] as Role;
}
export async function getAllRoles(): Promise<RoleSummary> {
return await pool.query(`SELECT id, name, color FROM roles`);
}
export async function getUsersWithRole(roleId: number): Promise<MemberLight[]> {
const out = await pool.query(
`
SELECT
m.member_id AS id,
m.member_name AS username,
m.displayName,
u.color
FROM members_roles mr
JOIN view_member_rank_unit_status_latest m
ON m.member_id = mr.member_id
LEFT JOIN units u
ON u.name = m.unit
WHERE mr.role_id = ?
`,
[roleId]
)
return out as MemberLight[]
}
export async function stripUserRoles(userID: number, con: mariadb.Pool | mariadb.Connection = pool) {
try {
const out = await con.query(`DELETE FROM members_roles WHERE member_id = ?;`, [userID]);
return { success: true, affectedRows: out.affectedRows };
} catch (error) {
logger.error('app', 'Failed to strip user roles', error);
throw error;
} finally {
memberCache.Invalidate(userID);
}
}

View File

@@ -1,4 +1,4 @@
import pool from "../db" import pool from "../../db"
export async function assignUserToStatus(userID: number, statusID: number) { export async function assignUserToStatus(userID: number, statusID: number) {
const sql = `INSERT INTO members_statuses (member_id, status_id, start_date) VALUES (?, ?, NOW())` const sql = `INSERT INTO members_statuses (member_id, status_id, start_date) VALUES (?, ?, NOW())`

View File

@@ -0,0 +1,22 @@
import pool from "../../db";
import * as mariadb from 'mariadb';
export async function cancelLatestUnit(userID: number, con: mariadb.Pool | mariadb.Connection = pool): Promise<boolean> {
try {
let sql = `CALL sp_end_member_unit(?,NOW())`;
con.query(sql, [userID]);
return true;
} catch (error) {
throw error;
}
}
export async function assignNewUnit(memberID: number, unitID: number, authorizedID: number, creatorID: number, reason: string) {
let sql = `CALL sp_update_member_unit(?, ?, ?, ?, ?, NOW())`;
const result = await pool.query(sql, [memberID, unitID, authorizedID, creatorID, reason]);
if (!result || result.affectedRows === 0) {
throw new Error('Record was not updated');
}
}

View File

@@ -0,0 +1,56 @@
import { randomUUID } from "crypto";
import { logger } from "../logging/logger";
interface Event {
id: string
type: string
occurredAt: string
payload?: Record<string, any>
}
type EventHandler = (event: Event) => void | Promise<void>;
class EventBus {
private handlers: Map<string, EventHandler[]> = new Map();
/**
* Register event listener
* @param type
* @param handler
*/
on(type: string, handler: EventHandler) {
const handlers = this.handlers.get(type) ?? [];
handlers.push(handler);
this.handlers.set(type, handlers);
}
/**
* Emit event of given type
* @param type
* @param payload
*/
async emit(type: string, payload?: Record<string, any>) {
const event: Event = {
id: randomUUID(),
type,
occurredAt: new Date().toISOString(),
payload
}
const handlers = this.handlers.get(type) ?? []
for (const h of handlers) {
try {
await h(event)
} catch (error) {
logger.error('app', 'Event handler failed', {
type: event.type,
id: event.id,
error: error instanceof Error ? error.message : String(error),
})
}
}
}
}
export const bus = new EventBus();

View File

@@ -0,0 +1,39 @@
import { bus } from "../events/eventBus";
import { logger } from "../logging/logger";
export function initializeDiscordIntegrations() {
bus.on('application.create', async (event) => {
if (!process.env.DISCORD_APPLICATIONS_WEBHOOK) {
logger.error("app", 'Discord Applications Webhook is not defined')
return;
}
let applicantName = event.payload.member_discord_id || event.payload.member_name;
if (event.payload.member_discord_id) {
applicantName = `<@${event.payload.member_discord_id}>`;
}
const link = `${process.env.CLIENT_URL}/administration/applications/${event.payload.application}`;
const embed = {
title: "Application Posted",
description: `[View Application](${link})`,
color: 0x00ff00, // optional: green color
timestamp: new Date().toISOString(), // <-- Discord expects ISO8601
fields: [
{
name: "Submitted By",
value: applicantName,
inline: false,
},
],
};
// send to Discord webhook
await fetch(process.env.DISCORD_APPLICATIONS_WEBHOOK!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embeds: [embed] }),
});
});
}

View File

@@ -0,0 +1,61 @@
import pool from "../../db";
import { logger } from "./logger";
export type AuditArea = 'member' | 'calendar' | 'roles' | 'auth' | 'leave_of_absence' | 'application' | 'course';
export interface AuditContext {
actorId: number; // The person doing the action (created_by)
targetId?: number; // The ID of the thing being changed (target_id)
}
class AuditLogger {
async record(
area: AuditArea,
action: string,
context: AuditContext,
data: Record<string, any> = {} // Already optional with default {}
) {
const actionType = `${area}.${action}`;
try {
await pool.query(
`INSERT INTO audit_log (action_type, payload, target_id, created_by)
VALUES (?, ?, ?, ?)`, // Fixed: removed extra comma/placeholder
[
actionType,
JSON.stringify(data),
context.targetId || null,
context.actorId,
]
);
} catch (err) {
logger.error('audit', `AUDIT_FAILURE: Failed to log ${actionType}`, { error: err });
}
}
member(action: 'update_rank'| 'update_unit' | 'suspension_added' | 'suspension_removed' | 'discharged', context: AuditContext, data: any = {}) {
return this.record('member', action, context, data);
}
roles(action: 'add_member' | 'remove_member' | 'create' | 'delete', context: AuditContext, data: any = {}) {
return this.record('roles', action, context, data);
}
leaveOfAbsence(action: 'created' | 'admin_created' | 'ended' | 'admin_ended' | 'extended', context: AuditContext, data: any = {}) {
return this.record('leave_of_absence', action, context, data);
}
calendar(action: 'event_created' | 'event_updated' | 'attendance_set' | 'cancelled' | 'un-cancelled', context: AuditContext, data: any = {}) {
return this.record('calendar', action, context, data);
}
application(action: 'created' | 'approved' | 'denied' | 'restarted', context: AuditContext, data: any = {}) {
return this.record('application', action, context, data);
}
course(action: 'report_created' | 'report_edited', context: AuditContext, data: any = {}) {
return this.record('course', action, context, data);
}
}
export const audit = new AuditLogger();

View File

@@ -0,0 +1,72 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogDepth = 'normal' | 'verbose' | 'profiling';
export type LogType = 'http' | 'app' | 'auth' | 'profiling' | 'audit';
export interface LogHeader {
timestamp: string;
level: LogLevel;
depth: LogDepth;
type: LogType; // 'http', 'app', 'db', etc.
user_id?: number;
}
export interface LogPayload {
message?: string; // short human-friendly description
data?: Record<string, any>; // type-specific rich data
}
// Environment defaults
const CURRENT_DEPTH: LogDepth = (process.env.LOG_DEPTH as LogDepth) || 'normal';
const DEPTH_ORDER: Record<LogDepth, number> = { normal: 0, verbose: 1, profiling: 2 };
function shouldLog(depth: LogDepth) {
let should = DEPTH_ORDER[depth] <= DEPTH_ORDER[CURRENT_DEPTH]
return should;
}
function emitLog(header: LogHeader, payload: LogPayload = {}) {
if (!shouldLog(header.depth)) return;
const logLine = { ...header, ...payload };
if (header.level === 'error')
console.error(JSON.stringify(logLine))
else
console.log(JSON.stringify(logLine));
}
export const logger = {
log(level: LogLevel, type: LogType, message: string, data?: Record<string, any>, depth: LogDepth = 'normal', context?: Partial<LogHeader>) {
const header: LogHeader = {
timestamp: new Date().toISOString(),
level,
depth,
type,
...context,
};
const payload: LogPayload = {
message,
data,
};
emitLog(header, payload);
},
info(type: LogType, message: string, data?: Record<string, any>, depth: LogDepth = 'normal', context?: Partial<LogHeader>) {
this.log('info', type, message, data, depth, context);
},
debug(type: LogType, message: string, data?: Record<string, any>, depth: LogDepth = 'normal', context?: Partial<LogHeader>) {
this.log('debug', type, message, data, depth, context);
},
warn(type: LogType, message: string, data?: Record<string, any>, depth: LogDepth = 'normal', context?: Partial<LogHeader>) {
this.log('warn', type, message, data, depth, context);
},
error(type: LogType, message: string, data?: Record<string, any>, depth: LogDepth = 'normal', context?: Partial<LogHeader>) {
this.log('error', type, message, data, depth, context);
},
}

View File

@@ -1,73 +0,0 @@
import pool from "../db";
import { Member, MemberLight, memberSettings, MemberState } from '@app/shared/types/member'
export async function getUserData(userID: number): Promise<Member> {
const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`;
const res: Member = await pool.query(sql, [userID]);
return res[0] ?? null;
}
export async function setUserState(userID: number, state: MemberState) {
const sql = `UPDATE members
SET state = ?
WHERE id = ?;`;
return await pool.query(sql, [state, userID]);
}
export async function getUserState(user: number): Promise<MemberState> {
let out = await pool.query(`SELECT state FROM members WHERE id = ?`, [user]);
return (out[0].state as MemberState);
}
export async function getMemberSettings(id: number): Promise<memberSettings> {
const sql = `SELECT * FROM view_member_settings WHERE id = ?`;
let out: memberSettings[] = await pool.query(sql, [id]);
if (out.length != 1)
throw new Error("Could not get user settings");
return out[0];
}
export async function setUserSettings(id: number, settings: memberSettings) {
const sql = `UPDATE view_member_settings SET
displayName = ?
WHERE id = ?;`;
let result = await pool.query(sql, [settings.displayName, id])
}
export async function getMembersLite(ids: number[]): Promise<MemberLight[]> {
const sql = `SELECT m.member_id AS id,
m.member_name AS username,
m.displayName,
u.color
FROM view_member_rank_unit_status_latest m
LEFT JOIN units u ON u.name = m.unit
WHERE member_id IN (?);`;
const res: MemberLight[] = await pool.query(sql, [ids]);
return res;
}
export async function getAllMembersLite(): Promise<MemberLight[]> {
const sql = `SELECT m.member_id AS id,
m.member_name AS username,
m.displayName,
u.color
FROM view_member_rank_unit_status_latest m
LEFT JOIN units u ON u.name = m.unit;`;
const res: MemberLight[] = await pool.query(sql);
return res;
}
export async function getMembersFull(ids: number[]): Promise<Member[]> {
const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id IN (?);`;
const res: Member[] = await pool.query(sql, [ids]);
return res;
}
export async function mapDiscordtoID(id: number): Promise<number | null> {
const sql = `SELECT id FROM members WHERE discord_id = ?;`
let res = await pool.query(sql, [id]);
return res.length > 0 ? res[0].id : null;
}

View File

@@ -1,32 +0,0 @@
import pool from "../db";
export async function getAllRanks() {
const rows = await pool.query(
'SELECT id, name, short_name, sort_id FROM ranks;'
);
return rows;
}
export async function getRankByName(name: string) {
const rows = await pool.query(`SELECT id, name, short_name, sort_id FROM ranks WHERE name = ?`, [name]);
if (rows.length === 0)
throw new Error("Could not find rank: " + name);
return rows[0];
}
export async function insertMemberRank(member_id: number, rank_id: number, date: Date): Promise<void>;
export async function insertMemberRank(member_id: number, rank_id: number): Promise<void>;
export async function insertMemberRank(member_id: number, rank_id: number, date?: Date): Promise<void> {
const sql = date
? `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, ?);`
: `INSERT INTO members_ranks (member_id, rank_id, start_date) VALUES (?, ?, NOW());`;
const params = date
? [member_id, rank_id, date]
: [member_id, rank_id];
await pool.query(sql, params);
}

View File

@@ -1,27 +0,0 @@
import pool from '../db';
import { Role } from '@app/shared/types/roles'
export async function assignUserGroup(userID: number, roleID: number) {
const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`;
const params = [userID, roleID];
return await pool.query(sql, params);
}
export async function createGroup(name: string, color: string, description: string) {
const sql = `INSERT INTO roles (name, color, description) VALUES (?, ?, ?)`;
const params = [name, color, description];
const result = await pool.query(sql, params);
return { id: result.insertId, name, color, description };
}
export async function getUserRoles(userID: number): Promise<Role[]> {
const sql = `SELECT r.id, r.name
FROM members_roles mr
INNER JOIN roles r ON mr.role_id = r.id
WHERE mr.member_id = ?;`;
return await pool.query(sql, [userID]);
}

View File

@@ -7,6 +7,7 @@
"node", "node",
"express" "express"
], ],
"sourceMap": true,
"paths": { "paths": {
"@app/shared/*": ["../shared/*"] "@app/shared/*": ["../shared/*"]
} }

13
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,13 @@
version: "3.9"
services:
db:
image: mariadb:10.6.23-ubi9
environment:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: ranger_unit_tracker
MARIADB_USER: dev
MARIADB_PASSWORD: dev
ports:
- "3306:3306"
volumes:
- ./db_data:/var/lib/mysql

54
readme.md Normal file
View File

@@ -0,0 +1,54 @@
## Prerequs
* Node.js
* npm
* Docker + Docker Compose
## Installation
Install dependencies in each workspace:
```
cd ui && npm install
cd ../api && npm install
cd ../shared && npm install
```
## Local Development Setup
From the project root, start required services:
```
docker compose -f docker-compose.dev.yml up
```
Run database setup from `/api`:
```
npm run migrate:up
npm run migrate:seed
```
## Running the App
Start the frontend:
```
cd ui
npm run dev
```
Start the API:
```
cd api
npm run dev
```
* UI runs via Vite
* API runs on Node after TypeScript build
## Notes
* `shared` must have its dependencies installed for both UI and API to work
* `docker-compose.dev.yml` is required for local dev dependencies (e.g. database)

View File

@@ -0,0 +1,10 @@
import z from "zod";
export const dischargeSchema = z.object({
reason: z.string().min(1, "Please provide a valid reason for discharge").max(200),
// effectiveDate: z.string().min(1, "Date is required"),
})
export type Discharge = z.infer<typeof dischargeSchema> & {
userID: number;
};

View File

@@ -0,0 +1,32 @@
import { z } from "zod";
export const batchPromotionMemberSchema = z.object({
member_id: z.number({ invalid_type_error: "Must select a member" }).int().positive(),
rank_id: z.number({ invalid_type_error: "Must select a rank" }).int().positive(),
start_date: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: "Must be a valid date",
}),
});
export const batchPromotionSchema = z.object({
promotions: z.array(batchPromotionMemberSchema, { message: "At least one promotion is required" }).nonempty({ message: "At least one promotion is required" }),
approver: z.number({ invalid_type_error: "Must select a member" }).int().positive()
})
.superRefine((data, ctx) => {
// optional: check for duplicate member_ids
const memberCounts = new Map<number, number>();
data.promotions.forEach((p, index) => {
memberCounts.set(p.member_id, (memberCounts.get(p.member_id) ?? 0) + 1);
if (memberCounts.get(p.member_id)! > 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["promotions", index, "member_id"],
message: "Duplicate member in batch is not allowed",
});
}
});
});
export type BatchPromotion = z.infer<typeof batchPromotionSchema>;
export type BatchPromotionMember = z.infer<typeof batchPromotionMemberSchema>;

View File

@@ -1,17 +1,22 @@
import { LOARequest } from "./loa"; import { LOARequest } from "./loa";
import { Role } from "./roles"; import { Role } from "./roles";
import { PagedData } from "./pagination";
export interface memberSettings { export interface memberSettings {
displayName: string; displayName: string;
} }
export type PaginatedMembers = PagedData<Member>;
export enum MemberState { export enum MemberState {
Guest = "guest", Guest = 1,
Applicant = "applicant", Applicant = 2,
Member = "member", Member = 3,
Retired = "retired", Retired = 4,
Banned = "banned", Discharged = 5,
Denied = "denied" Suspended = 6,
Banned = 7,
Denied = 8
} }
export type Member = { export type Member = {
@@ -25,6 +30,7 @@ export type Member = {
status: string | null; status: string | null;
status_date: string | null; status_date: string | null;
loa_until?: Date; loa_until?: Date;
member_state?: MemberState;
}; };
export interface MemberLight { export interface MemberLight {
@@ -34,9 +40,20 @@ export interface MemberLight {
color: string color: string
} }
export interface MemberCardDetails {
member: Member;
roles: Role[];
}
export interface myData { export interface myData {
member: Member; member: Member;
LOAs: LOARequest[]; LOAs: LOARequest[];
roles: Role[]; roles: Role[];
state: MemberState; state: MemberState;
} }
export interface UserCacheBustResult {
success: boolean;
clearedEntries: number;
bustedAt: string;
}

19
shared/types/rank.ts Normal file
View File

@@ -0,0 +1,19 @@
export type Rank = {
id: number
name: string
short_name: string
category: string
sortOrder: number
}
export interface PromotionSummary {
entry_day: Date;
}
export interface PromotionDetails {
promo_id: number;
member_id: number;
short_name: string;
created_by_id: number;
authorized_by_id: number;
}

View File

@@ -1,6 +1,14 @@
import { MemberLight } from "./member";
export interface Role { export interface Role {
id: number; id: number;
name: string; name: string;
color?: string; color?: string;
description?: string; description?: string;
} }
export interface RoleSummary {
id: number;
name: string;
color?: string;
}

7
shared/types/units.ts Normal file
View File

@@ -0,0 +1,7 @@
export interface Unit {
id: number;
name: string;
description?: string;
active: boolean;
color?: string;
}

View File

@@ -12,3 +12,25 @@ export function toDateTime(date: Date): string {
return `${year}-${month}-${day} ${hour}:${minute}:${second}`; return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
} }
export function toDateIgnoreZone(date: Date): string {
if (typeof date === 'string') {
date = new Date(date);
}
return date.toISOString().split('T')[0];
}
export function toDate(date: Date): string {
if (typeof date === 'string') {
date = new Date(date);
}
console.log(date);
// This produces a CST-local date because server runs in CST
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
let out = `${year}-${month}-${day}`;
console.log(out);
return out;
}

108
ui/package-lock.json generated
View File

@@ -35,7 +35,8 @@
"@types/node": "^24.2.1", "@types/node": "^24.2.1",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.0.6", "vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0" "vite-plugin-vue-devtools": "^8.0.0",
"vue-tsc": "^3.2.4"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
@@ -1884,6 +1885,35 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@volar/language-core": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz",
"integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.27"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz",
"integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.27",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz",
"integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.27",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue/babel-helper-vue-transform-on": { "node_modules/@vue/babel-helper-vue-transform-on": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz",
@@ -2083,6 +2113,22 @@
"rfdc": "^1.4.1" "rfdc": "^1.4.1"
} }
}, },
"node_modules/@vue/language-core": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.4.tgz",
"integrity": "sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.27",
"@vue/compiler-dom": "^3.5.0",
"@vue/shared": "^3.5.0",
"alien-signals": "^3.0.0",
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1",
"picomatch": "^4.0.2"
}
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.18", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.18.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.18.tgz",
@@ -2171,6 +2217,13 @@
"vue": "^3.5.0" "vue": "^3.5.0"
} }
}, },
"node_modules/alien-signals": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
"integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/ansis": { "node_modules/ansis": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz",
@@ -3123,6 +3176,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -3216,6 +3276,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3646,6 +3713,21 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.10.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
@@ -3932,6 +4014,13 @@
"vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0"
} }
}, },
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.18", "version": "3.5.18",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.18.tgz",
@@ -3974,6 +4063,23 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue-tsc": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
"integrity": "sha512-xj3YCvSLNDKt1iF9OcImWHhmYcihVu9p4b9s4PGR/qp6yhW+tZJaypGxHScRyOrdnHvaOeF+YkZOdKwbgGvp5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/typescript": "2.4.27",
"@vue/language-core": "3.2.4"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
},
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -39,6 +39,7 @@
"@types/node": "^24.2.1", "@types/node": "^24.2.1",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.0.6", "vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0" "vite-plugin-vue-devtools": "^8.0.0",
"vue-tsc": "^3.2.4"
} }
} }

View File

@@ -1,4 +1,4 @@
<script setup> <script setup lang="ts">
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import Button from './components/ui/button/Button.vue'; import Button from './components/ui/button/Button.vue';
import { useUserStore } from './stores/user'; import { useUserStore } from './stores/user';
@@ -18,7 +18,10 @@ function formatDate(dateStr) {
}); });
} }
//@ts-ignore
const environment = import.meta.env.VITE_ENVIRONMENT; const environment = import.meta.env.VITE_ENVIRONMENT;
//@ts-ignore
const version = import.meta.env.VITE_APPLICATION_VERSION;
</script> </script>
<template> <template>
@@ -28,15 +31,22 @@ const environment = import.meta.env.VITE_ENVIRONMENT;
background-position: center;"> background-position: center;">
<div class="sticky top-0 bg-background z-50"> <div class="sticky top-0 bg-background z-50">
<Navbar class="flex"></Navbar> <Navbar class="flex"></Navbar>
<Alert v-if="environment == 'dev'" class="m-2 mx-auto w-5xl" variant="info"> <Alert v-if="environment == 'dev'" class="m-2 mx-auto max-w-5xl" variant="info">
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto"> <AlertDescription class="flex flex-row items-center text-wrap gap-5 mx-auto">
<p>This is a development build of the application. Some features will be unavailable or unstable.</p> <p>Development environment (v{{ version }}). Features may be incomplete or unavailable.</p>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Alert v-if="userStore.user?.LOAs?.[0]" class="m-2 mx-auto w-5xl" variant="info"> <Alert v-if="userStore.user?.LOAs?.[0]" class="m-2 mx-auto max-w-5xl" variant="info">
<AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto"> <AlertDescription class="flex flex-row items-center text-nowrap gap-5 mx-auto">
<p>You are on LOA until <strong>{{ formatDate(userStore.user?.LOAs?.[0].extended_till || <p
userStore.user?.LOAs?.[0].end_date) }}</strong></p> v-if="new Date(userStore.user?.LOAs?.[0].extended_till || userStore.user?.LOAs?.[0].end_date) > new Date()">
LOA until <strong>{{ formatDate(userStore.user?.LOAs?.[0].extended_till ||
userStore.user?.LOAs?.[0].end_date) }}</strong>
</p>
<p v-else>
LOA expired on <strong>{{ formatDate(userStore.user?.LOAs?.[0].extended_till ||
userStore.user?.LOAs?.[0].end_date) }}</strong>
</p>
<Button variant="secondary" <Button variant="secondary"
@click="async () => { await cancelLOA(userStore.user.LOAs?.[0].id); userStore.loadUser(); }">End @click="async () => { await cancelLOA(userStore.user.LOAs?.[0].id); userStore.loadUser(); }">End
LOA</Button> LOA</Button>
@@ -47,5 +57,3 @@ const environment = import.meta.env.VITE_ENVIRONMENT;
<RouterView class="flex-1 min-h-0"></RouterView> <RouterView class="flex-1 min-h-0"></RouterView>
</div> </div>
</template> </template>
<style scoped></style>

View File

@@ -175,3 +175,20 @@ export async function extendLOA(id: number, to: Date) {
throw new Error("Could not extend LOA"); throw new Error("Could not extend LOA");
} }
} }
export async function adminExtendLOA(id: number, to: Date) {
const res = await fetch(`${addr}/loa/extendAdmin/${id}`, {
method: "POST",
credentials: 'include',
body: JSON.stringify({ to }),
headers: {
"Content-Type": "application/json",
}
});
if (res.ok) {
return
} else {
throw new Error("Could not extend LOA");
}
}

View File

@@ -1,4 +1,5 @@
import { memberSettings, Member, MemberLight } from "@shared/types/member"; import { Discharge } from "@shared/schemas/dischargeSchema";
import { memberSettings, Member, MemberLight, MemberCardDetails, PaginatedMembers, MemberState, UserCacheBustResult } from "@shared/types/member";
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
@@ -13,6 +14,33 @@ export async function getMembers(): Promise<Member[]> {
return response.json(); return response.json();
} }
export async function getMembersFiltered(params: {
page?: number;
pageSize?: number;
search?: string;
status?: string | MemberState;
unitId?: string;
} = {}): Promise<PaginatedMembers> {
// Construct the query string dynamically
const query = new URLSearchParams();
if (params.page) query.append('page', params.page.toString());
if (params.pageSize) query.append('pageSize', params.pageSize.toString());
if (params.search) query.append('search', params.search);
if (params.status && params.status !== 'all') query.append('status', String(params.status));
if (params.unitId && params.unitId !== 'all') query.append('unitId', params.unitId);
const response = await fetch(`${addr}/members/filtered?${query.toString()}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error("Failed to fetch members");
}
return response.json();
}
export async function getMemberSettings(): Promise<memberSettings> { export async function getMemberSettings(): Promise<memberSettings> {
const response = await fetch(`${addr}/members/settings`, { const response = await fetch(`${addr}/members/settings`, {
credentials: 'include' credentials: 'include'
@@ -38,8 +66,8 @@ export async function setMemberSettings(settings: memberSettings) {
return; return;
} }
export async function getAllLightMembers(): Promise<MemberLight[]> { export async function getAllLightMembers(activeOnly: boolean = true): Promise<MemberLight[]> {
const response = await fetch(`${addr}/members/lite`, { const response = await fetch(`${addr}/members/lite${activeOnly ? '?active=true' : '?active=false'}`, {
credentials: 'include', credentials: 'include',
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -71,7 +99,7 @@ export async function getLightMembers(ids: number[]): Promise<MemberLight[]> {
return response.json(); return response.json();
} }
export async function getFullMembers(ids: number[]): Promise<Member[]> { export async function getFullMembers(ids: number[]): Promise<MemberCardDetails[]> {
if (ids.length === 0) return []; if (ids.length === 0) return [];
@@ -88,3 +116,58 @@ export async function getFullMembers(ids: number[]): Promise<Member[]> {
} }
return response.json(); return response.json();
} }
/**
* Requests for the given member to be discharged
* @param data discharge packet
* @returns true on success
*/
export async function dischargeMember(data: Discharge): Promise<boolean> {
const response = await fetch(`${addr}/members/discharge`, {
credentials: 'include',
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error("Failed to discharge member");
}
return true;
}
export async function suspendMember(memberID: number): Promise<boolean> {
const response = await fetch(`${addr}/members/suspend?target=${memberID}`, {
credentials: 'include',
method: 'POST',
});
if (!response.ok) {
throw new Error("Failed to discharge member");
}
return true;
}
export async function unsuspendMember(memberID: number): Promise<boolean> {
const response = await fetch(`${addr}/members/unsuspend?target=${memberID}`, {
credentials: 'include',
method: 'POST',
});
if (!response.ok) {
throw new Error("Failed to discharge member");
}
return true;
}
export async function bustUserCache(): Promise<UserCacheBustResult> {
const response = await fetch(`${addr}/members/cache/user/bust`, {
credentials: 'include',
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to bust user cache');
}
return response.json();
}

View File

@@ -1,38 +1,73 @@
export type Rank = { import { BatchPromotion, BatchPromotionMember } from '@shared/schemas/promotionSchema';
id: number import { PagedData } from '@shared/types/pagination';
name: string import { PromotionDetails, PromotionSummary, Rank } from '@shared/types/rank'
short_name: string
sortOrder: number
}
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
export async function getRanks(): Promise<Rank[]> { export async function getAllRanks(): Promise<Rank[]> {
const res = await fetch(`${addr}/ranks`) const res = await fetch(`${addr}/ranks`, {
credentials: 'include'
})
if (res.ok) { if (res.ok) {
return res.json() return res.json()
} else { } else {
console.error("Something went wrong approving the application") console.error("Something went wrong approving the application")
} }
} }
// Placeholder: submit a rank change export async function submitRankChange(promo: BatchPromotion) {
export async function submitRankChange(member_id: number, rank_id: number, date: string): Promise<{ ok: boolean }> {
const res = await fetch(`${addr}/memberRanks`, { const res = await fetch(`${addr}/memberRanks`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ change: { member_id, rank_id, date } }), credentials: 'include',
body: JSON.stringify(promo),
}) })
if (res.ok) { if (res.ok) {
return { ok: true } return
} else { } else {
console.error("Failed to submit rank change") throw new Error("Failed to submit rank change: Server error");
return { ok: false } }
}
export async function getPromoHistory(page?: number, pageSize?: number): Promise<PagedData<PromotionSummary>> {
const params = new URLSearchParams();
if (page !== undefined) {
params.set("page", page.toString());
}
if (pageSize !== undefined) {
params.set("pageSize", pageSize.toString());
}
return fetch(`${addr}/memberRanks?${params}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: 'include',
}).then((res) => {
if (res.ok) {
return res.json();
} else {
return [];
}
});
}
export async function getPromotionsOnDay(day: Date): Promise<PromotionDetails[]> {
const res = await fetch(`${addr}/memberRanks/${day.toISOString()}`, {
credentials: 'include',
})
if (res.ok) {
return await res.json();
} else {
throw new Error("Failed to submit rank change: Server error");
} }
} }

View File

@@ -1,10 +1,5 @@
export type Role = { import { Member, MemberLight } from "@shared/types/member";
id: number; import { Role } from "@shared/types/roles";
name: string;
color: string;
description: string | null;
members: any[];
};
// @ts-ignore // @ts-ignore
const addr = import.meta.env.VITE_APIHOST; const addr = import.meta.env.VITE_APIHOST;
@@ -22,6 +17,30 @@ export async function getRoles(): Promise<Role[]> {
} }
} }
export async function getRoleDetails(id: number): Promise<Role> {
const res = await fetch(`${addr}/roles/${id}`, {
credentials: 'include',
})
if (res.ok) {
return res.json() as Promise<Role>;
} else {
throw new Error("Could not load role");
}
}
export async function getRoleMembers(id: number): Promise<MemberLight[]> {
const res = await fetch(`${addr}/roles/${id}/members`, {
credentials: 'include',
})
if (res.ok) {
return res.json();
} else {
throw new Error("Could not load members");
}
}
export async function createRole(name: string, color: string, description: string | null): Promise<Role | null> { export async function createRole(name: string, color: string, description: string | null): Promise<Role | null> {
const res = await fetch(`${addr}/roles`, { const res = await fetch(`${addr}/roles`, {
method: "POST", method: "POST",

26
ui/src/api/units.ts Normal file
View File

@@ -0,0 +1,26 @@
import { memberSettings, Member, MemberLight, MemberCardDetails } from "@shared/types/member";
import { Unit } from "@shared/types/units";
// @ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getUnits(): Promise<Unit[]> {
const response = await fetch(`${addr}/units`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error("Failed to fetch units");
}
return response.json();
}
export async function adminAssignUnit(member: number, unit: number, rank: number, reason: string) {
const response = await fetch(`${addr}/memberUnits/admin?memberId=${member}&unitId=${unit}&rankId=${rank}&reason=${encodeURIComponent(reason)}`, {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error("Failed to assign unit");
}
return;
}

View File

@@ -21,6 +21,7 @@ import { useAuth } from '@/composables/useAuth';
import { ArrowUpRight, CircleArrowOutUpRight } from 'lucide-vue-next'; import { ArrowUpRight, CircleArrowOutUpRight } from 'lucide-vue-next';
import DropdownMenuGroup from '../ui/dropdown-menu/DropdownMenuGroup.vue'; import DropdownMenuGroup from '../ui/dropdown-menu/DropdownMenuGroup.vue';
import DropdownMenuSeparator from '../ui/dropdown-menu/DropdownMenuSeparator.vue'; import DropdownMenuSeparator from '../ui/dropdown-menu/DropdownMenuSeparator.vue';
import { MemberState } from '@shared/types/member';
const userStore = useUserStore(); const userStore = useUserStore();
const auth = useAuth(); const auth = useAuth();
@@ -51,7 +52,7 @@ function blurAfter() {
<img src="/17RBN_Logo.png" class="w-10 h-10 object-contain"></img> <img src="/17RBN_Logo.png" class="w-10 h-10 object-contain"></img>
</RouterLink> </RouterLink>
<!-- Member navigation --> <!-- Member navigation -->
<div v-if="auth.accountStatus.value == 'member'" class="h-15 flex items-center justify-center"> <div v-if="auth.accountStatus.value == MemberState.Member" class="h-15 flex items-center justify-center">
<NavigationMenu> <NavigationMenu>
<NavigationMenuList class="gap-3"> <NavigationMenuList class="gap-3">
@@ -123,13 +124,13 @@ function blurAfter() {
</RouterLink> </RouterLink>
</NavigationMenuLink> </NavigationMenuLink>
<!-- <NavigationMenuLink <NavigationMenuLink
v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])" v-if="auth.hasAnyRole(['17th Administrator', '17th HQ', '17th Command'])"
as-child :class="navigationMenuTriggerStyle()"> as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/transfer" @click="blurAfter"> <RouterLink to="/administration/rankChange" @click="blurAfter">
Transfer Requests Promotions
</RouterLink> </RouterLink>
</NavigationMenuLink> --> </NavigationMenuLink>
<NavigationMenuLink v-if="auth.hasRole('Recruiter')" as-child <NavigationMenuLink v-if="auth.hasRole('Recruiter')" as-child
:class="navigationMenuTriggerStyle()"> :class="navigationMenuTriggerStyle()">
@@ -138,6 +139,12 @@ function blurAfter() {
</RouterLink> </RouterLink>
</NavigationMenuLink> </NavigationMenuLink>
<NavigationMenuItem as-child :class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/members" @click="blurAfter">
Member Management
</RouterLink>
</NavigationMenuItem>
<NavigationMenuLink v-if="auth.hasRole('17th Administrator')" as-child <NavigationMenuLink v-if="auth.hasRole('17th Administrator')" as-child
:class="navigationMenuTriggerStyle()"> :class="navigationMenuTriggerStyle()">
<RouterLink to="/administration/roles" @click="blurAfter"> <RouterLink to="/administration/roles" @click="blurAfter">
@@ -147,12 +154,11 @@ function blurAfter() {
</NavigationMenuContent> </NavigationMenuContent>
</NavigationMenuItem> </NavigationMenuItem>
<!-- <NavigationMenuItem as-child :class="navigationMenuTriggerStyle()"> <NavigationMenuItem v-if="auth.hasRole('Dev')">
<RouterLink to="/members" @click="blurAfter"> <NavigationMenuLink as-child :class="navigationMenuTriggerStyle()">
Members (debug) <RouterLink to="/developer" @click="blurAfter">Developer</RouterLink>
</RouterLink> </NavigationMenuLink>
</NavigationMenuItem> --> </NavigationMenuItem>
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
</div> </div>

View File

@@ -1,18 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'; import Button from '@/components/ui/button/Button.vue';
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'; import Checkbox from '@/components/ui/checkbox/Checkbox.vue';
import { useForm, Field as VeeField } from 'vee-validate';
import { import {
FormControl, Field,
FormDescription, FieldContent,
FormField, FieldDescription,
FormItem, FieldGroup,
FormLabel, FieldLabel,
FormMessage, } from '@/components/ui/field'
} from '@/components/ui/form' import FieldError from '@/components/ui/field/FieldError.vue';
import Input from '@/components/ui/input/Input.vue'; import Input from '@/components/ui/input/Input.vue';
import Textarea from '@/components/ui/textarea/Textarea.vue'; import Textarea from '@/components/ui/textarea/Textarea.vue';
import { toTypedSchema } from '@vee-validate/zod'; import { toTypedSchema } from '@vee-validate/zod';
import { Form } from 'vee-validate';
import { nextTick, onMounted, ref, watch } from 'vue'; import { nextTick, onMounted, ref, watch } from 'vue';
import * as z from 'zod'; import * as z from 'zod';
import DateInput from '../form/DateInput.vue'; import DateInput from '../form/DateInput.vue';
@@ -58,13 +58,22 @@ const fallbackInitials = {
const props = defineProps<{ const props = defineProps<{
readOnly: boolean, readOnly: boolean,
data: ApplicationData, data: ApplicationData | null,
}>() }>()
const emit = defineEmits(['submit']); const emit = defineEmits(['submit']);
const initialValues = ref<Record<string, unknown> | null>(null); const initialValues = ref<Record<string, unknown> | null>(null);
const { handleSubmit, resetForm, values } = useForm({
validationSchema: formSchema,
validateOnMount: false,
});
const submitForm = handleSubmit(async (val) => {
await onSubmit(val);
});
async function onSubmit(val: any) { async function onSubmit(val: any) {
emit('submit', val); emit('submit', val);
} }
@@ -80,6 +89,9 @@ onMounted(async () => {
initialValues.value = { ...fallbackInitials }; initialValues.value = { ...fallbackInitials };
} }
// apply the initial values to the vee-validate form
resetForm({ values: initialValues.value });
// CoCbox.value.innerHTML = await getCoC() // CoCbox.value.innerHTML = await getCoC()
CoCString.value = await getCoC(); CoCString.value = await getCoC();
}) })
@@ -103,221 +115,237 @@ function enforceExternalLinks() {
} }
watch(() => showCoC.value, async () => { watch(() => showCoC.value, async () => {
if (showCoC) { if (showCoC.value) {
await nextTick(); // wait for v-html to update await nextTick(); // wait for v-html to update
enforceExternalLinks(); enforceExternalLinks();
} }
}); });
function convertToAge(dob: string) {
if (dob === undefined) return "";
const [month, day, year] = dob.split('/').map(Number);
let dobDate = new Date(year, month - 1, day);
let out = Math.floor(
(Date.now() - dobDate.getTime()) / (1000 * 60 * 60 * 24 * 365.2425)
);
return Number.isNaN(out) ? "" : out;
}
</script> </script>
<template> <template>
<Form v-if="initialValues" :validation-schema="formSchema" :initial-values="initialValues" @submit="onSubmit" <form v-if="initialValues" @submit.prevent="submitForm" class="space-y-6">
class="space-y-6">
<!-- Age --> <!-- Age -->
<FormField name="dob" v-slot="{ value, handleChange }"> <VeeField name="dob" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>What is your date of birth?</FormLabel> <FieldLabel>What is your date of birth?</FieldLabel>
<FormControl> <FieldContent>
<DateInput :model-value="(value as string) ?? ''" :disabled="readOnly" @update:model-value="handleChange" /> <div class="flex items-center gap-10">
</FormControl> <DateInput :model-value="(field.value as string) ?? ''" :disabled="readOnly" @update:model-value="field.onChange" />
<div class="h-4"> <p v-if="props.readOnly" class="text-muted-foreground">Age: {{ convertToAge(field.value) }}</p>
<FormMessage class="text-destructive" />
</div> </div>
</FormItem> </FieldContent>
</FormField> <div class="h-4">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</Field>
</VeeField>
<!-- Name --> <!-- Name -->
<FormField name="name" v-slot="{ value, handleChange }"> <VeeField name="name" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>What name will you be going by within the community?</FormLabel> <FieldLabel>What name will you be going by within the community?</FieldLabel>
<FormDescription>This name must be consistent across platforms.</FormDescription> <FieldDescription>This name must be consistent across platforms.</FieldDescription>
<FormControl> <FieldContent>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input :model-value="field.value" @update:model-value="field.onChange" :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Playtime --> <!-- Playtime -->
<FormField name="playtime" v-slot="{ value, handleChange }"> <VeeField name="playtime" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>How long have you played Arma 3 for (in hours)?</FormLabel> <FieldLabel>How long have you played Arma 3 for (in hours)?</FieldLabel>
<FormControl> <FieldContent>
<Input type="number" :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input type="number" :model-value="field.value" @update:model-value="field.onChange" :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Hobbies --> <!-- Hobbies -->
<FormField name="hobbies" v-slot="{ value, handleChange }"> <VeeField name="hobbies" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>What hobbies do you like to participate in outside of gaming?</FormLabel> <FieldLabel>What hobbies do you like to participate in outside of gaming?</FieldLabel>
<FormControl> <FieldContent>
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange" <Textarea rows="4" class="resize-none" :model-value="field.value" @update:model-value="field.onChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Military (boolean) --> <!-- Military (boolean) -->
<FormField name="military" v-slot="{ value, handleChange }"> <VeeField name="military" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>Have you ever served in the military?</FormLabel> <FieldLabel>Have you ever served in the military?</FieldLabel>
<FormControl> <FieldContent>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Checkbox :model-value="field.value" @update:model-value="field.onChange" :disabled="readOnly" />
<span>Yes (checked) / No (unchecked)</span> <span>Yes (checked) / No (unchecked)</span>
</div> </div>
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Other communities (freeform) --> <!-- Other communities (freeform) -->
<FormField name="communities" v-slot="{ value, handleChange }"> <VeeField name="communities" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>Are you a part of any other communities? If so, which ones? If none, type "No"</FormLabel> <FieldLabel>Are you a part of any other communities? If so, which ones? If none, type "No"</FieldLabel>
<FormControl> <FieldContent>
<Input :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Input :model-value="field.value" @update:model-value="field.onChange" :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Why join --> <!-- Why join -->
<FormField name="joinReason" v-slot="{ value, handleChange }"> <VeeField name="joinReason" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>Why do you want to join our community?</FormLabel> <FieldLabel>Why do you want to join our community?</FieldLabel>
<FormControl> <FieldContent>
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange" <Textarea rows="4" class="resize-none" :model-value="field.value" @update:model-value="field.onChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Attraction to milsim --> <!-- Attraction to milsim -->
<FormField name="milsimAttraction" v-slot="{ value, handleChange }"> <VeeField name="milsimAttraction" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>What attracts you to the Arma 3 milsim playstyle?</FormLabel> <FieldLabel>What attracts you to the Arma 3 milsim playstyle?</FieldLabel>
<FormControl> <FieldContent>
<Textarea rows="4" class="resize-none" :model-value="value" @update:model-value="handleChange" <Textarea rows="4" class="resize-none" :model-value="field.value" @update:model-value="field.onChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Referral (freeform) --> <!-- Referral (freeform) -->
<FormField name="referral" v-slot="{ value, handleChange }"> <VeeField name="referral" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>Where did you hear about us? (If another member, who?)</FormLabel> <FieldLabel>Where did you hear about us? (If another member, who?)</FieldLabel>
<FormControl> <FieldContent>
<Input placeholder="e.g., Reddit / Member: Alice" :model-value="value" @update:model-value="handleChange" <Input placeholder="e.g., Reddit / Member: Alice" :model-value="field.value" @update:model-value="field.onChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Steam profile --> <!-- Steam profile -->
<FormField name="steamProfile" v-slot="{ value, handleChange }"> <VeeField name="steamProfile" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>Steam profile link</FormLabel> <FieldLabel>Steam profile link</FieldLabel>
<FormDescription> <FieldDescription>
Format: <code>https://steamcommunity.com/id/USER/</code> or Format: <code>https://steamcommunity.com/id/USER/</code> or
<code>https://steamcommunity.com/profiles/STEAMID64/</code> <code>https://steamcommunity.com/profiles/STEAMID64/</code>
</FormDescription> </FieldDescription>
<FormControl> <FieldContent>
<Input type="url" placeholder="https://steamcommunity.com/profiles/7656119..." :model-value="value" <Input type="url" placeholder="https://steamcommunity.com/profiles/7656119..." :model-value="field.value"
@update:model-value="handleChange" :disabled="readOnly" /> @update:model-value="field.onChange" :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Timezone --> <!-- Timezone -->
<FormField name="timezone" v-slot="{ value, handleChange }"> <VeeField name="timezone" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>What time zone are you in?</FormLabel> <FieldLabel>What time zone are you in?</FieldLabel>
<FormControl> <FieldContent>
<Input placeholder="e.g., AEST, EST, UTC+10" :model-value="value" @update:model-value="handleChange" <Input placeholder="e.g., AEST, EST, UTC+10" :model-value="field.value" @update:model-value="field.onChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Attendance (boolean) --> <!-- Attendance (boolean) -->
<FormField name="canAttendSaturday" v-slot="{ value, handleChange }"> <VeeField name="canAttendSaturday" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>Are you able to attend weekly operations Saturdays @ 7pm CST?</FormLabel> <FieldLabel>Are you able to attend weekly operations Saturdays @ 7pm CST?</FieldLabel>
<FormControl> <FieldContent>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox :model-value="value ?? false" @update:model-value="handleChange" :disabled="readOnly" /> <Checkbox :model-value="field.value ?? false" @update:model-value="field.onChange" :disabled="readOnly" />
<span>Yes (checked) / No (unchecked)</span> <span>Yes (checked) / No (unchecked)</span>
</div> </div>
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Interests / Playstyle (freeform) --> <!-- Interests / Playstyle (freeform) -->
<FormField name="interests" v-slot="{ value, handleChange }"> <VeeField name="interests" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>Which playstyles interest you?</FormLabel> <FieldLabel>Which playstyles interest you?</FieldLabel>
<FormControl> <FieldContent>
<Input placeholder="e.g., Rifleman; Medic; Pilot" :model-value="value" @update:model-value="handleChange" <Input placeholder="e.g., Rifleman; Medic; Pilot" :model-value="field.value" @update:model-value="field.onChange"
:disabled="readOnly" /> :disabled="readOnly" />
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<!-- Code of Conduct (boolean, field name kept as-is) --> <!-- Code of Conduct (boolean, field name kept as-is) -->
<FormField name="acknowledgeRules" v-slot="{ value, handleChange }"> <VeeField name="acknowledgeRules" v-slot="{ field, errors }">
<FormItem> <Field>
<FormLabel>Community Code of Conduct</FormLabel> <FieldLabel>Community Code of Conduct</FieldLabel>
<FormControl> <FieldContent>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox :model-value="value" @update:model-value="handleChange" :disabled="readOnly" /> <Checkbox :model-value="field.value" @update:model-value="field.onChange" :disabled="readOnly" />
<span>By checking this box, you accept the <Button variant="link" class="p-0 h-min" <span>By checking this box, you accept the <Button variant="link" class="p-0 h-min"
@click.prevent.stop="showCoC = true">Code of @click.prevent.stop="showCoC = true">Code of
Conduct</Button>.</span> Conduct</Button>.</span>
</div> </div>
</FormControl> </FieldContent>
<div class="h-4"> <div class="h-4">
<FormMessage class="text-destructive" /> <FieldError v-if="errors.length" :errors="errors" />
</div> </div>
</FormItem> </Field>
</FormField> </VeeField>
<div class="pt-2" v-if="!readOnly"> <div class="pt-2" v-if="!readOnly">
<Button type="submit" :disabled="readOnly">Submit Application</Button> <Button type="submit" :disabled="readOnly">Submit Application</Button>
@@ -334,5 +362,5 @@ watch(() => showCoC.value, async () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Form> </form>
</template> </template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CalendarEvent, CalendarSignup } from '@shared/types/calendar' import type { CalendarEvent, CalendarSignup } from '@shared/types/calendar'
import { CircleAlert, Clock4, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next'; import { CircleAlert, Clock4, EllipsisVertical, Link, MapPin, User, X } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import ButtonGroup from '../ui/button-group/ButtonGroup.vue'; import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
import Button from '../ui/button/Button.vue'; import Button from '../ui/button/Button.vue';
@@ -14,6 +14,8 @@ import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue';
import { Calendar } from 'lucide-vue-next'; import { Calendar } from 'lucide-vue-next';
import MemberCard from '../members/MemberCard.vue'; import MemberCard from '../members/MemberCard.vue';
import Spinner from '../ui/spinner/Spinner.vue'; import Spinner from '../ui/spinner/Spinner.vue';
import { CopyLink } from '@/lib/copyLink';
import { MemberState } from '@shared/types/member';
const route = useRoute(); const route = useRoute();
@@ -30,9 +32,14 @@ watch(
{ immediate: true } { immediate: true }
); );
watch(loaded, (value) => {
if (value) emit('load');
});
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void (e: 'close'): void
(e: 'reload'): void (e: 'reload'): void
(e: 'load'): void
(e: 'edit', event: CalendarEvent): void (e: 'edit', event: CalendarEvent): void
}>() }>()
@@ -80,7 +87,7 @@ async function setAttendance(state: CalendarAttendance) {
const canEditEvent = computed(() => { const canEditEvent = computed(() => {
if (!userStore.isLoggedIn) return false; if (!userStore.isLoggedIn) return false;
if (userStore.state !== 'member') return false; if (userStore.state !== MemberState.Member) return false;
if (userStore.user.member.member_id == activeEvent.value.creator_id) if (userStore.user.member.member_id == activeEvent.value.creator_id)
return true; return true;
}); });
@@ -178,17 +185,20 @@ defineExpose({ forceReload })
<template> <template>
<div v-if="loaded"> <div v-if="loaded">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3 h-14"> <div class="flex items-center justify-between gap-3 border-b border-border px-4 py-3 ">
<h2 class="text-lg font-semibold break-all"> <h2 class="text-lg font-semibold break-after-all">
{{ activeEvent?.name || 'Event' }} {{ activeEvent?.name || 'Event' }}
</h2> </h2>
<div class="flex gap-4 items-center"> <div class="flex gap-2 items-center">
<DropdownMenu v-if="canEditEvent"> <DropdownMenu v-if="canEditEvent">
<DropdownMenuTrigger> <DropdownMenuTrigger>
<button <Button variant="ghost" size="icon">
<EllipsisVertical class="size-5" />
</Button>
<!-- <button
class="inline-flex flex-none size-8 items-center justify-center cursor-pointer rounded-md hover:bg-muted/40 transition"> class="inline-flex flex-none size-8 items-center justify-center cursor-pointer rounded-md hover:bg-muted/40 transition">
<EllipsisVertical class="size-6" /> <EllipsisVertical class="size-6" />
</button> </button> -->
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem @click="emit('edit', activeEvent)"> <DropdownMenuItem @click="emit('edit', activeEvent)">
@@ -197,16 +207,22 @@ defineExpose({ forceReload })
<DropdownMenuItem v-if="activeEvent.cancelled" @click="setCancel(false)"> <DropdownMenuItem v-if="activeEvent.cancelled" @click="setCancel(false)">
Un-Cancel Un-Cancel
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem v-else @click="setCancel(true)"> <DropdownMenuItem v-else @click="setCancel(true)" class="text-destructive">
Cancel Cancel
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<button <Button variant="ghost" size="icon" @click="CopyLink()">
class="inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition cursor-pointer" <Link class="size-4" />
</Button>
<Button variant="ghost" size="icon" @click="emit('close')">
<X class="size-5" />
</Button>
<!-- <button
class="inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted transition cursor-pointer"
aria-label="Close" @click="emit('close')"> aria-label="Close" @click="emit('close')">
<X class="size-4" /> <X class="size-4" />
</button> </button> -->
</div> </div>
</div> </div>
<!-- Body --> <!-- Body -->
@@ -216,15 +232,15 @@ defineExpose({ forceReload })
<CircleAlert></CircleAlert> This event has been cancelled <CircleAlert></CircleAlert> This event has been cancelled
</div> </div>
</section> </section>
<section v-if="isPast && userStore.state === 'member'" class="w-full"> <section v-if="isPast && userStore.state === MemberState.Member" class="w-full">
<ButtonGroup class="flex w-full"> <ButtonGroup class="flex w-full justify-center">
<Button variant="outline" <Button variant="outline" class="flex-1"
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''" :class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Attending)">Going</Button> @click="setAttendance(CalendarAttendance.Attending)">Going</Button>
<Button variant="outline" <Button variant="outline" class="flex-1"
:class="myAttendance?.status === CalendarAttendance.Maybe ? 'border-2 !border-l-2 border-primary text-primary' : ''" :class="myAttendance?.status === CalendarAttendance.Maybe ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.Maybe)">Maybe</Button> @click="setAttendance(CalendarAttendance.Maybe)">Maybe</Button>
<Button variant="outline" <Button variant="outline" class="flex-1"
:class="myAttendance?.status === CalendarAttendance.NotAttending ? 'border-2 !border-l-2 border-primary text-primary' : ''" :class="myAttendance?.status === CalendarAttendance.NotAttending ? 'border-2 !border-l-2 border-primary text-primary' : ''"
@click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button> @click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button>
</ButtonGroup> </ButtonGroup>
@@ -249,7 +265,7 @@ defineExpose({ forceReload })
<!-- Description --> <!-- Description -->
<section class="space-y-2 w-full"> <section class="space-y-2 w-full">
<p class="text-lg font-semibold">Description</p> <p class="text-lg font-semibold">Description</p>
<p class="border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2 whitespace-pre-line"> <p class="border border-border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2 whitespace-pre-line">
{{ activeEvent.description }} {{ activeEvent.description }}
</p> </p>
</section> </section>
@@ -263,8 +279,8 @@ defineExpose({ forceReload })
<p>Declined <span class="ml-1">{{ attendanceStatusSummary.notAttending }}</span></p> <p>Declined <span class="ml-1">{{ attendanceStatusSummary.notAttending }}</span></p>
</div> --> </div> -->
</div> </div>
<div class="flex flex-col border bg-muted/50 rounded-lg min-h-24 my-2"> <div class="flex flex-col border border-border bg-muted/50 rounded-lg min-h-24 my-2">
<div class="flex w-full pt-2 border-b *:w-full *:text-center *:pb-1 *:cursor-pointer"> <div class="flex w-full pt-2 border-b border-border *:w-full *:text-center *:pb-1 *:cursor-pointer">
<label :class="attendanceTab === 'Alpha' ? 'border-b-3 border-foreground' : 'mb-[2px]'" <label :class="attendanceTab === 'Alpha' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@click="attendanceTab = 'Alpha'">Alpha {{ attendanceCountsByGroup.Alpha }}</label> @click="attendanceTab = 'Alpha'">Alpha {{ attendanceCountsByGroup.Alpha }}</label>
<label :class="attendanceTab === 'Echo' ? 'border-b-3 border-foreground' : 'mb-[2px]'" <label :class="attendanceTab === 'Echo' ? 'border-b-3 border-foreground' : 'mb-[2px]'"
@@ -273,14 +289,14 @@ defineExpose({ forceReload })
@click="attendanceTab = 'Other'">Other {{ attendanceCountsByGroup.Other }}</label> @click="attendanceTab = 'Other'">Other {{ attendanceCountsByGroup.Other }}</label>
</div> </div>
<div class="pb-1 min-h-48"> <div class="pb-1 min-h-48">
<div class="grid grid-cols-2 font-semibold text-muted-foreground border-b py-1 px-3 mb-2"> <div class="grid grid-cols-2 font-semibold text-muted-foreground border-b border-border py-1 px-3 mb-2">
<p>Name</p> <p>Name</p>
<p class="text-right">Status</p> <p class="text-right">Status</p>
</div> </div>
<div v-for="person in attendanceList" :key="person.member_id" <div v-for="person in attendanceList" :key="person.member_id"
class="grid grid-cols-2 py-1 *:px-3 hover:bg-muted"> class="grid grid-cols-3 py-1 *:px-3 hover:bg-muted">
<div> <div class="col-span-2">
<MemberCard :member-id="person.member_id"></MemberCard> <MemberCard :member-id="person.member_id"></MemberCard>
</div> </div>
<p :class="statusColor(person.status)" class="text-right"> <p :class="statusColor(person.status)" class="text-right">
@@ -292,13 +308,14 @@ defineExpose({ forceReload })
</section> </section>
</div> </div>
</div> </div>
<div v-else class="flex justify-center h-full items-center"> <div v-else class="relative flex justify-center items-center h-full">
<button <!-- Close button (top-right) -->
class="absolute top-4 right-4 inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition cursor-pointer z-50" <Button variant="ghost" size="icon" class="absolute top-2 right-2" @click="emit('close')">
aria-label="Close" @click="emit('close')"> <X class="size-5" />
<X class="size-4" /> </Button>
</button>
<Spinner class="size-8"></Spinner> <!-- Spinner (centered) -->
<Spinner class="size-8" />
</div> </div>
</template> </template>

View File

@@ -66,14 +66,19 @@ import { loaSchema } from '@shared/schemas/loaSchema'
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import Calendar from "../ui/calendar/Calendar.vue"; import Calendar from "../ui/calendar/Calendar.vue";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import Spinner from "../ui/spinner/Spinner.vue";
const { handleSubmit, values, resetForm } = useForm({ const { handleSubmit, values, resetForm } = useForm({
validationSchema: toTypedSchema(loaSchema), validationSchema: toTypedSchema(loaSchema),
}) })
const formSubmitted = ref(false); const formSubmitted = ref(false);
const submitting = ref(false);
const onSubmit = handleSubmit(async (values) => { const onSubmit = handleSubmit(async (values) => {
//catch double submit
if (submitting.value) return;
submitting.value = true;
const out: LOARequest = { const out: LOARequest = {
member_id: values.member_id, member_id: values.member_id,
start_date: values.start_date, start_date: values.start_date,
@@ -88,6 +93,7 @@ const onSubmit = handleSubmit(async (values) => {
userStore.loadUser(); userStore.loadUser();
} }
formSubmitted.value = true; formSubmitted.value = true;
submitting.value = false;
}) })
onMounted(async () => { onMounted(async () => {
@@ -325,7 +331,12 @@ const filteredMembers = computed(() => {
</VeeField> </VeeField>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<Button type="submit">Submit</Button> <Button type="submit" :disabled="submitting" class="w-35">
<span class="flex items-center gap-2" v-if="submitting">
<Spinner></Spinner> Submitting…
</span>
<span v-else>Submit</span>
</Button>
</div> </div>
</form> </form>
<div v-else class="flex flex-col gap-4 py-8 text-left"> <div v-else class="flex flex-col gap-4 py-8 text-left">

View File

@@ -16,7 +16,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { ChevronDown, ChevronUp, Ellipsis, X } from "lucide-vue-next"; import { ChevronDown, ChevronUp, Ellipsis, X } from "lucide-vue-next";
import { cancelLOA, extendLOA, getAllLOAs, getMyLOAs } from "@/api/loa"; import { adminExtendLOA, cancelLOA, extendLOA, getAllLOAs, getMyLOAs } from "@/api/loa";
import { onMounted, ref, computed } from "vue"; import { onMounted, ref, computed } from "vue";
import { LOARequest } from "@shared/types/loa"; import { LOARequest } from "@shared/types/loa";
import Dialog from "../ui/dialog/Dialog.vue"; import Dialog from "../ui/dialog/Dialog.vue";
@@ -75,16 +75,18 @@ function formatDate(date: Date): string {
}); });
} }
function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Overdue" | "Closed" { function loaStatus(loa: LOARequest): "Upcoming" | "Active" | "Extended" | "Overdue" | "Closed" {
if (loa.closed) return "Closed"; if (loa.closed) return "Closed";
const now = new Date(); const now = new Date();
const start = new Date(loa.start_date); const start = new Date(loa.start_date);
const end = new Date(loa.end_date); const end = new Date(loa.end_date);
const extension = new Date(loa.extended_till);
if (now < start) return "Upcoming"; if (now < start) return "Upcoming";
if (now >= start && now <= end) return "Active"; if (now >= start && (now <= end)) return "Active";
if (now > end) return "Overdue"; if (now >= start && (now <= extension)) return "Extended";
if (now > loa.extended_till || end) return "Overdue";
return "Overdue"; // fallback return "Overdue"; // fallback
} }
@@ -99,7 +101,6 @@ const targetLOA = ref<LOARequest | null>(null);
const extendTo = ref<CalendarDate | null>(null); const extendTo = ref<CalendarDate | null>(null);
const targetEnd = computed(() => { return targetLOA.value.extended_till ? targetLOA.value.extended_till : targetLOA.value.end_date }) const targetEnd = computed(() => { return targetLOA.value.extended_till ? targetLOA.value.extended_till : targetLOA.value.end_date })
function toCalendarDate(date: Date): CalendarDate { function toCalendarDate(date: Date): CalendarDate {
if (typeof date === 'string') if (typeof date === 'string')
date = new Date(date); date = new Date(date);
@@ -107,7 +108,11 @@ function toCalendarDate(date: Date): CalendarDate {
} }
async function commitExtend() { async function commitExtend() {
if (props.adminMode) {
await adminExtendLOA(targetLOA.value.id, extendTo.value.toDate(getLocalTimeZone()));
} else {
await extendLOA(targetLOA.value.id, extendTo.value.toDate(getLocalTimeZone())); await extendLOA(targetLOA.value.id, extendTo.value.toDate(getLocalTimeZone()));
}
isExtending.value = false; isExtending.value = false;
await loadLOAs(); await loadLOAs();
} }
@@ -143,7 +148,7 @@ function setPage(pagenum: number) {
<div class="flex gap-5"> <div class="flex gap-5">
<Calendar v-model="extendTo" class="rounded-md border shadow-sm w-min" layout="month-and-year" <Calendar v-model="extendTo" class="rounded-md border shadow-sm w-min" layout="month-and-year"
:min-value="toCalendarDate(targetEnd)" :min-value="toCalendarDate(targetEnd)"
:max-value="toCalendarDate(targetEnd).add({ years: 1 })" /> :max-value="props.adminMode ? toCalendarDate(targetEnd).add({ years: 1 }) : toCalendarDate(targetEnd).add({ months: 1 })" />
<div class="flex flex-col w-full gap-3 px-2"> <div class="flex flex-col w-full gap-3 px-2">
<p>Quick Options</p> <p>Quick Options</p>
<Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ days: 7 })">1 <Button variant="outline" @click="extendTo = toCalendarDate(targetEnd).add({ days: 7 })">1
@@ -191,20 +196,22 @@ function setPage(pagenum: number) {
<TableCell> <TableCell>
<Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-400">Upcoming</Badge> <Badge v-if="loaStatus(post) === 'Upcoming'" class="bg-blue-400">Upcoming</Badge>
<Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge> <Badge v-else-if="loaStatus(post) === 'Active'" class="bg-green-500">Active</Badge>
<Badge v-else-if="loaStatus(post) === 'Extended'" class="bg-green-500">Extended</Badge>
<Badge v-else-if="loaStatus(post) === 'Overdue'" class="bg-yellow-400">Overdue</Badge> <Badge v-else-if="loaStatus(post) === 'Overdue'" class="bg-yellow-400">Overdue</Badge>
<Badge v-else class="bg-gray-400">Ended</Badge> <Badge v-else class="bg-gray-400">Ended</Badge>
</TableCell> </TableCell>
<TableCell @click.stop="" class="text-right"> <TableCell @click.stop="" class="text-right">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer"> <DropdownMenuTrigger class="cursor-pointer">
<Button variant="ghost"> <Button variant="ghost" size="icon">
<Ellipsis class="size-6"></Ellipsis> <Ellipsis class="size-6"></Ellipsis>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem v-if="!post.closed && props.adminMode" <DropdownMenuItem v-if="!post.closed"
:disabled="post.extended_till !== null && !props.adminMode"
@click="isExtending = true; targetLOA = post"> @click="isExtending = true; targetLOA = post">
Extend {{ (post.extended_till !== null && !props.adminMode) ? 'Extend (Already Extended)' : 'Extend' }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem v-if="!post.closed" :variant="'destructive'" <DropdownMenuItem v-if="!post.closed" :variant="'destructive'"
@click="cancelAndReload(post.id)">{{ loaStatus(post) === 'Upcoming' ? @click="cancelAndReload(post.id)">{{ loaStatus(post) === 'Upcoming' ?
@@ -220,10 +227,11 @@ function setPage(pagenum: number) {
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button v-if="expanded === post.id" @click.stop="expanded = null" variant="ghost"> <Button v-if="expanded === post.id" @click.stop="expanded = null" size="icon"
variant="ghost">
<ChevronUp class="size-6" /> <ChevronUp class="size-6" />
</Button> </Button>
<Button v-else @click.stop="expanded = post.id" variant="ghost"> <Button v-else @click.stop="expanded = post.id" size="icon" variant="ghost">
<ChevronDown class="size-6" /> <ChevronDown class="size-6" />
</Button> </Button>
</TableCell> </TableCell>
@@ -231,21 +239,47 @@ function setPage(pagenum: number) {
<TableRow v-if="expanded === post.id" @mouseenter="hoverID = post.id" <TableRow v-if="expanded === post.id" @mouseenter="hoverID = post.id"
@mouseleave="hoverID = null" :class="{ 'bg-muted/50 border-t-0': hoverID === post.id }"> @mouseleave="hoverID = null" :class="{ 'bg-muted/50 border-t-0': hoverID === post.id }">
<TableCell :colspan="8" class="p-0"> <TableCell :colspan="8" class="p-0">
<div class="w-full p-3 mb-6 space-y-3"> <div class="w-full p-4 mb-6 space-y-4">
<div class="flex justify-between items-start gap-4">
<div class="flex-1">
<!-- Title -->
<p class="text-md font-semibold text-foreground">
Reason
</p>
<!-- Content --> <!-- Dates -->
<p <div class="grid grid-cols-3 gap-4 text-sm">
class="mt-1 text-md whitespace-pre-wrap leading-relaxed text-muted-foreground"> <div>
{{ post.reason }} <p class="text-muted-foreground">Start</p>
<p class="font-medium">
{{ formatDate(post.start_date) }}
</p>
</div>
<div>
<p class="text-muted-foreground">Original end</p>
<p class="font-medium">
{{ formatDate(post.end_date) }}
</p>
</div>
<div class="">
<p class="text-muted-foreground">Extended to</p>
<p class="font-medium text-foreground">
{{ post.extended_till ? formatDate(post.extended_till) : 'N/A' }}
</p> </p>
</div> </div>
</div> </div>
<!-- Reason -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<h4 class="text-sm font-semibold text-foreground">
Reason
</h4>
<Separator class="flex-1" />
</div>
<div
class="rounded-lg border bg-muted/40 px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
{{ post.reason || 'No reason provided.' }}
</div>
</div>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { Form, Field as VeeField } from 'vee-validate'
import * as z from 'zod'
import { X, AlertTriangle } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Field, FieldLabel, FieldError } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import MemberCard from './MemberCard.vue'
import { Member } from '@shared/types/member'
import { Discharge, dischargeSchema } from '@shared/schemas/dischargeSchema';
import { dischargeMember } from '@/api/member'
const props = defineProps<{
open: boolean
member: Member | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'discharged': [value: { data: Discharge }]
}>()
const formSchema = toTypedSchema(dischargeSchema);
async function onSubmit(values: z.infer<typeof dischargeSchema>) {
const data: Discharge = { userID: props.member.member_id, reason: values.reason }
console.log('Discharging member:', props.member?.member_id)
console.log('Discharge Data:', data)
await dischargeMember(data);
// Notify parent to refresh/close
emit('discharged', { data })
emit('update:open', false)
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<div class="flex items-center gap-2 mb-1">
<!-- <AlertTriangle class="size-5" /> -->
<DialogTitle>Discharge Member</DialogTitle>
</div>
<DialogDescription>
You are initiating the discharge process for <MemberCard :member-id="member.member_id"></MemberCard>
<!-- <span class="font-semibold text-foreground">{{ member }}</span> -->
</DialogDescription>
</DialogHeader>
<Form v-slot="{ handleSubmit }" as="" :validation-schema="formSchema">
<form id="dischargeForm" @submit="handleSubmit($event, onSubmit)" class="space-y-4 py-2">
<VeeField v-slot="{ componentField, errors }" name="reason">
<Field :data-invalid="!!errors.length">
<FieldLabel>Reason for Discharge</FieldLabel>
<Textarea placeholder="Retirement, inactivity, etc. " v-bind="componentField"
class="resize-none" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField>
<!-- <VeeField v-slot="{ componentField, errors }" name="effectiveDate">
<Field :data-invalid="!!errors.length">
<FieldLabel>Effective Date</FieldLabel>
<Input type="date" v-bind="componentField" />
<FieldError v-if="errors.length" :errors="errors" />
</Field>
</VeeField> -->
</form>
</Form>
<DialogFooter class="gap-2">
<Button variant="ghost" @click="emit('update:open', false)">
Cancel
</Button>
<Button type="submit" form="dischargeForm" variant="destructive">
Discharge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMemberDirectory } from '@/stores/memberDirectory'; import { useMemberDirectory } from '@/stores/memberDirectory';
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { Member, type MemberLight } from '@shared/types/member' import { Member, MemberCardDetails, type MemberLight } from '@shared/types/member'
import Popover from '../ui/popover/Popover.vue'; import Popover from '../ui/popover/Popover.vue';
import PopoverTrigger from '../ui/popover/PopoverTrigger.vue'; import PopoverTrigger from '../ui/popover/PopoverTrigger.vue';
import PopoverContent from '../ui/popover/PopoverContent.vue'; import PopoverContent from '../ui/popover/PopoverContent.vue';
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils.js'
import { watch } from 'vue'; import { watch } from 'vue';
import { format } from 'path'; import { format } from 'path';
import Spinner from '../ui/spinner/Spinner.vue'; import Spinner from '../ui/spinner/Spinner.vue';
import { Dot } from 'lucide-vue-next';
// Props // Props
@@ -21,7 +22,7 @@ const props = defineProps({
// Local state // Local state
const memberLight = ref<MemberLight | null>(null); const memberLight = ref<MemberLight | null>(null);
const memberFull = ref<Member | null>(null) const memberFull = ref<MemberCardDetails | null>(null)
const loadingFull = ref(false) const loadingFull = ref(false)
const membersStore = useMemberDirectory(); const membersStore = useMemberDirectory();
@@ -63,7 +64,7 @@ const hasFullInfo = computed(() => {
if (!memberFull.value) return false if (!memberFull.value) return false
// check if any field has a value // check if any field has a value
const { rank, unit, status } = memberFull.value const { rank, unit, status } = memberFull.value.member
return !!(rank || unit || status) return !!(rank || unit || status)
}) })
@@ -90,7 +91,7 @@ function formatDate(date: Date): string {
{{ displayName }} {{ displayName }}
</p> </p>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-72 p-0 overflow-hidden"> <PopoverContent class="w-80 p-0 overflow-hidden">
<!-- Loading --> <!-- Loading -->
<div v-if="loadingFull" class="p-4 text-sm text-muted-foreground mx-auto flex justify-center my-5"> <div v-if="loadingFull" class="p-4 text-sm text-muted-foreground mx-auto flex justify-center my-5">
<Spinner></Spinner> <Spinner></Spinner>
@@ -114,26 +115,36 @@ function formatDate(date: Date): string {
<div class="p-4 space-y-3 text-sm"> <div class="p-4 space-y-3 text-sm">
<!-- Full info --> <!-- Full info -->
<template v-if="hasFullInfo"> <template v-if="hasFullInfo">
<div v-if="memberFull.loa_until" <div v-if="memberFull.member.loa_until"
class=" rounded-md text-center bg-yellow-500/10 px-2 py-1 text-xs text-yellow-600"> class=" rounded-md text-center bg-yellow-500/10 px-2 py-1 text-xs text-yellow-600">
On Leave of Absence until {{ formatDate(memberFull.loa_until) }} On Leave of Absence until {{ formatDate(memberFull.member.loa_until) }}
</div> </div>
<div v-if="memberFull.rank" class="flex justify-between">
<div v-if="memberFull.member.rank" class="flex justify-between">
<span class="text-muted-foreground">Rank</span> <span class="text-muted-foreground">Rank</span>
<span class="font-medium">{{ memberFull.rank }}</span> <span class="font-medium">{{ memberFull.member.rank }}</span>
</div> </div>
<div v-if="memberFull.unit" class="flex justify-between"> <div v-if="memberFull.member.unit" class="flex justify-between items-center">
<span class="text-muted-foreground">Unit</span> <span class="text-muted-foreground">Unit</span>
<span class="font-medium">{{ memberFull.unit }}</span> <span class="font-medium flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: textColor }"></span>
{{ memberFull.member.unit }}
</span>
</div> </div>
<div v-if="memberFull.status" class="flex justify-between"> <div v-if="memberFull.member.status" class="flex justify-between">
<span class="text-muted-foreground">Status</span> <span class="text-muted-foreground">Status</span>
<span class="font-medium">{{ memberFull.status }}</span> <span class="font-medium">{{ memberFull.member.status }}</span>
</div> </div>
<div class="flex gap-2 flex-wrap mt-6">
<div v-for="role in memberFull.roles" class="border rounded-full px-3 text-nowrap">
{{ role.name }}
</div>
</div>
</template> </template>
<!-- No info fallback --> <!-- No info fallback -->

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { adminAssignUnit, getUnits } from '@/api/units'
import { getAllRanks } from '@/api/rank'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import MemberCard from './MemberCard.vue'
import type { Member } from '@shared/types/member'
import type { Rank } from '@shared/types/rank'
import type { Unit } from '@shared/types/units'
const props = defineProps<{
open: boolean
member: Member | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
transferred: [value: { memberId: number; unitId: number; rankId: number; reason: string }]
}>()
const units = ref<Unit[]>([])
const ranks = ref<Rank[]>([])
const loadingUnits = ref(false)
const loadingRanks = ref(false)
const submitting = ref(false)
const formError = ref('')
const selectedUnitId = ref('')
const selectedRankId = ref('')
const selectedReason = ref('transfer_request')
const customReason = ref('')
const reasonOptions = [
{ label: 'Transfer Request', value: 'transfer_request' },
{ label: 'Leadership Vote', value: 'leadership_vote' },
{ label: 'Appointment', value: 'appointment' },
{ label: 'Step Down', value: 'step_down' },
{ label: 'Custom', value: 'custom' },
]
const resolvedReason = computed(() => {
if (selectedReason.value === 'custom') {
return customReason.value.trim()
}
return selectedReason.value
})
const canSubmit = computed(() => {
return !!props.member && !!selectedUnitId.value && !!selectedRankId.value && !!resolvedReason.value
})
function resolveDefaultRankId(member: Member | null): string {
if (!member || !member.rank) {
return ''
}
const normalizedMemberRank = member.rank.trim().toLowerCase()
const matchedRank = ranks.value.find((rank) => {
return rank.name.trim().toLowerCase() === normalizedMemberRank
|| rank.short_name.trim().toLowerCase() === normalizedMemberRank
})
return matchedRank ? String(matchedRank.id) : ''
}
function resetForm() {
selectedUnitId.value = ''
selectedRankId.value = ''
selectedReason.value = 'transfer_request'
customReason.value = ''
formError.value = ''
}
async function loadUnits() {
loadingUnits.value = true
formError.value = ''
try {
units.value = await getUnits()
} catch {
formError.value = 'Failed to load units. Please try again.'
} finally {
loadingUnits.value = false
}
}
async function loadRanks() {
loadingRanks.value = true
formError.value = ''
try {
ranks.value = await getAllRanks()
selectedRankId.value = resolveDefaultRankId(props.member)
} catch {
formError.value = 'Failed to load ranks. Please try again.'
} finally {
loadingRanks.value = false
}
}
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
resetForm()
loadUnits()
loadRanks()
}
},
)
async function onSubmit() {
if (!props.member) {
return
}
if (!selectedUnitId.value) {
formError.value = 'Please select a target unit.'
return
}
if (!selectedRankId.value) {
formError.value = 'Please select a target rank.'
return
}
if (!resolvedReason.value) {
formError.value = 'Please select a reason or enter a custom reason.'
return
}
submitting.value = true
formError.value = ''
try {
const unitId = Number(selectedUnitId.value)
const rankId = Number(selectedRankId.value)
await adminAssignUnit(props.member.member_id, unitId, rankId, resolvedReason.value)
emit('transferred', {
memberId: props.member.member_id,
unitId,
rankId,
reason: resolvedReason.value,
})
emit('update:open', false)
} catch {
formError.value = 'Failed to transfer member. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Transfer Member</DialogTitle>
<DialogDescription>
Select a new unit assignment for
<MemberCard v-if="member" :member-id="member.member_id" />
</DialogDescription>
</DialogHeader>
<form id="transferForm" @submit.prevent="onSubmit" class="space-y-4 py-2">
<Field>
<FieldLabel>Target Unit</FieldLabel>
<Select v-model="selectedUnitId" :disabled="loadingUnits || submitting">
<SelectTrigger>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="unit in units" :key="unit.id" :value="String(unit.id)">
{{ unit.name }}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Target Rank</FieldLabel>
<Select v-model="selectedRankId" :disabled="loadingRanks || submitting">
<SelectTrigger>
<SelectValue placeholder="Select rank" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="rank in ranks" :key="rank.id" :value="String(rank.id)">
{{ rank.name }}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Reason</FieldLabel>
<Select v-model="selectedReason" :disabled="submitting">
<SelectTrigger>
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="reason in reasonOptions"
:key="reason.value"
:value="reason.value"
>
{{ reason.label }}
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field v-if="selectedReason === 'custom'">
<FieldLabel>Custom Reason</FieldLabel>
<Input
v-model="customReason"
:disabled="submitting"
placeholder="Enter custom transfer reason"
/>
</Field>
<FieldError v-if="formError" :errors="[formError]" />
</form>
<DialogFooter class="gap-2">
<Button variant="ghost" @click="emit('update:open', false)">
Cancel
</Button>
<Button type="submit" form="transferForm" :disabled="!canSubmit || loadingUnits || loadingRanks || submitting">
{{ submitting ? 'Transferring...' : 'Transfer Member' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
import { batchPromotionSchema } from '@shared/schemas/promotionSchema';
import { useForm, Field as VeeField, FieldArray as VeeFieldArray } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import FieldSet from '../ui/field/FieldSet.vue';
import FieldLegend from '../ui/field/FieldLegend.vue';
import FieldDescription from '../ui/field/FieldDescription.vue';
import FieldGroup from '../ui/field/FieldGroup.vue';
import Combobox from '../ui/combobox/Combobox.vue';
import ComboboxAnchor from '../ui/combobox/ComboboxAnchor.vue';
import ComboboxInput from '../ui/combobox/ComboboxInput.vue';
import ComboboxList from '../ui/combobox/ComboboxList.vue';
import ComboboxEmpty from '../ui/combobox/ComboboxEmpty.vue';
import ComboboxGroup from '../ui/combobox/ComboboxGroup.vue';
import ComboboxItem from '../ui/combobox/ComboboxItem.vue';
import ComboboxItemIndicator from '../ui/combobox/ComboboxItemIndicator.vue';
import { computed, onMounted, ref } from 'vue';
import { MemberLight } from '@shared/types/member';
import { Check, Plus, X } from 'lucide-vue-next';
import Button from '../ui/button/Button.vue';
import FieldError from '../ui/field/FieldError.vue';
import { getAllLightMembers } from '@/api/member';
import { Rank } from '@shared/types/rank';
import { getAllRanks, submitRankChange } from '@/api/rank';
import { error } from 'console';
import Input from '../ui/input/Input.vue';
import Field from '../ui/field/Field.vue';
const { handleSubmit, errors, values, resetForm, setFieldValue, submitCount } = useForm({
validationSchema: toTypedSchema(batchPromotionSchema),
validateOnMount: false,
})
const submitting = ref(false);
const submitForm = handleSubmit(
async (vals) => {
if (submitting.value) return;
submitting.value = true;
try {
let output = vals;
output.promotions.map(p => p.start_date = new Date(p.start_date).toISOString())
await submitRankChange(output);
formSubmitted.value = true;
emit("submitted");
} catch (error) {
submitError.value = error;
console.error(error);
} finally {
submitting.value = false;
}
}
);
const emit = defineEmits<{
submitted: [void]
}>();
const submitError = ref<string>(null);
const formSubmitted = ref(false);
const allmembers = ref<MemberLight[]>([]);
const allRanks = ref<Rank[]>([]);
const memberById = computed(() => {
const map = new Map<number, MemberLight>();
for (const m of allmembers.value) {
map.set(m.id, m);
}
return map;
});
const rankById = computed(() => {
const map = new Map<number, Rank>();
for (const r of allRanks.value) {
map.set(r.id, r);
}
return map;
});
const memberSearch = ref('');
const rankSearch = ref('');
const filteredMembers = computed(() => {
const q = memberSearch?.value?.toLowerCase() ?? ""
const results: MemberLight[] = []
for (const m of allmembers.value ?? []) {
if (!q || (m.displayName || m.username).toLowerCase().includes(q)) {
results.push(m)
if (results.length >= 50) break
}
}
return results
})
const filteredRanks = computed(() =>
filterRanks(rankSearch.value)
);
function filterRanks(query: string): Rank[] {
if (!query) return allRanks.value;
const q = query.toLowerCase();
return allRanks.value.filter(r =>
r.name.toLowerCase().includes(q) ||
r.short_name.toLowerCase().includes(q)
);
}
onMounted(async () => {
allmembers.value = await getAllLightMembers()
allRanks.value = await getAllRanks();
})
function setAllToday() {
const today = () => {
const d = new Date()
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
values.promotions?.forEach((_, index) => {
// @ts-ignore
setFieldValue(`promotions[${index}].start_date`, today())
})
}
</script>
<template>
<div class="w-xl">
<form v-if="!formSubmitted" id="trainingForm" @submit.prevent="submitForm"
class="w-full min-w-0 flex flex-col gap-4">
<div>
<FieldLegend class="scroll-m-20 text-2xl font-semibold tracking-tight">
Promotion Form
</FieldLegend>
</div>
<VeeFieldArray name="promotions" v-slot="{ fields, push, remove }">
<FieldSet class="w-full min-w-0">
<div class="flex flex-col gap-2">
<!-- TABLE SHELL -->
<div class="">
<FieldGroup class="">
<!-- HEADER -->
<div class="grid grid-cols-[200px_200px_150px_1fr_auto]
gap-3 px-1 -mb-4
text-sm font-medium text-muted-foreground">
<div>Member</div>
<div>Rank</div>
<div class="flex justify-between">
<p>Date</p><button @click.prevent.stop="setAllToday"
class="cursor-pointer border-1 rounded-full px-3 hover:bg-secondary hover:border-accent">Today</button>
</div>
<div></div>
<div></div>
</div>
<!-- BODY -->
<div class="flex flex-col gap-2">
<div v-for="(row, index) in fields" :key="row.key" class="grid grid-cols-[200px_200px_150px_1fr_auto]
gap-3 items-start">
<!-- Member -->
<VeeField :name="`promotions[${index}].member_id`" v-slot="{ field, errors }">
<div class="flex flex-col min-w-0">
<Combobox :model-value="field.value"
@update:model-value="field.onChange" :ignore-filter="true">
<ComboboxAnchor>
<ComboboxInput class="w-full pl-3" placeholder="Search members…"
:display-value="id =>
memberById.get(id)?.displayName ||
memberById.get(id)?.username
" @input="memberSearch = $event.target.value" />
</ComboboxAnchor>
<ComboboxList>
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxGroup>
<div
class="max-h-80 overflow-y-auto min-w-[12rem] scrollbar-themed">
<ComboboxItem v-for="member in filteredMembers"
:key="member.id" :value="member.id">
{{ member.displayName || member.username }}
<ComboboxItemIndicator>
<Check />
</ComboboxItemIndicator>
</ComboboxItem>
</div>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-5">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</div>
</VeeField>
<!-- Rank -->
<VeeField :name="`promotions[${index}].rank_id`" v-slot="{ field, errors }">
<div class="flex flex-col min-w-0">
<Combobox :model-value="field.value"
@update:model-value="field.onChange" :ignore-filter="true">
<ComboboxAnchor>
<ComboboxInput class="w-full pl-3" placeholder="Select rank"
:display-value="id => rankById.get(id)?.name"
@input="rankSearch = $event.target.value" />
</ComboboxAnchor>
<ComboboxList>
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxGroup>
<div
class="max-h-80 overflow-y-auto min-w-[12rem] scrollbar-themed">
<ComboboxItem v-for="rank in filteredRanks"
:key="rank.id" :value="rank.id">
{{ rank.name }}
<ComboboxItemIndicator>
<Check />
</ComboboxItemIndicator>
</ComboboxItem>
</div>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-5">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</div>
</VeeField>
<!-- Date -->
<VeeField :name="`promotions[${index}].start_date`" v-slot="{ field, errors }">
<Field>
<div>
<Input type="date" v-bind="field" />
<div class="h-5">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</div>
</Field>
</VeeField>
<!-- Remove -->
<div class="flex justify-end">
<Button type="button" variant="ghost" size="icon" @click="remove(index)">
<X />
</Button>
</div>
</div>
</div>
</FieldGroup>
</div>
<Button type="button" @click="push({})" class="w-full" variant="outline">
<Plus /> Member
</Button>
</div>
</FieldSet>
</VeeFieldArray>
<div class="flex justify-between items-start">
<VeeField name="approver" v-slot="{ field, errors }">
<div class="flex flex-col min-w-0 gap-2">
<p>Approved By</p>
<Combobox :model-value="field.value" @update:model-value="field.onChange" :ignore-filter="true">
<ComboboxAnchor>
<ComboboxInput class="w-full pl-3" placeholder="Search members" :display-value="id =>
memberById.get(id)?.displayName ||
memberById.get(id)?.username
" @input="memberSearch = $event.target.value" />
</ComboboxAnchor>
<ComboboxList>
<ComboboxEmpty>No results</ComboboxEmpty>
<ComboboxGroup>
<div class="max-h-80 overflow-y-auto min-w-[12rem] scrollbar-themed">
<ComboboxItem v-for="member in filteredMembers" :key="member.id"
:value="member.id">
{{ member.displayName || member.username }}
<ComboboxItemIndicator>
<Check />
</ComboboxItemIndicator>
</ComboboxItem>
</div>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<div class="h-5">
<FieldError v-if="errors.length" :errors="errors" />
</div>
</div>
</VeeField>
<div class="flex flex-col items-end gap-2">
<div class="h-6" />
<Button type="submit" form="trainingForm" :disabled="submitting" class="w-35">
<span class="flex items-center gap-2" v-if="submitting">
<Spinner></Spinner> Submitting…
</span>
<span v-else>Submit</span>
</Button>
<p v-if="submitError" class="text-destructive">{{ submitError }}</p>
<div v-else class="h-6 flex justify-end">
<p v-if="submitCount > 0 && errors.promotions && typeof errors.promotions === 'string'"
class="text-sm text-red-500">
{{ errors.promotions }}
</p>
</div>
</div>
</div>
</form>
<div v-else>
<div class="flex flex-col max-w-sm justify-center gap-4 py-24 mx-auto">
<div class="text-left">
<h2 class="text-2xl font-semibold mb-2">Successfully Submitted</h2>
<p class="text-muted-foreground">Your promotions have been recorded.</p>
</div>
<Button @click="() => { formSubmitted = false; resetForm(); }" variant="secondary">
Submit Another
</Button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { pagination } from '@shared/types/pagination';
import { PromotionSummary } from '@shared/types/rank';
import { onMounted, ref } from 'vue';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { ChevronDown, ChevronUp } from 'lucide-vue-next';
import Button from '../ui/button/Button.vue';
import { getPromoHistory } from '@/api/rank';
import Spinner from '../ui/spinner/Spinner.vue';
import PromotionListDay from './promotionListDay.vue';
const loading = ref(true);
const batchList = ref<PromotionSummary[]>();
onMounted(async () => {
await loadHistory();
loading.value = false;
})
async function loadHistory() {
let d = await getPromoHistory(pageNum.value, pageSize.value);
batchList.value = d.data;
pageData.value = d.pagination;
}
function refresh() {
loadHistory();
promoDayDetails.value?.[0].loadData();
}
defineExpose({
refresh
})
const promoDayDetails = ref<InstanceType<typeof PromotionListDay>[]>(null)
const expanded = ref<number | null>(null);
const hoverID = ref<number | null>(null);
const pageNum = ref<number>(1);
const pageData = ref<pagination>();
const pageSize = ref<number>(15)
const pageSizeOptions = [10, 15, 30]
function setPageSize(size: number) {
pageSize.value = size
pageNum.value = 1;
loadHistory();
}
function setPage(pagenum: number) {
pageNum.value = pagenum;
loadHistory();
}
function formatDate(date: Date): string {
if (!date) return "";
date = typeof date === 'string' ? new Date(date) : date;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
<template>
<div class="flex flex-col max-w-7xl w-full">
<div class="w-full mx-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="(batch, index) in batchList" :key="index">
<TableRow class="hover:bg-muted/50 cursor-pointer" @click="expanded = index"
@mouseenter="hoverID = index" @mouseleave="hoverID = null" :class="{
'border-b-0': expanded === index,
'bg-muted/50': hoverID === index
}">
<TableCell class="font-medium">
{{ formatDate(new Date(batch.entry_day)) }}
</TableCell>
<TableCell class="text-right">
<Button v-if="expanded === index" @click.stop="expanded = null" size="icon"
variant="ghost">
<ChevronUp class="size-6" />
</Button>
<Button v-else @click.stop="expanded = index" size="icon" variant="ghost">
<ChevronDown class="size-6" />
</Button>
</TableCell>
</TableRow>
<TableRow v-if="expanded === index" @mouseenter="hoverID = index" @mouseleave="hoverID = null"
:class="{ 'bg-muted/50 border-t-0': hoverID === index }">
<TableCell :colspan="8" class="p-0">
<div class="w-full p-2 mb-6 space-y-3">
<PromotionListDay ref="promoDayDetails" :date="new Date(batch.entry_day)">
</PromotionListDay>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<div v-if="loading" class="w-full flex mx-auto justify-center my-15">
<Spinner class="size-7"></Spinner>
</div>
<div class="mt-5 flex justify-between mb-20">
<div></div>
<Pagination v-slot="{ page }" :items-per-page="pageData?.pageSize || 10" :total="pageData?.total || 10"
:default-page="2" :page="pageNum" @update:page="setPage">
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, index) in items" :key="index">
<PaginationItem v-if="item.type === 'page'" :value="item.value"
:is-active="item.value === page">
{{ item.value }}
</PaginationItem>
</template>
<PaginationEllipsis :index="4" />
<PaginationNext />
</PaginationContent>
</Pagination>
<div class="flex items-center gap-3 text-sm">
<p class="text-muted-foreground text-nowrap">Per page:</p>
<button v-for="size in pageSizeOptions" :key="size" @click="setPageSize(size)"
class="px-2 py-1 rounded transition-colors" :class="{
'bg-muted font-semibold': pageSize === size,
'hover:bg-muted/50': pageSize !== size
}">
{{ size }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { getPromotionsOnDay } from '@/api/rank';
import { onMounted, ref } from 'vue';
import MemberCard from '../members/MemberCard.vue';
import Spinner from '../ui/spinner/Spinner.vue';
import { PromotionDetails } from '@shared/types/rank';
const props = defineProps<{
date: Date
}>()
const promoList = ref<PromotionDetails[]>();
const loading = ref(true);
async function loadData() {
promoList.value = await getPromotionsOnDay(props.date);
}
defineExpose({
loadData
})
onMounted(async () => {
// promoList.value = await getPromotionsOnDay(props.date);
await loadData();
loading.value = false;
})
</script>
<template>
<div class="overflow-x-auto">
<table v-if="!loading" class="min-w-full text-left border-collapse">
<thead>
<tr class="border-b-2 border-gray-200 bg-white/10">
<th class="px-4 py-3 text-sm font-semibold">Member</th>
<th class="px-4 py-3 text-sm font-semibold">Rank</th>
<th class="px-4 py-3 text-sm font-semibold">Approved By</th>
<th class="px-4 py-3 text-sm font-semibold text-right">Submitted By</th>
</tr>
</thead>
<tbody>
<tr v-for="p in promoList" :key="p.member_id" class="border-b transition-colors">
<td class="px-2 py-2">
<MemberCard :member-id="p.member_id" />
</td>
<td class="px-4 py-2 text-sm">
{{ p.short_name }}
</td>
<td class="px-2 py-2 text-sm text-right">
<MemberCard :member-id="p.authorized_by_id" />
</td>
<td class="px-2 py-2 text-sm text-right">
<MemberCard :member-id="p.created_by_id" />
</td>
</tr>
</tbody>
</table>
<div v-else class="overflow-hidden mx-auto flex w-full">
<Spinner class="size-7"></Spinner>
</div>
</div>
</template>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { addMemberToRole, removeMemberFromRole } from '@/api/roles';
import { MemberLight } from '@shared/types/member';
import { Role } from '@shared/types/roles';
import { computed, ref } from 'vue';
import Dialog from '../ui/dialog/Dialog.vue'
import DialogContent from '../ui/dialog/DialogContent.vue'
import DialogHeader from '../ui/dialog/DialogHeader.vue'
import DialogTitle from '../ui/dialog/DialogTitle.vue'
import DialogDescription from '../ui/dialog/DialogDescription.vue'
import DialogFooter from '../ui/dialog/DialogFooter.vue'
import Button from '../ui/button/Button.vue';
import InputGroup from '../ui/input-group/InputGroup.vue';
import InputGroupAddon from '../ui/input-group/InputGroupAddon.vue';
import { SearchIcon } from 'lucide-vue-next';
import Spinner from '../ui/spinner/Spinner.vue';
const props = defineProps<{
allMembers: MemberLight[],
role: Role
}>()
const emit = defineEmits(['submit'])
const showAddMemberDialog = ref(false)
const memberToAdd = ref<MemberLight | null>(null)
const searchQuery = ref('')
const filteredMembers = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return props.allMembers
return props.allMembers.filter(m =>
m.displayName?.toLowerCase().includes(q) ||
m.username?.toLowerCase().includes(q)
)
})
defineExpose({
openDialog,
})
function openDialog() {
showAddMemberDialog.value = true;
}
const submitting = ref(false);
async function handleAddMember() {
//catch double submit
if (submitting.value) return;
submitting.value = true;
//guard
if (memberToAdd.value == null)
return;
await addMemberToRole(memberToAdd.value.id, props.role.id);
emit('submit');
showAddMemberDialog.value = false;
submitting.value = false;
}
</script>
<template>
<Dialog v-model:open="showAddMemberDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add member to {{ props.role?.name }}</DialogTitle>
<DialogDescription>
Search for a member to add to this group.
</DialogDescription>
</DialogHeader>
<!-- Search -->
<InputGroup>
<InputGroupAddon>
<SearchIcon class="h-4 w-4 text-muted-foreground" />
</InputGroupAddon>
<input v-model="searchQuery" type="text" placeholder="Search members…"
class="flex-1 bg-transparent outline-none text-sm" />
</InputGroup>
<!-- Results -->
<div class="mt-3 h-64 overflow-y-auto scrollbar-themed rounded-md border">
<ul class="divide-y">
<li v-for="member in filteredMembers" :key="member.id" @click="memberToAdd = member"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-muted"
:class="memberToAdd?.id === member.id && 'bg-muted'">
<span>{{ member.displayName || member.username }}</span>
<span v-if="memberToAdd?.id === member.id" class="text-xs text-muted-foreground">
Selected
</span>
</li>
</ul>
</div>
<DialogFooter>
<Button variant="secondary" @click="showAddMemberDialog = false">
Cancel
</Button>
<Button :disabled="!memberToAdd || submitting" @click="handleAddMember">
<span class="flex items-center gap-2" v-if="submitting">
<Spinner></Spinner> Add
</span>
<span v-else>Add</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { addMemberToRole, getRoleDetails, getRoleMembers, removeMemberFromRole } from '@/api/roles'
import type { MemberLight } from '@shared/types/member'
import type { Role } from '@shared/types/roles'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import Separator from '@/components/ui/separator/Separator.vue'
import { Plus, SearchIcon, X } from 'lucide-vue-next'
import MemberCard from '../members/MemberCard.vue'
import InputGroup from '../ui/input-group/InputGroup.vue'
import InputGroupAddon from '../ui/input-group/InputGroupAddon.vue'
import AddMember from './addMember.vue'
import Spinner from '../ui/spinner/Spinner.vue'
const route = useRoute()
const roleData = ref<Role | null>(null)
const roleMembers = ref<MemberLight[]>([])
const loading = ref(true)
async function loadRole() {
const id = Number(route.params.id)
roleData.value = await getRoleDetails(id)
roleMembers.value = await getRoleMembers(id)
loading.value = false
}
const searchQuery = ref('')
const roleMembersFiltered = computed(() => {
if (!searchQuery.value) return roleMembers.value
const query = searchQuery.value.toLowerCase()
return roleMembers.value.filter(member =>
member.displayName?.toLowerCase().includes(query) ||
member.username?.toLowerCase().includes(query)
)
})
const props = defineProps<{
allMembers: MemberLight[]
}>()
const availableMembers = computed(() =>
props.allMembers.filter(
m => !roleMembers.value.some(rm => rm.id === m.id)
)
)
async function handleRemoveMember(memberId: number) {
await removeMemberFromRole(memberId, Number(route.params.id));
await loadRole();
}
const addMemberRef = ref<InstanceType<typeof AddMember> | null>(null)
onMounted(loadRole)
watch(() => route.params.id, loadRole)
</script>
<template>
<AddMember ref="addMemberRef" :all-members="availableMembers" :role="roleData" @submit="loadRole"></AddMember>
<div class="h-full px-6 py-2">
<!-- Loading -->
<div v-if="loading" class="h-full flex items-center justify-center text-muted-foreground">
<Spinner class="size-8" />
</div>
<!-- No role selected -->
<div v-else-if="!roleData" class="text-muted-foreground">
Select a group to view details
</div>
<!-- Role details -->
<div v-else class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="space-y-1">
<div class="flex items-center gap-3">
<span class="h-3 w-3 rounded-full" :style="{ backgroundColor: roleData.color }" />
<h2 class="text-2xl font-semibold tracking-tight">
{{ roleData.name }}
</h2>
</div>
<p class="text-sm text-muted-foreground">
{{ roleData.description || 'No description provided.' }}
</p>
</div>
<!-- <Button variant="ghost" size="sm" class="text-destructive">
Delete
</Button> -->
</div>
<Separator />
<!-- Members -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">
Members ({{ roleMembers.length }})
</h3>
<div class="flex items-center gap-5">
<InputGroup class="w-64">
<InputGroupAddon>
<SearchIcon class="h-4 w-4 text-muted-foreground" />
</InputGroupAddon>
<input v-model="searchQuery" type="text" placeholder="Search members…"
class="flex-1 bg-transparent outline-none text-sm" />
</InputGroup>
<Button variant="secondary" @click="addMemberRef.openDialog()">
<Plus class="mr-2 h-4 w-4" />
Add Member
</Button>
</div>
</div>
<!-- Empty state -->
<div v-if="roleMembers.length === 0" class="text-sm text-muted-foreground py-6 text-center">
No members in this group yet.
</div>
<div class="overflow-y-auto max-h-[55dvh] pr-1 scrollbar-themed">
<ul class="space-y-1">
<li v-for="member in roleMembersFiltered" :key="member.id"
class="flex items-center justify-between rounded-md px-3 py-2 hover:bg-muted/50">
<MemberCard :member-id="member.id" />
<Button variant="ghost" size="icon" class="text-muted-foreground"
@click="handleRemoveMember(member.id)">
<X class="h-4 w-4" />
</Button>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
defineProps<{
open: boolean,
message: string
}>();
</script>
<template>
<div class="relative inline-flex items-center group w-min">
<slot></slot>
<div v-if="open" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
{{ message }}
</div>
</div>
</template>

View File

@@ -25,6 +25,8 @@ import Popover from "@/components/ui/popover/Popover.vue";
import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue"; import PopoverTrigger from "@/components/ui/popover/PopoverTrigger.vue";
import PopoverContent from "@/components/ui/popover/PopoverContent.vue"; import PopoverContent from "@/components/ui/popover/PopoverContent.vue";
import Combobox from '../ui/combobox/Combobox.vue' import Combobox from '../ui/combobox/Combobox.vue'
import Tooltip from '../tooltip/Tooltip.vue'
import Spinner from '../ui/spinner/Spinner.vue'
const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({ const { handleSubmit, resetForm, errors, values, setFieldValue } = useForm({
@@ -50,7 +52,9 @@ watch(() => values.course_id, (newCourseId, oldCourseId) => {
if (!oldCourseId) return; if (!oldCourseId) return;
values.attendees.forEach((a, index) => { values.attendees.forEach((a, index) => {
// @ts-ignore
setFieldValue(`attendees[${index}].passed_bookwork`, false); setFieldValue(`attendees[${index}].passed_bookwork`, false);
// @ts-ignore
setFieldValue(`attendees[${index}].passed_qual`, false); setFieldValue(`attendees[${index}].passed_qual`, false);
}); });
}); });
@@ -64,19 +68,24 @@ function toMySQLDateTime(date: Date): string {
.replace("T", " ") + "000"; // becomes → 2025-11-19 00:00:00.000000 .replace("T", " ") + "000"; // becomes → 2025-11-19 00:00:00.000000
} }
const submitting = ref(false);
function onSubmit(vals) { async function onSubmit(vals) {
//catch double submit
if (submitting.value) return;
submitting.value = true;
try { try {
const clean: CourseEventDetails = { const clean: CourseEventDetails = {
...vals, ...vals,
event_date: new Date(vals.event_date), event_date: new Date(vals.event_date),
} }
postTrainingReport(clean).then((newID) => { await postTrainingReport(clean).then((newID) => {
emit("submit", newID); emit("submit", newID);
}); });
} catch (err) { } catch (err) {
console.error("There was an error submitting the training report", err); console.error("There was an error submitting the training report", err);
} finally {
submitting.value = false;
} }
} }
@@ -326,22 +335,13 @@ const filteredMembers = computed(() => {
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_bookwork`" type="checkbox" <VeeField v-slot="{ field }" :name="`attendees[${index}].passed_bookwork`" type="checkbox"
:value="false" :unchecked-value="true"> :value="false" :unchecked-value="true">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div class="relative inline-flex items-center group"> <Tooltip :open="!selectedCourse?.hasBookwork"
message="This course does not have bookwork">
<Checkbox :disabled="!selectedCourse?.hasBookwork" <Checkbox :disabled="!selectedCourse?.hasBookwork"
:name="`attendees[${index}].passed_bookwork`" :model-value="!field.checked" :name="`attendees[${index}].passed_bookwork`" :model-value="!field.checked"
@update:model-value="field['onUpdate:modelValue']"> @update:model-value="field['onUpdate:modelValue']">
</Checkbox> </Checkbox>
<!-- Tooltip bubble --> </Tooltip>
<div v-if="!selectedCourse?.hasBookwork" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
This course does not have bookwork
</div>
</div>
<div class="h-4"> <div class="h-4">
</div> </div>
</div> </div>
@@ -351,20 +351,12 @@ const filteredMembers = computed(() => {
<VeeField v-slot="{ field }" :name="`attendees[${index}].passed_qual`" type="checkbox" <VeeField v-slot="{ field }" :name="`attendees[${index}].passed_qual`" type="checkbox"
:value="false" :unchecked-value="true"> :value="false" :unchecked-value="true">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div class="relative inline-flex items-center group"> <Tooltip :open="!selectedCourse?.hasQual"
message="This course does not have a qualification">
<Checkbox :disabled="!selectedCourse?.hasQual" <Checkbox :disabled="!selectedCourse?.hasQual"
:name="`attendees[${index}].passed_qual`" :model-value="!field.checked" :name="`attendees[${index}].passed_qual`" :model-value="!field.checked"
@update:model-value="field['onUpdate:modelValue']"></Checkbox> @update:model-value="field['onUpdate:modelValue']"></Checkbox>
<!-- Tooltip bubble --> </Tooltip>
<div v-if="!selectedCourse?.hasQual" class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2
whitespace-nowrap rounded-md bg-popover px-2 py-1 text-xs
text-popover-foreground shadow-md border border-border
opacity-0 translate-y-1
group-hover:opacity-100 group-hover:translate-y-0
transition-opacity transition-transform duration-150">
This course does not have a qualification
</div>
</div>
<div class="h-4"> <div class="h-4">
</div> </div>
</div> </div>
@@ -416,7 +408,12 @@ const filteredMembers = computed(() => {
</FieldGroup> </FieldGroup>
<div class="flex gap-3 justify-end"> <div class="flex gap-3 justify-end">
<Button type="button" variant="outline" @click="resetForm">Reset</Button> <Button type="button" variant="outline" @click="resetForm">Reset</Button>
<Button type="submit" form="trainingForm">Submit</Button> <Button type="submit" form="trainingForm" :disabled="submitting" class="w-35">
<span class="flex items-center gap-2" v-if="submitting">
<Spinner></Spinner> Submitting
</span>
<span v-else>Submit</span>
</Button>
</div> </div>
</form> </form>
</template> </template>

View File

@@ -16,7 +16,7 @@ export const buttonVariants = cva(
secondary: secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent active:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
success: success:
"bg-success text-success-foreground shadow-xs hover:bg-success/90", "bg-success text-success-foreground shadow-xs hover:bg-success/90",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",

View File

@@ -2,10 +2,10 @@ import { cva } from "class-variance-authority";
export { default as InputGroup } from "./InputGroup.vue"; export { default as InputGroup } from "./InputGroup.vue";
export { default as InputGroupAddon } from "./InputGroupAddon.vue"; export { default as InputGroupAddon } from "./InputGroupAddon.vue";
export { default as InputGroupButton } from "./InputGroupButton.vue"; // export { default as InputGroupButton } from "./InputGroupButton.vue";
export { default as InputGroupInput } from "./InputGroupInput.vue"; // export { default as InputGroupInput } from "./InputGroupInput.vue";
export { default as InputGroupText } from "./InputGroupText.vue"; // export { default as InputGroupText } from "./InputGroupText.vue";
export { default as InputGroupTextarea } from "./InputGroupTextarea.vue"; // export { default as InputGroupTextarea } from "./InputGroupTextarea.vue";
export const inputGroupAddonVariants = cva( export const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",

View File

@@ -22,7 +22,7 @@ const modelValue = useVModel(props, "modelValue", emits, {
data-slot="input" data-slot="input"
:class=" :class="
cn( cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class, props.class,

3
ui/src/lib/copyLink.ts Normal file
View File

@@ -0,0 +1,3 @@
export function CopyLink() {
navigator.clipboard.writeText(window.location.href);
}

View File

@@ -10,14 +10,9 @@ import FormInput from './components/form/FormInput.vue'
import * as Sentry from "@sentry/vue"; import * as Sentry from "@sentry/vue";
const app = createApp(App) const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(createPinia())
app.use(router) app.use(router)
if (import.meta.env.VITE_DISABLE_GLITCHTIP === "true") { if (import.meta.env.VITE_DISABLE_GLITCHTIP === "true") {
@@ -38,7 +33,7 @@ if (import.meta.env.VITE_DISABLE_GLITCHTIP === "true") {
}); });
} }
app.component("FormInput", FormInput) // app.component("FormInput", FormInput)
app.component("FormCheckbox", FormCheckbox) // app.component("FormCheckbox", FormCheckbox)
app.mount('#app') app.mount('#app')

View File

@@ -5,10 +5,11 @@ import { onMounted, ref } from 'vue';
import { approveApplication, denyApplication, loadApplication, postApplication, postChatMessage, getMyApplication, postAdminChatMessage } from '@/api/application'; import { approveApplication, denyApplication, loadApplication, postApplication, postChatMessage, getMyApplication, postAdminChatMessage } from '@/api/application';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Button from '@/components/ui/button/Button.vue'; import Button from '@/components/ui/button/Button.vue';
import { CheckIcon, XIcon } from 'lucide-vue-next'; import { CheckIcon, Link, XIcon } from 'lucide-vue-next';
import Unauthorized from './Unauthorized.vue'; import Unauthorized from './Unauthorized.vue';
import { ApplicationData, ApplicationFull, ApplicationStatus, CommentRow } from '@shared/types/application'; import { ApplicationData, ApplicationFull, ApplicationStatus, CommentRow } from '@shared/types/application';
import Spinner from '@/components/ui/spinner/Spinner.vue'; import Spinner from '@/components/ui/spinner/Spinner.vue';
import { CopyLink } from '@/lib/copyLink';
const appData = ref<ApplicationData>(null); const appData = ref<ApplicationData>(null);
const appID = ref<number | null>(null); const appID = ref<number | null>(null);
@@ -20,6 +21,7 @@ const decisionDate = ref<Date | null>(null);
const submitDate = ref<Date | null>(null); const submitDate = ref<Date | null>(null);
const loading = ref<boolean>(true); const loading = ref<boolean>(true);
const member_name = ref<string>(); const member_name = ref<string>();
const notFound = ref<boolean>(false);
const props = defineProps<{ const props = defineProps<{
mode?: "create" | "view-self" | "view-recruiter" | "view-self-id" mode?: "create" | "view-self" | "view-recruiter" | "view-self-id"
@@ -29,6 +31,11 @@ const finalMode = ref<"create" | "view-self" | "view-recruiter" | "view-self-id"
function loadData(raw: ApplicationFull) { function loadData(raw: ApplicationFull) {
if (!raw) {
notFound.value = true;
return;
}
const data = raw.application; const data = raw.application;
appID.value = data.id; appID.value = data.id;
@@ -129,11 +136,18 @@ async function handleDeny(id) {
<div v-if="unauthorized" class="flex justify-center w-full my-10"> <div v-if="unauthorized" class="flex justify-center w-full my-10">
You do not have permission to view this application. You do not have permission to view this application.
</div> </div>
<div v-else-if="notFound" class="flex justify-center w-full my-10 text-muted-foreground">
Looks like you dont have an application, reach out to the administration team if you believe this is an
error.
</div>
<div v-else> <div v-else>
<div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8"> <div v-if="!newApp" class="flex flex-row justify-between items-center py-2 mb-8">
<!-- Application header --> <!-- Application header -->
<div> <div>
<div class="flex gap-4 items-center">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">{{ member_name }}</h3> <h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">{{ member_name }}</h3>
<Button v-if="finalMode === 'view-recruiter'" variant="ghost" size="icon" @click="CopyLink()"><Link class="size-5"/></Button>
</div>
<p class="text-muted-foreground">Submitted: {{ submitDate?.toLocaleString("en-US", { <p class="text-muted-foreground">Submitted: {{ submitDate?.toLocaleString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
@@ -181,7 +195,6 @@ async function handleDeny(id) {
</div> </div>
</div> </div>
<!-- TODO: Implement some kinda loading screen -->
<div v-else class="flex items-center justify-center h-full"> <div v-else class="flex items-center justify-center h-full">
<Spinner class="size-8" /> <Spinner class="size-8" />
</div> </div>

View File

@@ -11,6 +11,8 @@ import { useRouter, useRoute } from 'vue-router'
import { useCalendarEvents } from '@/composables/useCalendarEvents' import { useCalendarEvents } from '@/composables/useCalendarEvents'
import { useCalendarNavigation } from '@/composables/useCalendarNavigation' import { useCalendarNavigation } from '@/composables/useCalendarNavigation'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { CalendarOptions } from '@fullcalendar/core'
import { MemberState } from '@shared/types/member'
const monthLabels = [ const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
@@ -49,14 +51,14 @@ const dialogRef = ref<any>(null)
// NEW: handle day/time slot clicks to start creating an event // NEW: handle day/time slot clicks to start creating an event
function onDateClick(arg: { dateStr: string }) { function onDateClick(arg: { dateStr: string }) {
if (!userStore.isLoggedIn) return; if (!userStore.isLoggedIn) return;
if (userStore.state !== 'member') return; if (userStore.state !== MemberState.Member) return;
dialogRef.value?.openDialog(arg.dateStr); dialogRef.value?.openDialog(arg.dateStr);
} }
const calendarOptions = ref({ const calendarOptions = ref<CalendarOptions>({
plugins: [dayGridPlugin, interactionPlugin], plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth', initialView: 'dayGridMonth',
height: '100%', height: 'auto',
expandRows: true, expandRows: true,
headerToolbar: { headerToolbar: {
left: '', left: '',
@@ -70,6 +72,7 @@ const calendarOptions = ref({
eventClick: onEventClick, eventClick: onEventClick,
editable: false, editable: false,
// force block-mode in dayGrid so we can lay it out on one line // force block-mode in dayGrid so we can lay it out on one line
eventDisplay: 'block', eventDisplay: 'block',
@@ -155,8 +158,8 @@ onMounted(() => {
<div> <div>
<CreateCalendarEvent ref="dialogRef" @reload="loadEvents(); eventViewRef.forceReload();"></CreateCalendarEvent> <CreateCalendarEvent ref="dialogRef" @reload="loadEvents(); eventViewRef.forceReload();"></CreateCalendarEvent>
<div class="flex"> <div class="flex">
<div class="flex-1 min-h-0 mt-5"> <div class="flex-1 min-h-0 mt-5" :class="{ 'hidden md:block': panelOpen }">
<div class="h-[80vh] min-h-0"> <div class="max-h-[80vh] overflow-y-auto min-h-0 scrollbar-themed p-2">
<!-- calendar header --> <!-- calendar header -->
<div class="flex items-center justify-between mx-5"> <div class="flex items-center justify-between mx-5">
<!-- Left: title + pickers --> <!-- Left: title + pickers -->
@@ -196,7 +199,7 @@ onMounted(() => {
@click="goToday"> @click="goToday">
Today Today
</button> </button>
<button v-if="userStore.isLoggedIn && userStore.state === 'member'" <button v-if="userStore.isLoggedIn && userStore.state === MemberState.Member"
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90" class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent"> @click="onCreateEvent">
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
@@ -208,50 +211,52 @@ onMounted(() => {
</div> </div>
</div> </div>
<aside v-if="panelOpen" <aside v-if="panelOpen"
class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed" class="w-screen 3xl:w-lg 2xl:w-md lg:border-l bg-card text-foreground flex flex-col overflow-auto scrollbar-themed"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }"> :style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<ViewCalendarEvent ref="eventViewRef" :key="currentEventID" @close="() => { router.push('/calendar'); }" <ViewCalendarEvent ref="eventViewRef" :key="currentEventID" @close="() => { router.push('/calendar'); }"
@reload="loadEvents()" @edit="(val) => { dialogRef.openDialog(null, 'edit', val) }"> @reload="loadEvents()" @load="calendarRef.getApi().updateSize()"
@edit="(val) => { dialogRef.openDialog(null, 'edit', val) }">
</ViewCalendarEvent> </ViewCalendarEvent>
</aside> </aside>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
/* Firefox */
.scrollbar-themed {
scrollbar-width: thin;
scrollbar-color: #555 #1f1f1f;
padding-right: 6px;
}
/* Chrome, Edge, Safari */
.scrollbar-themed::-webkit-scrollbar {
width: 10px;
/* slightly wider to allow padding look */
}
.scrollbar-themed::-webkit-scrollbar-track {
background: #1f1f1f;
margin-left: 6px;
/* ❗ adds space between content + scrollbar */
}
.scrollbar-themed::-webkit-scrollbar-thumb {
background: #555;
border-radius: 9999px;
}
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>
<style scoped> <style scoped>
/* ---------- Optional container "card" around the calendar ---------- */ /* ---------- Optional container "card" around the calendar ---------- */
/* Ensure the calendar fills the container properly */
:global(.fc) { :global(.fc) {
height: 100% !important; height: 100% !important;
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: color-mix(in srgb, var(--color-foreground) 8%, transparent);
--fc-neutral-text-color: var(--color-muted-foreground);
--fc-border-color: var(--color-border);
--fc-button-bg-color: transparent;
--fc-button-border-color: var(--color-border);
--fc-button-hover-bg-color: var(--color-muted);
}
:global(.fc-theme-standard .fc-scrollgrid) {
border-radius: 8px;
overflow: hidden;
/* Rounds the corners of the grid */
border: 1px solid var(--color-border);
}
:global(.fc-daygrid-day-frame) {
display: flex;
flex-direction: column;
padding: 4px;
}
:global(.fc .fc-scroller-harness) {
background: transparent;
}
:global(.fc-daygrid-day-events) {
flex-grow: 1;
/* Pushes events to take up available space */
} }
:global(.ev-pill.is-cancelled) { :global(.ev-pill.is-cancelled) {
@@ -297,6 +302,7 @@ onMounted(() => {
:global(.fc .fc-scrollgrid td), :global(.fc .fc-scrollgrid td),
:global(.fc .fc-scrollgrid th) { :global(.fc .fc-scrollgrid th) {
border-color: var(--color-border); border-color: var(--color-border);
background: var(--fc-page-bg-color);
} }
/* ---------- Built-in toolbar (if you keep it) ---------- */ /* ---------- Built-in toolbar (if you keep it) ---------- */
@@ -344,6 +350,7 @@ onMounted(() => {
text-decoration: none; text-decoration: none;
} }
:global(.fc .fc-daygrid-day-top) { :global(.fc .fc-daygrid-day-top) {
padding: 8px 8px 0 8px; padding: 8px 8px 0 8px;
} }

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue';
import { bustUserCache } from '@/api/member';
import type { UserCacheBustResult } from '@shared/types/member';
import { ref } from 'vue';
const loading = ref(false);
const result = ref<UserCacheBustResult | null>(null);
const error = ref<string | null>(null);
async function onBustUserCache() {
loading.value = true;
error.value = null;
try {
result.value = await bustUserCache();
} catch (err) {
result.value = null;
error.value = err instanceof Error ? err.message : 'Failed to bust user cache';
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="max-w-3xl mx-auto pt-10 px-4">
<h1 class="scroll-m-20 text-2xl font-semibold tracking-tight">Developer Tools</h1>
<p class="mt-2 text-sm text-muted-foreground">
Use this page to recover from stale in-memory authentication data after manual database changes.
</p>
<div class="mt-6 rounded-lg border p-5 bg-card">
<p class="font-medium">Server User Cache</p>
<p class="text-sm text-muted-foreground mt-1">
This clears the API server's cached user session data so the next request reloads from the database.
</p>
<div class="mt-4 flex items-center gap-3">
<Button :disabled="loading" @click="onBustUserCache">
{{ loading ? 'Busting Cache...' : 'Bust User Cache' }}
</Button>
</div>
<p v-if="result" class="mt-4 text-sm text-green-700">
Cache busted successfully. Cleared {{ result.clearedEntries }} entr{{ result.clearedEntries === 1 ? 'y' : 'ies' }} at
{{ new Date(result.bustedAt).toLocaleString() }}.
</p>
<p v-if="error" class="mt-4 text-sm text-red-700">{{ error }}</p>
</div>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { getWelcomeMessage } from '@/api/docs'; import { getWelcomeMessage } from '@/api/docs';
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { MemberState } from '@shared/types/member';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -14,7 +15,7 @@ function goToApplication() {
} }
onMounted(async () => { onMounted(async () => {
if (user.state == 'member') { if (user.state == MemberState.Member) {
let policy = await getWelcomeMessage() as any; let policy = await getWelcomeMessage() as any;
welcomeRef.value.innerHTML = policy; welcomeRef.value.innerHTML = policy;
} }
@@ -25,7 +26,7 @@ const welcomeRef = ref<HTMLElement>(null);
<template> <template>
<div> <div>
<div v-if="user.state == 'member'" class="mt-10"> <div v-if="user.state == MemberState.Member" class="mt-10">
<div ref="welcomeRef" class="bookstack-container"> <div ref="welcomeRef" class="bookstack-container">
<!-- bookstack --> <!-- bookstack -->
</div> </div>

View File

@@ -15,6 +15,7 @@ import { Check, Circle, Dot, Users, X } from 'lucide-vue-next'
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import Application from './Application.vue'; import Application from './Application.vue';
import { restartApplication } from '@/api/application'; import { restartApplication } from '@/api/application';
import { MemberState } from '@shared/types/member';
function goToLogin() { function goToLogin() {
const redirectUrl = encodeURIComponent(window.location.origin + '/join') const redirectUrl = encodeURIComponent(window.location.origin + '/join')
@@ -26,7 +27,7 @@ function goToLogin() {
let userStore = useUserStore(); let userStore = useUserStore();
const steps = computed(() => { const steps = computed(() => {
const isDenied = userStore.state === 'denied' const isDenied = userStore.state === MemberState.Denied
return [ return [
{ {
@@ -58,19 +59,21 @@ const currentStep = computed<number>(() => {
if (!userStore.isLoggedIn) if (!userStore.isLoggedIn)
return 1; return 1;
switch (userStore.state) { switch (userStore.state) {
case "guest": case MemberState.Guest:
return 2; return 2;
break; break;
case "applicant": case MemberState.Applicant:
return 3; return 3;
break; break;
case "member": case MemberState.Member:
return 5; return 5;
break; break;
case "denied": case MemberState.Denied:
return 5; return 5;
break; break;
case "retired": case MemberState.Retired:
return 5;
case MemberState.Discharged:
return 5; return 5;
break; break;
} }
@@ -104,7 +107,8 @@ async function restartApp() {
size="icon" class="z-10 rounded-full shrink-0" size="icon" class="z-10 rounded-full shrink-0"
:class="[state === 'active' && 'ring-2 ring-ring ring-offset-2 ring-offset-background']"> :class="[state === 'active' && 'ring-2 ring-ring ring-offset-2 ring-offset-background']">
<template v-if="state === 'completed'"> <template v-if="state === 'completed'">
<X v-if="step.step === 4 && userStore.state === 'denied'" class="size-5" /> <X v-if="step.step === 4 && userStore.state === MemberState.Denied"
class="size-5" />
<Check v-else class="size-5" /> <Check v-else class="size-5" />
</template> </template>
<Circle v-if="state === 'active'" /> <Circle v-if="state === 'active'" />
@@ -160,7 +164,7 @@ async function restartApp() {
</div> </div>
<div v-if="finalPanel === 'message'"> <div v-if="finalPanel === 'message'">
<!-- Accepted message --> <!-- Accepted message -->
<div v-if="userStore.state === 'member'"> <div v-if="userStore.state === MemberState.Member">
<h1 class="text-3xl sm:text-4xl font-bold mb-4 text-left"> <h1 class="text-3xl sm:text-4xl font-bold mb-4 text-left">
Welcome to the 17th Ranger Battalion Welcome to the 17th Ranger Battalion
</h1> </h1>
@@ -232,7 +236,7 @@ async function restartApp() {
</div> </div>
</div> </div>
<!-- Denied message --> <!-- Denied message -->
<div v-else-if="userStore.state === 'denied'"> <div v-else-if="userStore.state === MemberState.Denied">
<div class="w-full max-w-2xl flex flex-col gap-8"> <div class="w-full max-w-2xl flex flex-col gap-8">
<h1 class="text-3xl sm:text-4xl font-bold text-left"> <h1 class="text-3xl sm:text-4xl font-bold text-left">
Application Not Approved Application Not Approved
@@ -263,7 +267,8 @@ async function restartApp() {
<Button class="w-min" @click="restartApp">New Application</Button> <Button class="w-min" @click="restartApp">New Application</Button>
</div> </div>
</div> </div>
<div v-else-if="userStore.state === 'retired'"> <div
v-else-if="userStore.state === MemberState.Discharged || userStore.state === MemberState.Retired">
<div class="w-full max-w-2xl flex flex-col gap-8"> <div class="w-full max-w-2xl flex flex-col gap-8">
<h1 class="text-3xl sm:text-4xl font-bold text-left"> <h1 class="text-3xl sm:text-4xl font-bold text-left">
You have retired from the 17th Ranger Battalion You have retired from the 17th Ranger Battalion

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