mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-14 21:27:23 +01:00
* WIP(privacy): start of banner * WIP(privacy): layout rough * WIP(privacy): mobile layout, add modal * fix(privacy): implement toggle disable and setting row fold * fix(privacy): clean up a couple of styles * fix(privacy): adjust banner width at mobile sizes * WIP(privacy): remove Loggly echo of Amplitude data * fix(banners): account for privacy in snackbar position * WIP(privacy): dismiss banner * chore(analytics): update to maintaned GA4 library * fix(tests): lint, misuse of apiError * fix(analytics): add debug mode * fix(analytics): load new library on client * WIP(privacy): gtag.js based implementation * fix(analytics): lint issues * fix(lint): one more unused * fix(lint): client errors * feat(privacy): draft workflows * fix(analytics): linting, send needed user values * fix(tests): use mock analytics service in test env * fix(tests): restore previous logic for node env * feat(intro): jump to page 2 onboarding * WIP(auth): revisions to registration flow * WIP(privacy): landing page and banner revisions * WIP(signup): added new username, tos, privacy state * fix(signup): revert debugging logic * WIP(signup): add defaulting and checkbox * wip(signup): move social auth behind username screen * Squashed commit of the following: commit ca0a238e5f008525ed154c5eaf12e44f2fc22b00 Author: Phillip Thelen <phillip@habitica.com> Date: Wed May 7 12:17:20 2025 +0200 make emails lowercase commit a2ce748558ce9134e6825208a7e66d78e720202e Author: Phillip Thelen <phillip@habitica.com> Date: Wed Apr 9 13:27:01 2025 +0200 remove unused import commit cc6ce6c388d9693cf192c4bea733931fc8c31c37 Author: Phillip Thelen <phillip@habitica.com> Date: Wed Apr 9 13:13:03 2025 +0200 add tests for new api route commit 0d40a6230b548625482aa9f6831c93ed9d62533a Author: Kalista Payne <sabrecat@gmail.com> Date: Wed Jun 18 15:50:22 2025 -0500 update social tests commit 79177d6754589b9e54682af8a531b63f60215dab Author: Phillip Thelen <phillip@habitica.com> Date: Wed Apr 9 10:21:51 2025 +0200 new api route to check if an email is available commit 11df73fe07eeb730c2a95593e18e14a931f52429 Author: Phillip Thelen <phillip@habitica.com> Date: Wed Apr 9 10:21:39 2025 +0200 Add field to not register social account when called * Squashed commit of the following: commitb8a2f0b8eeAuthor: CuriousMagpie <eilatan@gmail.com> Date: Thu Jun 20 17:18:30 2024 -0400 update privacy policy * fix(vite): import syntax * feat(auth): precheck on defaulted username * feat(auth): add store action for check-email * feat(auth): check email before proceeding * WIP(login): refactor username screen * WIP(auth): complete login/reg flow * fix(auth): filter out expected 404 * fix(login): use allowRegister with Apple and add z-index to component * fix(login): style corrections and email passthru * Fix edgecase Signed-off-by: Kalista Payne <sabrecat@gmail.com> * fix(auth): correct error behaviors * fix(auth): rewire Apple auth * make check-email check for restricted domains Signed-off-by: Kalista Payne <kalista@habitica.com> * fix(signup): all the style * fix(express): return when responding * fix(error): reduce specificity for restricted domain issue * fix apple auth Signed-off-by: Kalista Payne <kalista@habitica.com> * fix(signup): change from blur to 500ms debounce * fix(login): add missing 200 response in Apple flow * fix(signup): more reconciliation with @phillipthelen's work * fix(signup): now using token not code * fix(reg): don't bail on Apple if we're allowing reg * fix(auth): more reconciliation with @phillipthelen code * feat(copy): privacy policy updates * fix(copy): replace placeholder * fix(vue): use Vite syntax for scss import * fix(static): corrections to copy and css * chore(style): remove excess whitespace * use correct error Signed-off-by: Kalista Payne <kalista@habitica.com> * fix(layout): inputs, add privacy banner * fix(login): button hover, more validation states * fix(login): further layout and UX corrections * fix(static): add back containing div for show/hide * fix(apple): clean out Apple token * fix(settings): only change preference on save * fix(settings): correct save/cancel behavior * fix(layout): consistent use of header/footer * fix(layout): reposition mountains for reg/login/forgot * fix(signup): partial rollback of /username route * refactor(signup): move /username to page * fix(apple): don't overwrite reg method * fix(username): don't skip empty validation * fix(input): don't show valid if no username * fix(login): clean out Apple token if using another method * fix(apple): possible race with token * fix(tests): some housekeeping * fix(config): copypasta * fix(lint): various cleanup * fix(lint): line squeeze * fix(lint): one more v-for * fix(groups): funnel invite flow to new username page * Squashed commit of the following: commit 3c5ba4bf24e4bb7996786520101f27ad66405bce Author: Kalista Payne <kalista@habitica.com> Date: Mon Aug 18 14:38:31 2025 -0500 fix(privacy): update link ref commit9d216f623bAuthor: Kalista Payne <kalista@habitica.com> Date: Mon Aug 18 14:18:22 2025 -0500 fix(privacy-tos): copy edits cont'd commitd744f47140Author: Kalista Payne <kalista@habitica.com> Date: Mon Aug 18 13:43:22 2025 -0500 fix(privacy): copy edits and ToC reflow commit2c3c3fc9ceAuthor: Phillip Thelen <phillip@habitica.com> Date: Mon Aug 18 18:46:24 2025 +0200 lint commitcf363034d5Author: Phillip Thelen <phillip@habitica.com> Date: Mon Aug 18 18:34:54 2025 +0200 fix link commit3afacd2c05Author: Phillip Thelen <phillip@habitica.com> Date: Mon Aug 18 18:34:42 2025 +0200 add updated terms commit258b722499Author: Phillip Thelen <phillip@habitica.com> Date: Mon Aug 18 17:58:42 2025 +0200 put back button to show/hide third party info commit2992e0299bAuthor: Phillip Thelen <phillip@habitica.com> Date: Mon Aug 18 17:58:32 2025 +0200 minor edits commitbb5e252299Author: Kalista Payne <kalista@habitica.com> Date: Sun Aug 17 21:01:50 2025 -0500 fix(privacy): update Section 3 commitc79af7baa8Author: Kalista Payne <kalista@habitica.com> Date: Fri Aug 15 17:28:49 2025 -0500 fix(privacy): various copy edits commit100f2f4574Author: Phillip Thelen <phillip@habitica.com> Date: Fri Aug 15 11:37:37 2025 +0200 add newline commit11d1cfd0d9Author: Phillip Thelen <phillip@habitica.com> Date: Fri Aug 15 11:10:01 2025 +0200 update privacy policy commit59b99badf3Author: Kalista Payne <kalista@habitica.com> Date: Fri Aug 8 14:04:19 2025 -0500 5.38.2 commit78daeb4191Author: Kalista Payne <kalista@habitica.com> Date: Fri Aug 8 13:36:19 2025 -0500 fix(apple): don't run auth middleware during redirect commit93f8d60903Author: Weblate <noreply@weblate.org> Date: Fri Aug 8 10:12:25 2025 +0200 Translated using Weblate (German) Currently translated at 99.4% (185 of 186 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (186 of 186 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (186 of 186 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (54 of 54 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (243 of 243 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (15 of 15 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (47 of 47 strings) Translated using Weblate (Dutch) Currently translated at 78.0% (2643 of 3385 strings) Translated using Weblate (Dutch) Currently translated at 40.8% (100 of 245 strings) Translated using Weblate (Polish) Currently translated at 89.9% (233 of 259 strings) Translated using Weblate (Dutch) Currently translated at 67.5% (175 of 259 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (110 of 110 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 20.8% (51 of 245 strings) Translated using Weblate (Turkish) Currently translated at 65.9% (60 of 91 strings) Translated using Weblate (Turkish) Currently translated at 65.9% (60 of 91 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 17.9% (44 of 245 strings) Co-authored-by: FingerTiao <787170918@qq.com> Co-authored-by: Jaime Martí <jaumemarti77@icloud.com> Co-authored-by: Karmelkowy <kicimeow.karmelio@gmail.com> Co-authored-by: Linsey Dunya Pastoor <sekai.creations@gmail.com> Co-authored-by: Mete Olmez <metezori27@gmail.com> Co-authored-by: Sefa Uğurlu <ugurlusefa2@gmail.com> Co-authored-by: Summer_GUI <heyang94@163.com> Co-authored-by: Toro Mor <thomas.bizer@gmx.de> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: innnko <ayakabooker@gmail.com> Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/challenge/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/tr/ Translate-URL: https://translate.habitica.com/projects/habitica/contrib/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/death/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/defaulttasks/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hant/ Translate-URL: https://translate.habitica.com/projects/habitica/front/de/ Translate-URL: https://translate.habitica.com/projects/habitica/front/es/ Translate-URL: https://translate.habitica.com/projects/habitica/front/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/ Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/nl/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/pl/ Translation: Habitica/Backgrounds Translation: Habitica/Challenge Translation: Habitica/Communityguidelines Translation: Habitica/Contrib Translation: Habitica/Death Translation: Habitica/Defaulttasks Translation: Habitica/Faq Translation: Habitica/Front Translation: Habitica/Gear Translation: Habitica/Generic Translation: Habitica/Settings commiteb16fec41eAuthor: Phillip Thelen <phillip@habitica.com> Date: Wed Aug 6 22:08:07 2025 +0200 Add interface to block ip-addresses or clients due to abuse (#15484) * Read IP blocks from database * begin building general blocking solution * add new frontend files * Add UI for managing blockers * correctly reset local data after creating blocker * Tweak wording * Add UI for managing blockers * restructure admin pages * improve test coverage * Improve blocker UI * add blocker to block emails from registration * lint fix * fix * lint fixes * fix import * add new permission for managing blockers * improve permission check * fix managing permissions from admin * improve navbar display for non fullAccess admin * update block error strings * lint fix * add option to errorHandler to skip logging * validate blocker value during input * improve blocker form display * chore(subproj): reconcile habitica-images * fix(scripts): use same Mongo version for dev/test * fix(whitespace): eof * documentation improvements * remove nconf import * remove old test --------- Co-authored-by: Kalista Payne <kalista@habitica.com> Co-authored-by: Kalista Payne <sabrecat@gmail.com> commit47d832bf12Author: Fiz <34069775+Hafizzle@users.noreply.github.com> Date: Tue Aug 5 15:12:44 2025 -0500 Add backend support for Hydra mount (#15482) * chore: update time travelers shop to display seasonal backgrounds * chore: update time travelers banner (note CSS borken rn) * chore: fix borken CSS and update logic in shop * chore: added isSubscribed function, not working * chore: isSubscribed working but no bg for subscribers * chore: logic and css updates * chore: update habitica-images * chore: add check for trinket * chore: more time traveler shop logicking * Add backend support for Hydra mount - Add Dragon-Hydra to special mounts in stable.js - Configure as contributor level 7 reward with canFind: true - Add GIF format support for mount sprites - Enable admin panel granting capability * Fix Vue template errors in timeTravelers component * Fix duplicate template block in timeTravelers component * add CSS for Hydra mount GIF sprites Added CSS rules for Mount_Head_Dragon-Hydra and Mount_Body_Dragon-Hydra GIF sprites * Remove the separate Hydra mount dimension declaration --------- Co-authored-by: CuriousMagpie <eilatan@gmail.com> commitc03ab9855fAuthor: Kalista Payne <kalista@habitica.com> Date: Tue Aug 5 14:31:05 2025 -0500 5.38.1 commit8f96b7b7fdAuthor: Weblate <noreply@weblate.org> Date: Tue Aug 5 13:02:45 2025 +0200 Translated using Weblate (Chinese (Traditional)) Currently translated at 17.1% (42 of 245 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 16.7% (41 of 245 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 16.3% (40 of 245 strings) Translated using Weblate (Polish) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (Japanese) Currently translated at 98.8% (425 of 430 strings) Translated using Weblate (French) Currently translated at 99.4% (184 of 185 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 15.9% (39 of 245 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (268 of 268 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (3385 of 3385 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (185 of 185 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 99.8% (3379 of 3385 strings) Translated using Weblate (Polish) Currently translated at 95.5% (128 of 134 strings) Translated using Weblate (Japanese) Currently translated at 94.7% (254 of 268 strings) Translated using Weblate (Polish) Currently translated at 94.0% (126 of 134 strings) Translated using Weblate (Japanese) Currently translated at 98.6% (424 of 430 strings) Translated using Weblate (Japanese) Currently translated at 98.3% (423 of 430 strings) Translated using Weblate (Japanese) Currently translated at 92.5% (798 of 862 strings) Translated using Weblate (Japanese) Currently translated at 92.4% (797 of 862 strings) Translated using Weblate (Japanese) Currently translated at 90.6% (781 of 862 strings) Translated using Weblate (Japanese) Currently translated at 91.9% (3112 of 3385 strings) Translated using Weblate (Japanese) Currently translated at 91.9% (3111 of 3385 strings) Translated using Weblate (Japanese) Currently translated at 94.0% (174 of 185 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (8 of 8 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 15.5% (38 of 245 strings) Translated using Weblate (Japanese) Currently translated at 91.6% (3104 of 3385 strings) Translated using Weblate (Japanese) Currently translated at 93.5% (173 of 185 strings) Translated using Weblate (Japanese) Currently translated at 99.6% (279 of 280 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (167 of 167 strings) Translated using Weblate (Japanese) Currently translated at 89.2% (769 of 862 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (Japanese) Currently translated at 94.4% (253 of 268 strings) Translated using Weblate (Japanese) Currently translated at 91.8% (170 of 185 strings) Translated using Weblate (Japanese) Currently translated at 97.9% (421 of 430 strings) Translated using Weblate (Japanese) Currently translated at 91.6% (3104 of 3385 strings) Translated using Weblate (Japanese) Currently translated at 93.6% (251 of 268 strings) Translated using Weblate (Japanese) Currently translated at 90.8% (168 of 185 strings) Translated using Weblate (Japanese) Currently translated at 82.4% (202 of 245 strings) Translated using Weblate (French) Currently translated at 100.0% (268 of 268 strings) Translated using Weblate (French) Currently translated at 100.0% (3385 of 3385 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 15.1% (37 of 245 strings) Translated using Weblate (French) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (Japanese) Currently translated at 91.3% (3092 of 3385 strings) Translated using Weblate (Japanese) Currently translated at 92.5% (248 of 268 strings) Translated using Weblate (Japanese) Currently translated at 92.5% (248 of 268 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (193 of 193 strings) Translated using Weblate (Croatian) Currently translated at 100.0% (15 of 15 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (167 of 167 strings) Translated using Weblate (Korean) Currently translated at 22.8% (56 of 245 strings) Translated using Weblate (Korean) Currently translated at 47.7% (128 of 268 strings) Translated using Weblate (Croatian) Currently translated at 45.1% (121 of 268 strings) Translated using Weblate (Korean) Currently translated at 71.9% (620 of 862 strings) Translated using Weblate (Croatian) Currently translated at 70.6% (609 of 862 strings) Translated using Weblate (Croatian) Currently translated at 75.0% (6 of 8 strings) Translated using Weblate (Korean) Currently translated at 67.6% (291 of 430 strings) Translated using Weblate (Korean) Currently translated at 52.8% (1788 of 3385 strings) Translated using Weblate (Croatian) Currently translated at 50.3% (1706 of 3385 strings) Translated using Weblate (Croatian) Currently translated at 51.7% (134 of 259 strings) Translated using Weblate (Czech) Currently translated at 92.8% (130 of 140 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 86.9% (233 of 268 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (94 of 94 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 100.0% (94 of 94 strings) Translated using Weblate (Danish) Currently translated at 92.1% (105 of 114 strings) Translated using Weblate (Czech) Currently translated at 89.4% (102 of 114 strings) Translated using Weblate (Czech) Currently translated at 83.5% (112 of 134 strings) Translated using Weblate (Spanish (Latin America)) Currently translated at 71.6% (308 of 430 strings) Translated using Weblate (Spanish (Latin America)) Currently translated at 100.0% (245 of 245 strings) Translated using Weblate (Serbian) Currently translated at 84.4% (49 of 58 strings) Translated using Weblate (Bulgarian) Currently translated at 51.4% (144 of 280 strings) Translated using Weblate (Swedish) Currently translated at 66.5% (286 of 430 strings) Translated using Weblate (Serbian) Currently translated at 65.5% (282 of 430 strings) Translated using Weblate (Slovak) Currently translated at 65.5% (282 of 430 strings) Translated using Weblate (Romanian) Currently translated at 66.7% (287 of 430 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 100.0% (430 of 430 strings) Translated using Weblate (Danish) Currently translated at 66.0% (284 of 430 strings) Translated using Weblate (Czech) Currently translated at 69.7% (300 of 430 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 99.7% (3377 of 3385 strings) Translated using Weblate (Swedish) Currently translated at 54.1% (1834 of 3385 strings) Translated using Weblate (Serbian) Currently translated at 50.6% (1714 of 3385 strings) Translated using Weblate (Slovak) Currently translated at 50.0% (1695 of 3385 strings) Translated using Weblate (Romanian) Currently translated at 60.5% (2050 of 3385 strings) Translated using Weblate (Hebrew) Currently translated at 38.4% (1301 of 3385 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (3385 of 3385 strings) Translated using Weblate (Danish) Currently translated at 54.0% (1829 of 3385 strings) Translated using Weblate (Czech) Currently translated at 59.6% (2020 of 3385 strings) Translated using Weblate (Swedish) Currently translated at 75.6% (140 of 185 strings) Translated using Weblate (Serbian) Currently translated at 73.5% (136 of 185 strings) Translated using Weblate (Slovak) Currently translated at 84.8% (157 of 185 strings) Translated using Weblate (Romanian) Currently translated at 78.9% (146 of 185 strings) Translated using Weblate (Portuguese) Currently translated at 82.1% (152 of 185 strings) Translated using Weblate (Italian) Currently translated at 91.8% (170 of 185 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (185 of 185 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 100.0% (185 of 185 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 100.0% (185 of 185 strings) Translated using Weblate (German) Currently translated at 100.0% (184 of 184 strings) Translated using Weblate (Danish) Currently translated at 77.2% (143 of 185 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 98.7% (242 of 245 strings) Translated using Weblate (Czech) Currently translated at 75.1% (139 of 185 strings) Translated using Weblate (Bulgarian) Currently translated at 74.5% (138 of 185 strings) Translated using Weblate (Czech) Currently translated at 8.1% (20 of 245 strings) Translated using Weblate (Swedish) Currently translated at 72.0% (621 of 862 strings) Translated using Weblate (Serbian) Currently translated at 65.1% (562 of 862 strings) Translated using Weblate (Slovak) Currently translated at 66.9% (577 of 862 strings) Translated using Weblate (Romanian) Currently translated at 77.7% (670 of 862 strings) Translated using Weblate (Portuguese) Currently translated at 70.0% (604 of 862 strings) Translated using Weblate (Polish) Currently translated at 67.1% (579 of 862 strings) Translated using Weblate (Italian) Currently translated at 86.8% (749 of 862 strings) Translated using Weblate (Indonesian) Currently translated at 86.0% (742 of 862 strings) Translated using Weblate (Hebrew) Currently translated at 66.1% (570 of 862 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 98.0% (845 of 862 strings) Translated using Weblate (Danish) Currently translated at 69.9% (603 of 862 strings) Translated using Weblate (Czech) Currently translated at 69.7% (601 of 862 strings) Translated using Weblate (Bulgarian) Currently translated at 66.3% (572 of 862 strings) Translated using Weblate (Serbian) Currently translated at 74.0% (305 of 412 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (193 of 193 strings) Translated using Weblate (Danish) Currently translated at 90.0% (371 of 412 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (Swedish) Currently translated at 53.6% (139 of 259 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (Danish) Currently translated at 62.1% (161 of 259 strings) Translated using Weblate (Bulgarian) Currently translated at 54.0% (140 of 259 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 82.8% (222 of 268 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 99.4% (184 of 185 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 98.3% (241 of 245 strings) Translated using Weblate (Japanese) Currently translated at 91.3% (3092 of 3385 strings) Translated using Weblate (Japanese) Currently translated at 88.4% (237 of 268 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (134 of 134 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (243 of 243 strings) Translated using Weblate (Japanese) Currently translated at 82.4% (202 of 245 strings) Translated using Weblate (English (United Kingdom)) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (Japanese) Currently translated at 87.3% (234 of 268 strings) Translated using Weblate (Japanese) Currently translated at 86.4% (160 of 185 strings) Translated using Weblate (Japanese) Currently translated at 99.8% (913 of 914 strings) Translated using Weblate (German) Currently translated at 100.0% (268 of 268 strings) Translated using Weblate (German) Currently translated at 100.0% (3377 of 3377 strings) Translated using Weblate (German) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (German) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (German) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (German) Currently translated at 100.0% (259 of 259 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (3385 of 3385 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (914 of 914 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (268 of 268 strings) Translated using Weblate (Russian) Currently translated at 88.5% (248 of 280 strings) Translated using Weblate (Spanish) Currently translated at 99.8% (3379 of 3385 strings) Translated using Weblate (German) Currently translated at 100.0% (862 of 862 strings) Co-authored-by: Ayaka Booker <ayakabooker@gmail.com> Co-authored-by: Chaotic Lawful <habitica@eusebius.fr> Co-authored-by: FingerTiao <787170918@qq.com> Co-authored-by: Jaime Martí <jaumemarti77@icloud.com> Co-authored-by: Jan Freihöfer <jan.stauch.is@gmail.com> Co-authored-by: Karmelkowy <kicimeow.karmelio@gmail.com> Co-authored-by: Lio Zam <zerofux@web.de> Co-authored-by: Mika <isekai.chr@gmail.com> Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com> Co-authored-by: Summer_GUI <heyang94@163.com> Co-authored-by: Vera <verasmolinap@gmail.com> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: Zhi Hao Li <zhihaoli000@gmail.com> Co-authored-by: Zuz Q <zuzannakunik@gmail.com> Co-authored-by: innnko <ayakabooker@gmail.com> Co-authored-by: 吳昀錡 <J1120241@gm.fdhs.tyc.edu.tw> Co-authored-by: 潘致翰 <happyq0908@gmail.com> Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/fr/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pl/ Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/character/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/character/tr/ Translate-URL: https://translate.habitica.com/projects/habitica/content/da/ Translate-URL: https://translate.habitica.com/projects/habitica/content/sr/ Translate-URL: https://translate.habitica.com/projects/habitica/death/hr/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/cs/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/en_GB/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/es_419/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/ko/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hant/ Translate-URL: https://translate.habitica.com/projects/habitica/front/bg/ Translate-URL: https://translate.habitica.com/projects/habitica/front/cs/ Translate-URL: https://translate.habitica.com/projects/habitica/front/da/ Translate-URL: https://translate.habitica.com/projects/habitica/front/de/ Translate-URL: https://translate.habitica.com/projects/habitica/front/en_GB/ Translate-URL: https://translate.habitica.com/projects/habitica/front/es/ Translate-URL: https://translate.habitica.com/projects/habitica/front/fr/ Translate-URL: https://translate.habitica.com/projects/habitica/front/it/ Translate-URL: https://translate.habitica.com/projects/habitica/front/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/front/pt/ Translate-URL: https://translate.habitica.com/projects/habitica/front/ro/ Translate-URL: https://translate.habitica.com/projects/habitica/front/sk/ Translate-URL: https://translate.habitica.com/projects/habitica/front/sr/ Translate-URL: https://translate.habitica.com/projects/habitica/front/sv/ Translate-URL: https://translate.habitica.com/projects/habitica/front/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/cs/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/da/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/es/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/he/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/hr/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/ko/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/ro/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/sk/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/sr/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/sv/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/cs/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/da/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/en_GB/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/es_419/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/ko/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/ro/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/sk/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/sr/ Translate-URL: https://translate.habitica.com/projects/habitica/groups/sv/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/bg/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/limited/ru/ Translate-URL: https://translate.habitica.com/projects/habitica/messages/sr/ Translate-URL: https://translate.habitica.com/projects/habitica/npc/cs/ Translate-URL: https://translate.habitica.com/projects/habitica/npc/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/npc/pl/ Translate-URL: https://translate.habitica.com/projects/habitica/overview/hr/ Translate-URL: https://translate.habitica.com/projects/habitica/overview/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/pets/cs/ Translate-URL: https://translate.habitica.com/projects/habitica/pets/da/ Translate-URL: https://translate.habitica.com/projects/habitica/quests/en_GB/ Translate-URL: https://translate.habitica.com/projects/habitica/quests/es/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/bg/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/cs/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/da/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/en_GB/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/he/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/hr/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/id/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/it/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ko/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pl/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ro/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/sk/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/sr/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/sv/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/bg/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/da/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/de/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/en_GB/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/es/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/hr/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/sv/ Translate-URL: https://translate.habitica.com/projects/habitica/settings/uk/ Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/de/ Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/en_GB/ Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/es/ Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/fr/ Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/hr/ Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/ Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ko/ Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/zh_Hans/ Translate-URL: https://translate.habitica.com/projects/habitica/tasks/cs/ Translation: Habitica/Achievements Translation: Habitica/Backgrounds Translation: Habitica/Character Translation: Habitica/Content Translation: Habitica/Death Translation: Habitica/Faq Translation: Habitica/Front Translation: Habitica/Gear Translation: Habitica/Generic Translation: Habitica/Groups Translation: Habitica/Limited Translation: Habitica/Messages Translation: Habitica/Npc Translation: Habitica/Overview Translation: Habitica/Pets Translation: Habitica/Quests Translation: Habitica/Questscontent Translation: Habitica/Settings Translation: Habitica/Subscriber Translation: Habitica/Tasks commit1dde2674f6Author: Kalista Payne <sabrecat@gmail.com> Date: Mon Jun 16 16:43:56 2025 -0500 fix(content): don't filter out the thing we want commit76122a8889Author: Kalista Payne <sabrecat@gmail.com> Date: Wed Jun 4 14:28:27 2025 -0500 fix(mobile): provide Challenge categories via API commit9e309a875eAuthor: Kalista Payne <kalista@habitica.com> Date: Mon Jul 28 14:15:00 2025 -0500 5.38.0 commit09e3a394b8Author: Kalista Payne <kalista@habitica.com> Date: Mon Jul 28 14:06:45 2025 -0500 5.37.3 commiteba263360fAuthor: Weblate <noreply@weblate.org> Date: Mon Jul 28 21:03:17 2025 +0200 Translated using Weblate (German) Currently translated at 100.0% (134 of 134 strings) Translated using Weblate (German) Currently translated at 100.0% (134 of 134 strings) Translated using Weblate (German) Currently translated at 100.0% (3377 of 3377 strings) Translated using Weblate (German) Currently translated at 100.0% (3377 of 3377 strings) Translated using Weblate (German) Currently translated at 100.0% (3377 of 3377 strings) Translated using Weblate (German) Currently translated at 100.0% (243 of 243 strings) Translated using Weblate (German) Currently translated at 100.0% (184 of 184 strings) Translated using Weblate (German) Currently translated at 98.6% (850 of 862 strings) Translated using Weblate (German) Currently translated at 99.8% (3373 of 3377 strings) Translated using Weblate (German) Currently translated at 99.8% (3373 of 3377 strings) Translated using Weblate (German) Currently translated at 99.8% (3373 of 3377 strings) Translated using Weblate (German) Currently translated at 99.5% (3361 of 3377 strings) Translated using Weblate (German) Currently translated at 99.5% (3361 of 3377 strings) Translated using Weblate (German) Currently translated at 99.5% (3361 of 3377 strings) Translated using Weblate (German) Currently translated at 99.4% (3360 of 3377 strings) Translated using Weblate (German) Currently translated at 100.0% (184 of 184 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (185 of 185 strings) Translated using Weblate (Polish) Currently translated at 67.1% (579 of 862 strings) Translated using Weblate (Polish) Currently translated at 67.1% (579 of 862 strings) Translated using Weblate (Polish) Currently translated at 100.0% (91 of 91 strings) Translated using Weblate (Polish) Currently translated at 100.0% (91 of 91 strings) Translated using Weblate (German) Currently translated at 100.0% (184 of 184 strings) Translated using Weblate (German) Currently translated at 100.0% (184 of 184 strings) Translated using Weblate (German) Currently translated at 100.0% (184 of 184 strings) Translated using Weblate (German) Currently translated at 100.0% (184 of 184 strings) Translated using Weblate (German) Currently translated at 100.0% (184 of 184 strings) Translated using Weblate (German) Currently translated at 100.0% (245 of 245 strings) Translated using Weblate (German) Currently translated at 100.0% (47 of 47 strings) Translated using Weblate (German) Currently translated at 100.0% (193 of 193 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 14.2% (35 of 245 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 13.8% (34 of 245 strings) Translated using Weblate (Chinese (Traditional)) Currently translated at 13.0% (32 of 245 strings) Translated using Weblate (Hebrew) Currently translated at 2.0% (5 of 245 strings) Translated using Weblate (Hebrew) Currently translated at 66.1% (570 of 862 strings) Translated using Weblate (Portuguese) Currently translated at 54.1% (1830 of 3377 strings) Co-authored-by: FingerTiao <787170918@qq.com> Co-authored-by: Jaime Martí <jaumemarti77@icloud.com> Co-authored-by: Jan Freihöfer <jan.stauch.is@gmail.com> Co-authored-by: Jonathan Niessen <37.friedrich@gmail.com> Co-authored-by: Karmelkowy <kicimeow.karmelio@gmail.com> Co-authored-by: Katharina <katharinaanna.wilding@gmail.com> Co-authored-by: Laura Fleckenstein <fleckenstein_laura@web.de> Co-authored-by: Omer I.S <omeritzicschwartz@gmail.com> Co-authored-by: Remigiusz Haziak <haziakremigiusz@gmail.com> Co-authored-by: Uwe B <hbtca@tunixgut.de> Co-authored-by: Weblate <noreply@weblate.org> Co-authored-by: Wellinton Cardoso <wmcardoso1@hotmail.com> Co-authored-by: cloudzzy <truskawka412@gmail.com> Co-authored-by: 吳昀錡 <J1120241@gm.fdhs.tyc.edu.tw> Translate-URL: https://translate.habitica.com/projects/habitica/character/de/ Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/pl/ Translate-URL: https://translate.habitica.com/projects/habitica/contrib/de/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/de/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/he/ Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hant/ Translate-URL: https://translate.habitica.com/projects/habitica/front/de/ Translate-URL: https://translate.habitica.com/projects/habitica/front/es/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/ Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/ Translate-URL: https://translate.habitica.com/projects/habitica/generic/de/ Translate-URL: https://translate.habitica.com/projects/habitica/npc/de/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/he/ Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pl/ Translation: Habitica/Character Translation: Habitica/Communityguidelines Translation: Habitica/Contrib Translation: Habitica/Faq Translation: Habitica/Front Translation: Habitica/Gear Translation: Habitica/Generic Translation: Habitica/Npc Translation: Habitica/Questscontent commit9550eec718Author: Phillip Thelen <phillip@habitica.com> Date: Mon Jul 28 16:50:38 2025 +0200 Fix 500 when deleting a very old group plan account (#15481) commitf267eb67e9Author: Kalista Payne <kalista@habitica.com> Date: Tue Jul 29 14:12:35 2025 -0500 fix(static): add back missing div for show/hide commit28251f42abAuthor: Kalista Payne <kalista@habitica.com> Date: Thu Jul 24 22:59:01 2025 -0500 feat(privacy): preview page * feat(privacy): respect Global Privacy Control * fix(lint): remove unused component * fix(test): test user opts in to tracking * fix(test): add user pref to more contexts * fix(test): final spot in api-unit * fix(tests): update integrations * chore(privacy): add paragraph to s1, retire separate preview pages * fix(build): route copypasta * fix(router): lingering dead import --------- Signed-off-by: Kalista Payne <sabrecat@gmail.com> Signed-off-by: Kalista Payne <kalista@habitica.com> Co-authored-by: Phillip Thelen <phillip@habitica.com> Co-authored-by: CuriousMagpie <eilatan@gmail.com>
1392 lines
48 KiB
JavaScript
1392 lines
48 KiB
JavaScript
import assign from 'lodash/assign';
|
|
import escapeRegExp from 'lodash/escapeRegExp';
|
|
import find from 'lodash/find';
|
|
import findIndex from 'lodash/findIndex';
|
|
import includes from 'lodash/includes';
|
|
import isArray from 'lodash/isArray';
|
|
import mergeWith from 'lodash/mergeWith';
|
|
import pick from 'lodash/pick';
|
|
import uniqBy from 'lodash/uniqBy';
|
|
import nconf from 'nconf';
|
|
import moment from 'moment';
|
|
import { authWithHeaders } from '../../middlewares/auth';
|
|
import {
|
|
model as Group,
|
|
basicFields as basicGroupFields,
|
|
} from '../../models/group';
|
|
import {
|
|
model as User,
|
|
nameFields,
|
|
} from '../../models/user';
|
|
import {
|
|
NotFound,
|
|
BadRequest,
|
|
NotAuthorized,
|
|
} from '../../libs/errors';
|
|
import { removeFromArray } from '../../libs/collectionManipulators';
|
|
import { sendTxn as sendTxnEmail } from '../../libs/email';
|
|
import {
|
|
inviteByUUID,
|
|
inviteByEmail,
|
|
inviteByUserName,
|
|
} from '../../libs/invites';
|
|
import common from '../../../common';
|
|
import payments from '../../libs/payments/payments';
|
|
import stripePayments from '../../libs/payments/stripe';
|
|
import amzLib from '../../libs/payments/amazon';
|
|
import { apiError } from '../../libs/apiError';
|
|
import { model as UserNotification } from '../../models/userNotification';
|
|
import {
|
|
leaveGroup,
|
|
removeMessagesFromMember,
|
|
} from '../../libs/groups';
|
|
|
|
const { MAX_SUMMARY_SIZE_FOR_GUILDS } = common.constants;
|
|
const MAX_EMAIL_INVITES_BY_USER = 200;
|
|
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL');
|
|
|
|
/**
|
|
* @apiDefine GroupBodyInvalid
|
|
* @apiError (400) {BadRequest} GroupBodyInvalid A parameter in the group body was invalid.
|
|
*/
|
|
|
|
/**
|
|
* @apiDefine GroupNotFound
|
|
* @apiError (404) {NotFound} GroupNotFound The specified group could not be found.
|
|
*/
|
|
|
|
/**
|
|
* @apiDefine PartyNotFound
|
|
* @apiError (404) {NotFound} PartyNotFound The user's party could not be found.
|
|
*/
|
|
|
|
/**
|
|
* @apiDefine groupIdRequired
|
|
* @apiError (400) {BadRequest} groupIdRequired A groupId is required
|
|
*/
|
|
|
|
/**
|
|
* @apiDefine messageGroupRequiresInvite
|
|
* @apiError (400) {NotAuthorized} messageGroupRequiresInvite Group requires an invitation
|
|
* to join (e.g. private group, party).
|
|
*/
|
|
|
|
/**
|
|
* @apiDefine GroupLeader Group Leader
|
|
* The group leader can use this route.
|
|
*/
|
|
|
|
const api = {};
|
|
|
|
/**
|
|
* @api {post} /api/v3/groups Create group
|
|
* @apiName CreateGroup
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Body) {String} name
|
|
* @apiParam (Body) {String="guild","party"} type Type of group (guild or party)
|
|
* @apiParam (Body) {String="private","public"} privacy Privacy of group (party MUST be private)
|
|
*
|
|
* @apiParamExample {json} Private Guild:
|
|
* {
|
|
* "name": "The Best Guild",
|
|
* "type": "guild",
|
|
* "privacy": "private"
|
|
* }
|
|
*
|
|
* @apiError (401) {NotAuthorized} messageInsufficientGems User does not have enough gems (4)
|
|
* @apiError (401) {NotAuthorized} partyMustbePrivate Party must have privacy set to private
|
|
* @apiError (401) {NotAuthorized} messageGroupAlreadyInParty
|
|
* @apiError (401) {NotAuthorized} chatPrivilegesRevoked You cannot do this because your chat
|
|
privileges have been removed...
|
|
*
|
|
* @apiSuccess (201) {Object} data The created group (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
|
|
*
|
|
* @apiSuccessExample {json} Private Guild:
|
|
* HTTP/1.1 200 OK
|
|
* {
|
|
* "name": "The Best Guild",
|
|
* "leader": {
|
|
* "_id": "authenticated-user-id",
|
|
* "profile": {authenticated user's profile}
|
|
* },
|
|
* "type": "guild",
|
|
* "privacy": "private",
|
|
* "chat": [],
|
|
* "leaderOnly": {
|
|
* "challenges": false
|
|
* },
|
|
* memberCount: 1,
|
|
* challengeCount: 0,
|
|
* balance: 1,
|
|
* logo: "",
|
|
* leaderMessage: ""
|
|
* }
|
|
*/
|
|
api.createGroup = {
|
|
method: 'POST',
|
|
url: '/groups',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
const group = new Group(Group.sanitize(req.body));
|
|
group.leader = user._id;
|
|
|
|
req.checkBody('summary', apiError('summaryLengthExceedsMax')).isLength({ max: MAX_SUMMARY_SIZE_FOR_GUILDS });
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
if (group.type === 'guild') {
|
|
if (!user.hasPermission('fullAccess')) {
|
|
throw new BadRequest(res.t('featureRetired'));
|
|
}
|
|
group.balance = 1;
|
|
user.guilds.push(group._id);
|
|
} else {
|
|
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
|
|
if (user.party._id) throw new NotAuthorized(res.t('messageGroupAlreadyInParty'));
|
|
|
|
user.party._id = group._id;
|
|
user.party.seeking = undefined;
|
|
}
|
|
|
|
let savedGroup;
|
|
|
|
await Group.db.transaction(async session => {
|
|
await user.save({ session });
|
|
savedGroup = await group.save({ session });
|
|
});
|
|
|
|
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
|
// await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]);
|
|
// doc.populate doesn't return a promise
|
|
const response = savedGroup.toJSON();
|
|
// the leader is the authenticated user
|
|
response.leader = {
|
|
_id: user._id,
|
|
profile: { name: user.profile.name },
|
|
};
|
|
|
|
const analyticsObject = {
|
|
user: pick(user, ['preferences', 'registeredThrough']),
|
|
uuid: user._id,
|
|
hitType: 'event',
|
|
category: 'behavior',
|
|
owner: true,
|
|
groupId: savedGroup._id,
|
|
groupType: savedGroup.type,
|
|
privacy: savedGroup.privacy,
|
|
headers: req.headers,
|
|
invited: false,
|
|
};
|
|
|
|
if (savedGroup.privacy === 'public') {
|
|
analyticsObject.groupName = savedGroup.name;
|
|
}
|
|
|
|
res.analytics.track('join group', analyticsObject);
|
|
|
|
res.respond(201, response); // do not remove chat flags data as we've just created the group
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {post} /api/v3/groups/create-plan Create a Group and then redirect to the correct payment
|
|
* @apiName CreateGroupPlan
|
|
* @apiGroup Group
|
|
*
|
|
* @apiSuccess (201) {Object} data The created group
|
|
*/
|
|
api.createGroupPlan = {
|
|
method: 'POST',
|
|
url: '/groups/create-plan',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
const group = new Group(Group.sanitize(req.body.groupToCreate));
|
|
|
|
req.checkBody('paymentType', res.t('paymentTypeRequired')).notEmpty();
|
|
req.checkBody('summary', apiError('summaryLengthExceedsMax')).isLength({ max: MAX_SUMMARY_SIZE_FOR_GUILDS });
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
// @TODO: Change message
|
|
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
|
|
group.leader = user._id;
|
|
user.guilds.push(group._id);
|
|
|
|
const results = await Promise.all([user.save(), group.save()]);
|
|
const savedGroup = results[1];
|
|
|
|
res.analytics.track('join group', {
|
|
user: pick(user, ['preferences', 'registeredThrough']),
|
|
uuid: user._id,
|
|
hitType: 'event',
|
|
category: 'behavior',
|
|
owner: true,
|
|
groupId: savedGroup._id,
|
|
groupType: savedGroup.type,
|
|
privacy: savedGroup.privacy,
|
|
headers: req.headers,
|
|
invited: false,
|
|
});
|
|
|
|
// do not remove chat flags data as we've just created the group
|
|
const groupResponse = savedGroup.toJSON();
|
|
// the leader is the authenticated user
|
|
groupResponse.leader = {
|
|
_id: user._id,
|
|
profile: { name: user.profile.name },
|
|
};
|
|
|
|
if (req.body.paymentType === 'Stripe') {
|
|
const {
|
|
gift, sub: subKey, gemsBlock, coupon,
|
|
} = req.body;
|
|
|
|
const sub = subKey ? common.content.subscriptionBlocks[subKey] : false;
|
|
const groupId = savedGroup._id;
|
|
|
|
const session = await stripePayments.createCheckoutSession({
|
|
user, gemsBlock, gift, sub, groupId, coupon, headers: req.headers,
|
|
});
|
|
|
|
res.respond(200, {
|
|
sessionId: session.id,
|
|
group: groupResponse,
|
|
});
|
|
} else if (req.body.paymentType === 'Amazon') {
|
|
const { billingAgreementId } = req.body;
|
|
const sub = req.body.subscription
|
|
? common.content.subscriptionBlocks[req.body.subscription]
|
|
: false;
|
|
const { coupon } = req.body;
|
|
const groupId = savedGroup._id;
|
|
const { headers } = req;
|
|
|
|
await amzLib.subscribe({
|
|
billingAgreementId,
|
|
sub,
|
|
coupon,
|
|
user,
|
|
groupId,
|
|
headers,
|
|
});
|
|
|
|
res.respond(201, groupResponse);
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {get} /api/v3/groups Get groups for a user
|
|
* @apiName GetGroups
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Query) {String} type The type of groups to retrieve.
|
|
* Must be a query string representing a list of values
|
|
* like 'tavern,party'. Possible values are party, guilds,
|
|
* privateGuilds, publicGuilds, tavern.
|
|
* @apiParam (Query) {String="true","false"} [paginate] Public guilds support pagination.
|
|
* When true guilds are returned in
|
|
* groups of 30.
|
|
* @apiParam (Query) {Number} [page] When pagination is enabled for public guilds this
|
|
parameter can be used to specify the page number
|
|
(the initial page is number 0 and not required).
|
|
*
|
|
* @apiParamExample {json} Private Guilds, Tavern:
|
|
* {
|
|
* "type": "privateGuilds,tavern"
|
|
* }
|
|
*
|
|
* @apiError (400) {BadRequest} groupTypesRequired Group types are required
|
|
* @apiError (400) {BadRequest} guildsPaginateBooleanString Paginate query parameter
|
|
* must be a boolean (true or false).
|
|
* @apiError (400) {BadRequest} queryPageInteger Page query parameter must be a positive integer
|
|
* @apiError (400) {BadRequest} guildsOnlyPaginate Only public guilds support pagination
|
|
*
|
|
* @apiSuccess {Object[]} data An array of the requested groups (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
|
|
*
|
|
* @apiSuccessExample {json} Private Guilds, Tavern:
|
|
* HTTP/1.1 200 OK
|
|
* [
|
|
* {guilds}
|
|
* ]
|
|
*/
|
|
api.getGroups = {
|
|
method: 'GET',
|
|
url: '/groups',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
|
|
req.checkQuery('type', res.t('groupTypesRequired')).notEmpty();
|
|
// pagination options, can only be used with public guilds
|
|
req.checkQuery('paginate').optional().isIn(['true', 'false'], apiError('guildsPaginateBooleanString'));
|
|
req.checkQuery('page').optional().isInt({ min: 0 }, apiError('queryPageInteger'));
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const types = req.query.type.split(',');
|
|
|
|
const paginate = req.query.paginate === 'true';
|
|
if (paginate && !includes(types, 'publicGuilds')) {
|
|
throw new BadRequest(apiError('guildsOnlyPaginate'));
|
|
}
|
|
|
|
const groupFields = basicGroupFields.concat(' description memberCount balance leaderOnly');
|
|
const sort = '-memberCount';
|
|
|
|
const filters = {};
|
|
if (req.query.categories) {
|
|
const categorySlugs = req.query.categories.split(',');
|
|
filters.categories = { $elemMatch: { slug: { $in: categorySlugs } } };
|
|
}
|
|
|
|
if (req.query.minMemberCount) {
|
|
if (!filters.memberCount) filters.memberCount = {};
|
|
filters.memberCount.$gte = parseInt(req.query.minMemberCount, 10);
|
|
}
|
|
|
|
if (req.query.maxMemberCount) {
|
|
if (!filters.memberCount) filters.memberCount = {};
|
|
filters.memberCount.$lte = parseInt(req.query.maxMemberCount, 10);
|
|
}
|
|
|
|
// @TODO: Tests for below?
|
|
if (req.query.leader) {
|
|
filters.leader = user._id;
|
|
}
|
|
|
|
if (req.query.member) {
|
|
filters._id = { $in: user.guilds };
|
|
}
|
|
|
|
if (req.query.search) {
|
|
filters.$or = [];
|
|
const searchWords = escapeRegExp(req.query.search.trim()).split(/\s+/).join('|');
|
|
const searchQuery = { $regex: new RegExp(`${searchWords}`, 'i') };
|
|
filters.$or.push({ name: searchQuery });
|
|
filters.$or.push({ summary: searchQuery });
|
|
filters.$or.push({ description: searchQuery });
|
|
}
|
|
|
|
const results = await Group.getGroups({
|
|
user,
|
|
types,
|
|
groupFields,
|
|
sort,
|
|
paginate,
|
|
page: req.query.page,
|
|
filters,
|
|
});
|
|
res.respond(200, results);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {get} /api/v3/groups/:groupId Get group
|
|
* @apiName GetGroup
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Path) {String} groupId The group _id ('party' for the user party
|
|
* and 'habitrpg' for tavern are accepted)
|
|
*
|
|
* @apiParamExample {String} Tavern:
|
|
* /api/v3/groups/habitrpg
|
|
*
|
|
* @apiSuccess {Object} data The group object (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
|
|
*
|
|
* @apiSuccessExample {json} Tavern:
|
|
* HTTP/1.1 200 OK
|
|
* {
|
|
* "name": "Tavern",
|
|
* ...
|
|
* }
|
|
*
|
|
* @apiUse groupIdRequired
|
|
* @apiUse GroupNotFound
|
|
*/
|
|
api.getGroup = {
|
|
method: 'GET',
|
|
url: '/groups/:groupId',
|
|
middlewares: [authWithHeaders({
|
|
// Some fields (including _id, preferences) are always loaded (see middlewares/auth)
|
|
userFieldsToInclude: ['party', 'guilds', 'contributor'],
|
|
})],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
|
|
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const { groupId } = req.params;
|
|
const group = await Group.getGroup({ user, groupId, populateLeader: false });
|
|
if (!group) {
|
|
throw new NotFound(res.t('groupNotFound'));
|
|
}
|
|
|
|
const groupJson = await Group.toJSONCleanChat(group, user);
|
|
groupJson.purchased.plan = group.purchased.plan.toObject();
|
|
|
|
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
|
const leader = await User.findById(groupJson.leader).select(`${nameFields} preferences.timezoneOffset preferences.dayStart`).exec();
|
|
if (leader) groupJson.leader = leader.toJSON({ minimize: true });
|
|
if (groupJson.purchased.plan.planId) {
|
|
groupJson.cron.timezoneOffset = leader.preferences.timezoneOffset;
|
|
groupJson.cron.dayStart = leader.preferences.dayStart;
|
|
}
|
|
delete groupJson.leader.preferences;
|
|
|
|
res.respond(200, groupJson);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {put} /api/v3/groups/:groupId Update group
|
|
* @apiName UpdateGroup
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Path) {String} groupId The group _id ('party' for the user party and 'habitrpg'
|
|
* for tavern are accepted).
|
|
*
|
|
* @apiParamExample {String} Tavern:
|
|
* /api/v3/groups/habitrpg
|
|
*
|
|
* @apiError (400) {NotAuthorized} messageGroupOnlyLeaderCanUpdate Only the group's leader
|
|
* can update the party.
|
|
*
|
|
* @apiSuccess {Object} data The updated group (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
|
|
*
|
|
* @apiSuccessExample {json} Tavern:
|
|
* HTTP/1.1 200 OK
|
|
* {
|
|
* "name": "Tavern",
|
|
* ...
|
|
* }
|
|
*
|
|
* @apiUse groupIdRequired
|
|
* @apiUse GroupNotFound
|
|
*
|
|
* @apiPermission GroupLeader, Admin
|
|
*/
|
|
api.updateGroup = {
|
|
method: 'PUT',
|
|
url: '/groups/:groupId',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
|
|
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
|
req.checkBody('summary', apiError('summaryLengthExceedsMax')).isLength({ max: MAX_SUMMARY_SIZE_FOR_GUILDS });
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
const optionalMembership = Boolean(user.hasPermission('moderator'));
|
|
const group = await Group.getGroup({ user, groupId: req.params.groupId, optionalMembership });
|
|
|
|
if (!group) throw new NotFound(res.t('groupNotFound'));
|
|
|
|
if (user.hasPermission('moderator')) {
|
|
if (req.body.bannedWordsAllowed === true) {
|
|
group.bannedWordsAllowed = true;
|
|
} else {
|
|
group.bannedWordsAllowed = false;
|
|
}
|
|
}
|
|
|
|
if (group.leader !== user._id && group.type === 'party') throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
|
|
else if (group.leader !== user._id && !user.hasPermission('moderator')) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
|
|
|
|
if (req.body.leader && req.body.leader !== user._id && group.hasNotCancelled()) {
|
|
throw new NotAuthorized(res.t('cannotChangeLeaderWithActiveGroupPlan'));
|
|
}
|
|
|
|
const handleArrays = (currentValue, updatedValue) => {
|
|
if (!isArray(currentValue)) {
|
|
return undefined;
|
|
}
|
|
|
|
// Previously, categories could get duplicated. By making the updated category list unique,
|
|
// the duplication issue is fixed on every group edit
|
|
return uniqBy(updatedValue, 'slug');
|
|
};
|
|
|
|
assign(group, mergeWith(group.toObject(), Group.sanitizeUpdate(req.body), handleArrays));
|
|
|
|
const savedGroup = await group.save();
|
|
const response = await Group.toJSONCleanChat(savedGroup, user);
|
|
|
|
// If the leader changed fetch new data, otherwise use authenticated user
|
|
if (response.leader !== user._id) {
|
|
const rawLeader = await User.findById(response.leader).select(nameFields).exec();
|
|
response.leader = rawLeader.toJSON({ minimize: true });
|
|
} else {
|
|
response.leader = {
|
|
_id: user._id,
|
|
profile: { name: user.profile.name },
|
|
};
|
|
}
|
|
res.respond(200, response);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {post} /api/v3/groups/:groupId/join Join a group
|
|
* @apiName JoinGroup
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg'
|
|
* for tavern are accepted).
|
|
*
|
|
* @apiParamExample {String} Tavern:
|
|
* /api/v3/groups/habitrpg/join
|
|
*
|
|
* @apiSuccess {Object} data The joined group (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
|
|
*
|
|
* @apiSuccessExample {json} Tavern:
|
|
* HTTP/1.1 200 OK
|
|
* {
|
|
* "name": "Tavern",
|
|
* ...
|
|
* }
|
|
*
|
|
* @apiUse groupIdRequired
|
|
* @apiUse GroupNotFound
|
|
* @apiUse messageGroupRequiresInvite
|
|
*/
|
|
api.joinGroup = {
|
|
method: 'POST',
|
|
url: '/groups/:groupId/join',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
let inviter;
|
|
|
|
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party'
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
// Works even if the user is not yet a member of the group
|
|
// Do not fetch chat and work even if the user is not yet a member of the group
|
|
const group = await Group
|
|
.getGroup({ user, groupId: req.params.groupId, optionalMembership: true });
|
|
if (!group) throw new NotFound(res.t('groupNotFound'));
|
|
|
|
let isUserInvited = false;
|
|
const seekingParty = Boolean(user.party.seeking);
|
|
|
|
if (group.type === 'party') {
|
|
// Check if was invited to party
|
|
const inviterParty = find(user.invitations.parties, { id: group._id });
|
|
if (inviterParty) {
|
|
// Check if the user is already a member of the party or not. Only make the user leave the
|
|
// party if the user is not a member of the party. See #12291 for more details.
|
|
if (user.party._id !== group._id) {
|
|
inviter = inviterParty.inviter;
|
|
|
|
// If user was in a different party (when partying solo you can be invited to a new party)
|
|
// make them leave that party before doing anything
|
|
if (user.party._id) {
|
|
const userPreviousParty = await Group.getGroup({ user, groupId: user.party._id });
|
|
|
|
if (userPreviousParty.memberCount === 1 && user.party.quest.key) {
|
|
throw new NotAuthorized(res.t('messageCannotLeaveWhileQuesting'));
|
|
}
|
|
|
|
if (userPreviousParty) await userPreviousParty.leave(user);
|
|
}
|
|
}
|
|
// Clear all invitations of new user and reset looking for party state
|
|
user.invitations.parties = [];
|
|
user.invitations.party = {};
|
|
user.party.seeking = undefined;
|
|
|
|
// invite new user to pending quest
|
|
if (group.quest.key && !group.quest.active) {
|
|
user.party.quest.RSVPNeeded = true;
|
|
user.party.quest.key = group.quest.key;
|
|
group.quest.members[user._id] = null;
|
|
group.markModified('quest.members');
|
|
}
|
|
|
|
user.party._id = group._id; // Set group as user's party
|
|
|
|
isUserInvited = true;
|
|
}
|
|
} else if (group.type === 'guild') {
|
|
const hasInvitation = removeFromArray(user.invitations.guilds, { id: group._id });
|
|
|
|
if (hasInvitation) {
|
|
isUserInvited = true;
|
|
} else {
|
|
isUserInvited = group.privacy !== 'private';
|
|
}
|
|
}
|
|
|
|
if (isUserInvited && group.type === 'guild') {
|
|
// if user is already a member (party is checked previously)
|
|
if (user.guilds.indexOf(group._id) !== -1) {
|
|
throw new NotAuthorized(res.t('youAreAlreadyInGroup'));
|
|
}
|
|
user.guilds.push(group._id); // Add group to user's guilds
|
|
if (!user.achievements.joinedGuild) {
|
|
user.achievements.joinedGuild = true;
|
|
user.addNotification('GUILD_JOINED_ACHIEVEMENT');
|
|
}
|
|
}
|
|
if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));
|
|
|
|
// @TODO: Review the need for this and if still needed, don't base this on memberCount
|
|
if (!group.hasNotCancelled() && group.memberCount === 0) {
|
|
group.leader = user._id; // If new user is only member -> set as leader
|
|
}
|
|
|
|
let promises = [user.save()];
|
|
|
|
if (group.type === 'party') {
|
|
// For parties we count the number of members from the database to get the correct value.
|
|
// See #12275 on why this is necessary and only done for parties.
|
|
const currentMembers = await group.getMemberCount({ excludeUserId: user._id });
|
|
// Load the inviter
|
|
if (inviter) inviter = await User.findById(inviter).exec();
|
|
|
|
// Check the inviter again, could be a deleted account
|
|
if (inviter) {
|
|
// Reward Inviter
|
|
if (!inviter.items.quests.basilist) {
|
|
inviter.items.quests.basilist = 0;
|
|
}
|
|
inviter.items.quests.basilist += 1;
|
|
inviter.markModified('items.quests');
|
|
promises.push(inviter.save());
|
|
}
|
|
group.memberCount = currentMembers + 1;
|
|
|
|
// Handle awarding party-related achievements
|
|
if (group.memberCount > 1) {
|
|
const notification = new UserNotification({ type: 'ACHIEVEMENT_PARTY_UP' });
|
|
|
|
promises.push(User.updateMany(
|
|
{
|
|
$or: [{ 'party._id': group._id }, { _id: user._id }],
|
|
'achievements.partyUp': { $ne: true },
|
|
},
|
|
{
|
|
$set: { 'achievements.partyUp': true },
|
|
$push: { notifications: notification.toObject() },
|
|
},
|
|
).exec());
|
|
}
|
|
|
|
if (group.memberCount > 3) {
|
|
const notification = new UserNotification({ type: 'ACHIEVEMENT_PARTY_ON' });
|
|
|
|
promises.push(User.updateMany(
|
|
{
|
|
$or: [{ 'party._id': group._id }, { _id: user._id }],
|
|
'achievements.partyOn': { $ne: true },
|
|
},
|
|
{
|
|
$set: { 'achievements.partyOn': true },
|
|
$push: { notifications: notification.toObject() },
|
|
},
|
|
).exec());
|
|
}
|
|
} else {
|
|
group.memberCount += 1;
|
|
}
|
|
|
|
promises.push(group.save());
|
|
|
|
const analyticsObject = {
|
|
user: pick(user, ['preferences', 'registeredThrough']),
|
|
uuid: user._id,
|
|
hitType: 'event',
|
|
category: 'behavior',
|
|
owner: false,
|
|
groupId: group._id,
|
|
groupType: group.type,
|
|
privacy: group.privacy,
|
|
headers: req.headers,
|
|
invited: isUserInvited,
|
|
seekingParty: group.type === 'party' ? seekingParty : null,
|
|
};
|
|
|
|
promises = await Promise.all(promises);
|
|
|
|
if (group.hasNotCancelled()) {
|
|
await payments.addSubToGroupUser(user, group);
|
|
await group.updateGroupPlan();
|
|
}
|
|
|
|
const response = await Group.toJSONCleanChat(group, user);
|
|
const leader = await User.findById(response.leader).select(nameFields).exec();
|
|
if (leader) {
|
|
response.leader = leader.toJSON({ minimize: true });
|
|
}
|
|
|
|
res.analytics.track('join group', analyticsObject);
|
|
|
|
res.respond(200, response);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {post} /api/v3/groups/:groupId/reject-invite Reject a group invitation
|
|
* @apiName RejectGroupInvite
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg'
|
|
* for tavern are accepted).
|
|
*
|
|
* @apiParamExample {String} party:
|
|
* /api/v3/groups/party/reject-invite
|
|
*
|
|
* @apiSuccess {Object} data An empty object
|
|
*
|
|
* @apiUse groupIdRequired
|
|
* @apiUse messageGroupRequiresInvite
|
|
*/
|
|
api.rejectGroupInvite = {
|
|
method: 'POST',
|
|
url: '/groups/:groupId/reject-invite',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
|
|
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party'
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const { groupId } = req.params;
|
|
let isUserInvited = false;
|
|
|
|
const hasPartyInvitation = removeFromArray(user.invitations.parties, { id: groupId });
|
|
if (hasPartyInvitation) {
|
|
user.invitations.party = user.invitations.parties.length > 0
|
|
? user.invitations.parties[user.invitations.parties.length - 1]
|
|
: {};
|
|
user.markModified('invitations.party');
|
|
isUserInvited = true;
|
|
} else {
|
|
const hasInvitation = removeFromArray(user.invitations.guilds, { id: groupId });
|
|
|
|
if (hasInvitation) {
|
|
isUserInvited = true;
|
|
}
|
|
}
|
|
|
|
if (!isUserInvited) throw new NotAuthorized(res.t('messageGroupRequiresInvite'));
|
|
|
|
await user.save();
|
|
|
|
res.respond(200, {});
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {post} /api/v3/groups/:groupId/leave Leave a group
|
|
* @apiName LeaveGroup
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Path) {String} groupId The group _id ('party' for the user party and 'habitrpg'
|
|
* for tavern are accepted).
|
|
* @apiParam (Query) {String="remove-all","keep-all"} keep=keep-all Whether or not to keep
|
|
* challenge tasks belonging to
|
|
* the group being left.
|
|
* @apiParam (Body) {String="remain-in-challenges"
|
|
* ,"leave-challenges"} [keepChallenges=leave-challenges] Whether or not
|
|
* to remain in the
|
|
* challenges of the
|
|
* group being left.
|
|
*
|
|
* @apiParamExample {json} Leave Party:
|
|
* /api/v3/groups/party/leave
|
|
* {
|
|
* "keepChallenges": "remain-in-challenges"
|
|
* }
|
|
*
|
|
* @apiError (400) {BadRequest} keepOrRemoveAll "keep" parameter is not "remove-all" or "keep-all"
|
|
* @apiError (400) {NotAuthorized} questLeaderCannotLeaveGroup User could not leave party because
|
|
* they are the owner of a quest
|
|
* currently running.
|
|
* @apiError (400) {NotAuthorized} cannotLeaveWhileActiveQuest User could not leave party due to
|
|
* being in a quest.
|
|
*
|
|
* @apiSuccess {Object} data An empty object
|
|
*
|
|
* @apiUse groupIdRequired
|
|
* @apiUse GroupNotFound
|
|
*/
|
|
api.leaveGroup = {
|
|
method: 'POST',
|
|
url: '/groups/:groupId/leave',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
|
// When removing the user from challenges, should we keep the tasks?
|
|
req.checkQuery('keep', apiError('keepOrRemoveAll')).optional().isIn(['keep-all', 'remove-all']);
|
|
req.checkBody('keepChallenges', apiError('groupRemainOrLeaveChallenges')).optional().isIn(['remain-in-challenges', 'leave-challenges']);
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const { groupId } = req.params;
|
|
await leaveGroup({
|
|
res,
|
|
user,
|
|
groupId,
|
|
keep: req.query.keep,
|
|
keepChallenges: req.body.keepChallenges,
|
|
});
|
|
res.respond(200, {});
|
|
},
|
|
};
|
|
|
|
// Send an email to the removed user with an optional message from the leader
|
|
function _sendMessageToRemoved (group, removedUser, message, isInGroup) {
|
|
if (removedUser.preferences.emailNotifications.kickedGroup !== false) {
|
|
const subject = isInGroup ? `kicked-from-${group.type}` : `${group.type}-invite-rescinded`;
|
|
sendTxnEmail(removedUser, subject, [
|
|
{ name: 'GROUP_NAME', content: group.name },
|
|
{ name: 'MESSAGE', content: message },
|
|
{ name: 'GUILDS_LINK', content: '/groups/discovery' },
|
|
{ name: 'PARTY_WANTED_GUILD', content: '/groups/guild/f2db2a7f-13c5-454d-b3ee-ea1f5089e601' },
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @api {post} /api/v3/groups/:groupId/removeMember/:memberId Remove a member from a group
|
|
* @apiName RemoveGroupMember
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Path) {String} groupId The group _id ('party' for the user party and 'habitrpg'
|
|
* for tavern are accepted).
|
|
* @apiParam (Path) {UUID} memberId The _id of the member to remove
|
|
* @apiParam (Query) {String} message Query parameter - The message to send to the removed members
|
|
*
|
|
* @apiParamExample {URL} Remove member from party:
|
|
* /api/v3/groups/party/removeMember/[User's ID]?message=Bye
|
|
*
|
|
* @apiError (400) {BadRequest} userIdrequired "memberId" cannot be empty or not a UUID
|
|
* @apiError (401) {NotAuthorized} onlyLeaderCanRemoveMember Only the group
|
|
leader can remove members.
|
|
* @apiError (401) {NotAuthorized} memberCannotRemoveYourself Group leader cannot remove themselves
|
|
* @apiError (401) {NotAuthorized} cannotRemoveQuestOwner Group leader cannot remove
|
|
the owner of an active quest
|
|
* @apiError (404) {NotFound} groupMemberNotFound Group member was not found
|
|
*
|
|
* @apiSuccess {Object} data An empty object
|
|
*
|
|
* @apiPermission GroupLeader, Admin
|
|
*
|
|
* @apiUse groupIdRequired
|
|
* @apiUse GroupNotFound
|
|
*/
|
|
api.removeGroupMember = {
|
|
method: 'POST',
|
|
url: '/groups/:groupId/removeMember/:memberId',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
|
|
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
|
req.checkParams('memberId', res.t('userIdRequired')).notEmpty().isUUID();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
const optionalMembership = Boolean(user.hasPermission('moderator'));
|
|
const group = await Group.getGroup({
|
|
user, groupId: req.params.groupId, optionalMembership, fields: '-chat',
|
|
}); // Do not fetch chat
|
|
|
|
if (!group) throw new NotFound(res.t('groupNotFound'));
|
|
|
|
const uuid = req.params.memberId;
|
|
|
|
if (group.leader !== user._id && group.type === 'party') throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
|
|
if (group.leader !== user._id && !user.hasPermission('moderator')) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
|
|
|
|
if (group.leader === uuid && user.hasPermission('moderator')) throw new NotAuthorized(res.t('cannotRemoveCurrentLeader'));
|
|
|
|
if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself'));
|
|
|
|
const member = await User.findOne({ _id: uuid }).exec();
|
|
|
|
// We're removing the user from a guild or a party? is the user invited only?
|
|
let isInGroup;
|
|
if (member.party._id === group._id) {
|
|
isInGroup = 'party';
|
|
} else if (member.guilds.indexOf(group._id) !== -1) {
|
|
isInGroup = 'guild';
|
|
}
|
|
|
|
let isInvited;
|
|
if (find(member.invitations.parties, { id: group._id })) {
|
|
isInvited = 'party';
|
|
} else if (findIndex(member.invitations.guilds, { id: group._id }) !== -1) {
|
|
isInvited = 'guild';
|
|
}
|
|
|
|
if (isInGroup) {
|
|
// For parties we count the number of members from the database to get the correct value.
|
|
// See #12275 on why this is necessary and only done for parties.
|
|
if (group.type === 'party') {
|
|
const currentMembers = await group.getMemberCount();
|
|
group.memberCount = currentMembers - 1;
|
|
} else {
|
|
group.memberCount -= 1;
|
|
}
|
|
|
|
if (group.quest && group.quest.leader === member._id) {
|
|
throw new NotAuthorized(res.t('cannotRemoveQuestOwner'));
|
|
} else if (group.quest && group.quest.members) {
|
|
// remove member from quest
|
|
delete group.quest.members[member._id];
|
|
group.markModified('quest.members');
|
|
}
|
|
|
|
if (isInGroup === 'guild') {
|
|
removeFromArray(member.guilds, group._id);
|
|
}
|
|
if (isInGroup === 'party') {
|
|
member.party._id = undefined; // TODO remove quest information too? Use group.leave()?
|
|
}
|
|
|
|
removeMessagesFromMember(member, group._id);
|
|
|
|
if (group.quest && group.quest.active && group.quest.leader === member._id) {
|
|
member.items.quests[group.quest.key] += 1;
|
|
member.markModified('items.quests');
|
|
}
|
|
} else if (isInvited) {
|
|
if (isInvited === 'guild') {
|
|
removeFromArray(member.invitations.guilds, { id: group._id });
|
|
}
|
|
if (isInvited === 'party') {
|
|
removeFromArray(member.invitations.parties, { id: group._id });
|
|
member.invitations.party = member.invitations.parties.length > 0
|
|
? member.invitations.parties[member.invitations.parties.length - 1]
|
|
: {};
|
|
member.markModified('invitations.party');
|
|
}
|
|
} else {
|
|
throw new NotFound(res.t('groupMemberNotFound'));
|
|
}
|
|
|
|
const message = req.query.message || req.body.message;
|
|
_sendMessageToRemoved(group, member, message, isInGroup);
|
|
|
|
await Promise.all([
|
|
member.save(),
|
|
group.save(),
|
|
]);
|
|
|
|
if (isInGroup && group.hasNotCancelled()) {
|
|
await group.updateGroupPlan(true);
|
|
await payments.cancelGroupSubscriptionForUser(member, group, true);
|
|
}
|
|
|
|
res.respond(200, {});
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {post} /api/v3/groups/:groupId/invite Invite users to a group
|
|
* @apiName InviteToGroup
|
|
* @apiGroup Group
|
|
* @apiDescription You can provide both `emails` and `uuids`, or just one.
|
|
* You must provide at least one.
|
|
*
|
|
* @apiParam (Path) {String} groupId The group _id ('party' for the user party and 'habitrpg'
|
|
* for tavern are accepted)
|
|
*
|
|
* @apiParam (Body) {Object[]} [emails] An array of objects, each representing one
|
|
* email address to invite.
|
|
* @apiParam (Body) {String} emails.email The email address of the user being invited.
|
|
* @apiParam (Body) {String} [emails.name] The name of the user being invited.
|
|
* @apiParam (Body) {Array} [uuids] An array of uuids to invite
|
|
*
|
|
* @apiParamExample {json} Emails
|
|
* {
|
|
* "emails": [
|
|
* {"email": "user-1@example.com"},
|
|
* {"name": "User2", "email": "user-2@example.com"}
|
|
* ]
|
|
* }
|
|
* @apiParamExample {json} User IDs
|
|
* {
|
|
* "uuids": ["user-id-of-existing-user", "user-id-of-another-existing-user"]
|
|
* }
|
|
* @apiParamExample {json} User IDs and Emails
|
|
* {
|
|
* "emails": [
|
|
* {"email": "user-1@example.com"},
|
|
* {"email": "user-2@example.com"}
|
|
* ],
|
|
* "uuids": ["user-id-of-existing-user"]
|
|
* }
|
|
*
|
|
* @apiSuccess {Array} data The invites
|
|
* @apiSuccess {Object} data[0] If the invitation was a User ID, you'll receive back an object.
|
|
* You'll receive one Object for each successful User ID invite.
|
|
* @apiSuccess {String} data[1] If the invitation was an email, you'll receive back the email.
|
|
* You'll receive one String for each successful email invite.
|
|
*
|
|
* @apiSuccessExample {json} Successful Response with Emails
|
|
* {
|
|
* "data": [
|
|
* "user-1@example.com",
|
|
* "user-2@exmaple.com"
|
|
* ]
|
|
* }
|
|
*
|
|
* @apiSuccessExample {json} Successful Response with User ID
|
|
* {
|
|
* "data": [
|
|
* { id: 'the-id-of-the-invited-user', name: 'The group name', inviter: 'your-user-id' }
|
|
* ]
|
|
* }
|
|
* @apiSuccessExample {json} Successful Response with User IDs and Emails
|
|
* {
|
|
* "data": [
|
|
* "user-1@example.com",
|
|
* { id: 'the-id-of-the-invited-user', name: 'The group name', inviter: 'your-user-id' },
|
|
* "user-2@exmaple.com"
|
|
* ]
|
|
* }
|
|
*
|
|
* @apiUse GroupBodyInvalid
|
|
*
|
|
* @apiError (400) {BadRequest} NoEmailProvided An email address was not provided
|
|
* in the `emails` body param `Array`.
|
|
* @apiError (400) {BadRequest} UuidOrEmailOnly The `emails` and `uuids` params
|
|
* were both missing and/or a.
|
|
* key other than `emails` or `uuids` was provided
|
|
* in the body param.
|
|
* @apiError (400) {BadRequest} CannotInviteSelf User ID or email of invitee matches
|
|
* that of the inviter.
|
|
* @apiError (400) {BadRequest} MustBeArray The `uuids` or `emails` body param was not an array.
|
|
* @apiError (400) {BadRequest} TooManyInvites A max of 100 invites (combined
|
|
* emails and User IDs) can
|
|
* be sent out at a time.
|
|
* @apiError (400) {BadRequest} ExceedsMembersLimit A max of 30 members can join a party.
|
|
*
|
|
* @apiError (401) {NotAuthorized} UserAlreadyInvited The user has already
|
|
* been invited to the group.
|
|
* @apiError (401) {NotAuthorized} UserAlreadyInGroup The user is already a member of the group.
|
|
* @apiError (401) {NotAuthorized} CannotInviteWhenMuted You cannot invite anyone
|
|
* to a guild or party because your
|
|
* chat privileges have been revoked.
|
|
* @apiError (401) {NotAuthorized} NotAuthorizedToSendMessageToThisUser You can't send a
|
|
* message to this player
|
|
* because they have chosen to
|
|
* block messages.
|
|
*
|
|
* @apiUse GroupNotFound
|
|
* @apiUse UserNotFound
|
|
* @apiUse PartyNotFound
|
|
*/
|
|
api.inviteToGroup = {
|
|
method: 'POST',
|
|
url: '/groups/:groupId/invite',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
|
|
if (user.flags.chatRevoked) throw new NotAuthorized(res.t('chatPrivilegesRevoked'));
|
|
|
|
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty();
|
|
|
|
if (user.invitesSent >= MAX_EMAIL_INVITES_BY_USER) throw new NotAuthorized(res.t('inviteLimitReached', { techAssistanceEmail: TECH_ASSISTANCE_EMAIL }));
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const group = await Group.getGroup({ user, groupId: req.params.groupId, fields: '-chat' });
|
|
if (!group) throw new NotFound(res.t('groupNotFound'));
|
|
|
|
if (group.purchased && group.purchased.plan.customerId && user._id !== group.leader) throw new NotAuthorized(res.t('onlyGroupLeaderCanInviteToGroupPlan'));
|
|
|
|
const {
|
|
uuids,
|
|
emails,
|
|
usernames,
|
|
} = req.body;
|
|
|
|
await Group.validateInvitations({
|
|
uuids,
|
|
emails,
|
|
usernames,
|
|
}, res, group);
|
|
|
|
const results = [];
|
|
|
|
if (uuids) {
|
|
const uuidInvites = uuids.map(uuid => inviteByUUID(uuid, group, user, req, res));
|
|
const uuidResults = await Promise.all(uuidInvites);
|
|
results.push(...uuidResults);
|
|
}
|
|
|
|
if (emails) {
|
|
const emailInvites = emails.map(invite => inviteByEmail(invite, group, user, req, res));
|
|
user.invitesSent += emails.length;
|
|
await user.save();
|
|
const emailResults = await Promise.all(emailInvites);
|
|
results.push(...emailResults);
|
|
}
|
|
|
|
if (usernames) {
|
|
const usernameInvites = usernames
|
|
.map(username => inviteByUserName(username, group, user, req, res));
|
|
const usernameResults = await Promise.all(usernameInvites);
|
|
results.push(...usernameResults);
|
|
}
|
|
|
|
res.respond(200, results);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {post} /api/v3/groups/:groupId/add-manager Add a manager to a group
|
|
* @apiName AddGroupManager
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg'
|
|
* for tavern are accepted).
|
|
*
|
|
* @apiParamExample {String} party:
|
|
* /api/v3/groups/party/add-manager
|
|
*
|
|
* @apiParam (Body) {UUID} managerId The user _id of the member to promote to manager
|
|
*
|
|
* @apiSuccess {Object} data An empty object
|
|
*
|
|
* @apiError (400) {NotAuthorized} managerId req.body.managerId is required
|
|
* @apiUse groupIdRequired
|
|
*/
|
|
api.addGroupManager = {
|
|
method: 'POST',
|
|
url: '/groups/:groupId/add-manager',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
const { managerId } = req.body;
|
|
|
|
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party'
|
|
req.checkBody('managerId', apiError('managerIdRequired')).notEmpty();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const newManager = await User.findById(managerId, 'guilds party').exec();
|
|
const groupFields = basicGroupFields.concat(' managers');
|
|
const group = await Group.getGroup({ user, groupId: req.params.groupId, fields: groupFields });
|
|
if (!group) throw new NotFound(res.t('groupNotFound'));
|
|
|
|
if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
|
|
|
|
const isMember = group.isMember(newManager);
|
|
if (!isMember) throw new NotAuthorized(res.t('userMustBeMember'));
|
|
|
|
group.managers[managerId] = true;
|
|
group.markModified('managers');
|
|
await group.save();
|
|
|
|
res.respond(200, group);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {post} /api/v3/groups/:groupId/remove-manager Remove a manager from a group
|
|
* @apiName RemoveGroupManager
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg'
|
|
* for tavern are accepted).
|
|
*
|
|
* @apiParamExample {String} party:
|
|
* /api/v3/groups/party/add-manager
|
|
*
|
|
* @apiParam (Body) {UUID} managerId The user _id of the member to remove
|
|
*
|
|
* @apiSuccess {Object} group The group
|
|
*
|
|
* @apiError (400) {NotAuthorized} managerId req.body.managerId is required
|
|
* @apiUse groupIdRequired
|
|
*/
|
|
api.removeGroupManager = {
|
|
method: 'POST',
|
|
url: '/groups/:groupId/remove-manager',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
const { managerId } = req.body;
|
|
|
|
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); // .isUUID(); can't be used because it would block 'habitrpg' or 'party'
|
|
req.checkBody('managerId', apiError('managerIdRequired')).notEmpty();
|
|
|
|
const validationErrors = req.validationErrors();
|
|
if (validationErrors) throw validationErrors;
|
|
|
|
const groupFields = basicGroupFields.concat(' managers');
|
|
const group = await Group.getGroup({ user, groupId: req.params.groupId, fields: groupFields });
|
|
if (!group) throw new NotFound(res.t('groupNotFound'));
|
|
|
|
if (group.leader !== user._id) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
|
|
|
|
if (!group.managers[managerId]) throw new NotAuthorized(res.t('userIsNotManager'));
|
|
|
|
delete group.managers[managerId];
|
|
group.markModified('managers');
|
|
await group.save();
|
|
|
|
const manager = await User.findById(managerId, 'notifications').exec();
|
|
const newNotifications = manager.notifications.filter(notification => {
|
|
const isGroupTaskNotification = notification && notification.type && notification.type.indexOf('GROUP_TASK_') === 0;
|
|
|
|
return !isGroupTaskNotification;
|
|
});
|
|
manager.notifications = newNotifications;
|
|
manager.markModified('notifications');
|
|
await manager.save();
|
|
|
|
res.respond(200, group);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {get} /api/v3/group-plans Get group plans for a user
|
|
* @apiName GetGroupPlans
|
|
* @apiGroup Group
|
|
*
|
|
* @apiSuccess {Object[]} data An array of the requested groups with a group plan (See <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js" target="_blank">/website/server/models/group.js</a>)
|
|
*
|
|
* @apiSuccessExample {json} Groups the user is in with a group plan:
|
|
* HTTP/1.1 200 OK
|
|
* [
|
|
* {groupPlans}
|
|
* ]
|
|
*/
|
|
api.getGroupPlans = {
|
|
method: 'GET',
|
|
url: '/group-plans',
|
|
middlewares: [authWithHeaders({ userFieldsToInclude: ['guilds', 'party._id'] })],
|
|
async handler (req, res) {
|
|
const { user } = res.locals;
|
|
|
|
const userGroups = user.getGroups();
|
|
|
|
if (userGroups.length === 0) {
|
|
res.respond(200, []);
|
|
return;
|
|
}
|
|
|
|
const groups = await Group
|
|
.find({
|
|
_id: { $in: userGroups },
|
|
})
|
|
.select('leaderOnly leader purchased name managers')
|
|
.exec();
|
|
|
|
const groupPlans = groups.filter(group => group.hasActiveGroupPlan());
|
|
|
|
res.respond(200, groupPlans);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @api {get} /api/v3/looking-for-party Get users in search of parties
|
|
* @apiName GetLookingForParty
|
|
* @apiGroup Group
|
|
*
|
|
* @apiParam (Query) {Number} [page] Page number, defaults to 0
|
|
*
|
|
* @apiSuccess {Object[]} data An array of users looking for a party
|
|
*
|
|
* @apiError (400) {BadRequest} notPartyLeader You are not the leader of a Party.
|
|
*/
|
|
api.getLookingForParty = {
|
|
method: 'GET',
|
|
url: '/looking-for-party',
|
|
middlewares: [authWithHeaders()],
|
|
async handler (req, res) {
|
|
const USERS_PER_PAGE = 30;
|
|
const { user } = res.locals;
|
|
|
|
req.checkQuery('page').optional().isInt({ min: 0 }, apiError('queryPageInteger'));
|
|
const PAGE = req.query.page || 0;
|
|
const PAGE_START = USERS_PER_PAGE * PAGE;
|
|
|
|
let partyLed = false;
|
|
if (user.party._id) {
|
|
const party = await Group.getGroup({ user, groupId: user.party._id });
|
|
if (party && party.leader === user._id) {
|
|
partyLed = true;
|
|
}
|
|
}
|
|
|
|
if (!partyLed) {
|
|
throw new BadRequest(apiError('notPartyLeader'));
|
|
}
|
|
|
|
const seekers = await User
|
|
.find({
|
|
'party.seeking': { $exists: true },
|
|
'invitations.party.id': { $exists: false },
|
|
'auth.timestamps.loggedin': {
|
|
$gt: moment().subtract(7, 'days').toDate(),
|
|
},
|
|
})
|
|
// eslint-disable-next-line no-multi-str
|
|
.select('_id auth.blocked auth.local.username auth.timestamps backer contributor.level \
|
|
flags.chatRevoked flags.classSelected inbox.blocks invitations.party items.gear.costume \
|
|
items.gear.equipped loginIncentives party._id preferences.background preferences.chair \
|
|
preferences.costume preferences.hair preferences.shirt preferences.size preferences.skin \
|
|
preferences.language profile.name stats.buffs stats.class stats.lvl')
|
|
.sort('-auth.timestamps.loggedin')
|
|
.exec();
|
|
|
|
const filteredSeekers = seekers.filter(seeker => {
|
|
if (seeker.party._id) return false;
|
|
if (seeker.flags.chatRevoked) return false;
|
|
if (seeker.auth.blocked) return false;
|
|
if (seeker.inbox.blocks.indexOf(user._id) !== -1) return false;
|
|
if (user.inbox.blocks.indexOf(seeker._id) !== -1) return false;
|
|
return true;
|
|
}).slice(PAGE_START, PAGE_START + USERS_PER_PAGE);
|
|
|
|
const cleanedSeekers = filteredSeekers.map(seeker => ({
|
|
_id: seeker._id,
|
|
auth: {
|
|
local: {
|
|
username: seeker.auth.local.username,
|
|
},
|
|
timestamps: seeker.auth.timestamps,
|
|
},
|
|
backer: seeker.backer,
|
|
contributor: seeker.contributor,
|
|
flags: seeker.flags,
|
|
invited: false,
|
|
items: seeker.items,
|
|
loginIncentives: seeker.loginIncentives,
|
|
preferences: seeker.preferences,
|
|
profile: seeker.profile,
|
|
stats: seeker.stats,
|
|
}));
|
|
|
|
res.respond(200, cleanedSeekers);
|
|
},
|
|
};
|
|
|
|
export default api;
|