mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-19 15:48:04 +01:00
* Simplify cron code
use transactions for cron
remove only
bump mongoose to 8.x
remove deprecated config
fix race condition when users join a party
console debugging time
try calling transaction differently
add missing await
addditional console log
.
..
...
….
await
more debug log
mongoose logging
more logging
move session to encapsulate all of cron
delete old todos before fetching all tasks
changes
try waiting for mongoose connection
try adding timeout to time jump
cleanup and code refactoring
Translated using Weblate (Spanish)
Currently translated at 100.0% (821 of 821 strings)
Translated using Weblate (German)
Currently translated at 100.0% (821 of 821 strings)
Translated using Weblate (Japanese)
Currently translated at 87.0% (228 of 262 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 93.8% (107 of 114 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 18.1% (44 of 243 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 11.9% (29 of 243 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 88.1% (724 of 821 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (22 of 22 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 91.2% (104 of 114 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 7.4% (18 of 243 strings)
Translated using Weblate (Spanish)
Currently translated at 99.5% (817 of 821 strings)
Translated using Weblate (German)
Currently translated at 99.3% (816 of 821 strings)
Translated using Weblate (German)
Currently translated at 100.0% (3265 of 3265 strings)
Translated using Weblate (German)
Currently translated at 100.0% (3265 of 3265 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 98.2% (112 of 114 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 97.7% (131 of 134 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 69.1% (2257 of 3265 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 99.5% (239 of 240 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 16.4% (40 of 243 strings)
Translated using Weblate (German)
Currently translated at 99.9% (3264 of 3265 strings)
Translated using Weblate (Japanese)
Currently translated at 86.6% (227 of 262 strings)
Translated using Weblate (Japanese)
Currently translated at 100.0% (272 of 272 strings)
Translated using Weblate (Japanese)
Currently translated at 97.9% (423 of 432 strings)
Translated using Weblate (German)
Currently translated at 100.0% (262 of 262 strings)
Translated using Weblate (German)
Currently translated at 100.0% (432 of 432 strings)
Translated using Weblate (German)
Currently translated at 100.0% (240 of 240 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 14.8% (36 of 243 strings)
Translated using Weblate (German)
Currently translated at 99.1% (814 of 821 strings)
Translated using Weblate (German)
Currently translated at 100.0% (397 of 397 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 84.7% (222 of 262 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 84.3% (221 of 262 strings)
Translated using Weblate (German)
Currently translated at 100.0% (60 of 60 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (22 of 22 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.0% (415 of 432 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 94.2% (3077 of 3265 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 63.7% (155 of 243 strings)
Translated using Weblate (German)
Currently translated at 99.0% (813 of 821 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 99.7% (396 of 397 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 98.7% (885 of 896 strings)
Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (German)
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Japanese)
Currently translated at 97.4% (265 of 272 strings)
Translated using Weblate (Japanese)
Currently translated at 100.0% (114 of 114 strings)
Translated using Weblate (Japanese)
Currently translated at 100.0% (134 of 134 strings)
Translated using Weblate (Japanese)
Currently translated at 100.0% (260 of 260 strings)
Translated using Weblate (Japanese)
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Japanese)
Currently translated at 100.0% (397 of 397 strings)
Translated using Weblate (Japanese)
Currently translated at 100.0% (60 of 60 strings)
Update translation files
Updated by "Cleanup translation files" hook in Weblate.
Translated using Weblate (Japanese)
Currently translated at 98.7% (392 of 397 strings)
Translated using Weblate (Japanese)
Currently translated at 100.0% (240 of 240 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (262 of 262 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (114 of 114 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (60 of 60 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (432 of 432 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (240 of 240 strings)
Translated using Weblate (Spanish)
Currently translated at 99.0% (813 of 821 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (397 of 397 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (French)
Currently translated at 100.0% (262 of 262 strings)
Translated using Weblate (French)
Currently translated at 100.0% (60 of 60 strings)
Translated using Weblate (French)
Currently translated at 100.0% (432 of 432 strings)
Translated using Weblate (French)
Currently translated at 100.0% (3265 of 3265 strings)
Translated using Weblate (French)
Currently translated at 100.0% (240 of 240 strings)
Translated using Weblate (French)
Currently translated at 100.0% (821 of 821 strings)
Translated using Weblate (French)
Currently translated at 100.0% (397 of 397 strings)
Translated using Weblate (French)
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (French)
Currently translated at 100.0% (3265 of 3265 strings)
Update translation files
Updated by "Cleanup translation files" hook in Weblate.
Update translation files
Updated by "Cleanup translation files" hook in Weblate.
Translated using Weblate (French)
Currently translated at 100.0% (3265 of 3265 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (3255 of 3255 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (15 of 15 strings)
Co-authored-by: Asier Gallego <agr2367789@gmail.com>
Co-authored-by: Asier Gallego Roca <asiernoide@users.noreply.translate.habitica.com>
Co-authored-by: Henrique Ferreira <pedroferreira217.ph@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: John Doe (Anonymous) <shyamjayeshduck@duck.com>
Co-authored-by: Katharina <katharinaanna.wilding@gmail.com>
Co-authored-by: Marie Blosse--Gilbin <mbgil@hotmail.fr>
Co-authored-by: Mauricio Pérez <mauriciodavidperez@gmail.com>
Co-authored-by: Raul Ernesto Ceron Lara <raztreuzz1234@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Willhelm Winter <carapax@posteo.de>
Co-authored-by: mattya 226 <worldworld1114@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/en_GB/
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/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/content/de/
Translate-URL: https://translate.habitica.com/projects/habitica/content/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/content/es/
Translate-URL: https://translate.habitica.com/projects/habitica/content/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/content/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/death/es/
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/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/en_GB/
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/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/de/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/es/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/de/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/es/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/de/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/es/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/en_GB/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/es/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/ja/
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/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt_BR/
Translation: Habitica/Backgrounds
Translation: Habitica/Content
Translation: Habitica/Death
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Loginincentives
Translation: Habitica/Messages
Translation: Habitica/Npc
Translation: Habitica/Pets
Translation: Habitica/Questscontent
Translation: Habitica/Settings
Translation: Habitica/Subscriber
5.33.1
fix(links): next round of wiki revisions
Translated using Weblate (German)
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Portuguese)
Currently translated at 96.4% (864 of 896 strings)
Co-authored-by: Miya <baddybadges@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt/
Translation: Habitica/Backgrounds
5.33.2
Fix achievement display in admin panel (#15326)
Fix news related permission issues (#15287)
Support sprite version of armoire icon (#15354)
* Use sprite component for armoire sprite
* use gif version of armoire sprite
* fix(import): sprite component path
---------
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
log slow requests to loggly (#15364)
Update .eslintrc.js (#15388)
Add `require-await` to eslint config
Translated using Weblate (Japanese)
Currently translated at 93.0% (764 of 821 strings)
Translated using Weblate (Hungarian)
Currently translated at 54.8% (1790 of 3265 strings)
Translated using Weblate (Hungarian)
Currently translated at 53.5% (1748 of 3265 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (262 of 262 strings)
Translated using Weblate (Hungarian)
Currently translated at 52.1% (1704 of 3265 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Hungarian)
Currently translated at 59.3% (532 of 896 strings)
Translated using Weblate (Hungarian)
Currently translated at 79.3% (208 of 262 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (13 of 13 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (432 of 432 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (245 of 245 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (91 of 91 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 77.4% (2528 of 3265 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (167 of 167 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (167 of 167 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (167 of 167 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (272 of 272 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 93.0% (764 of 821 strings)
Translated using Weblate (French)
Currently translated at 100.0% (193 of 193 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (French)
Currently translated at 100.0% (260 of 260 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Hungarian)
Currently translated at 94.8% (258 of 272 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (245 of 245 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 95.2% (378 of 397 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Hungarian)
Currently translated at 82.8% (203 of 245 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (821 of 821 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 94.9% (377 of 397 strings)
Translated using Weblate (Hungarian)
Currently translated at 52.1% (1704 of 3265 strings)
Translated using Weblate (Hungarian)
Currently translated at 49.7% (122 of 245 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.1% (789 of 821 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (821 of 821 strings)
Translated using Weblate (Hungarian)
Currently translated at 48.5% (119 of 245 strings)
Translated using Weblate (Hungarian)
Currently translated at 26.1% (64 of 245 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (2 of 2 strings)
Translated using Weblate (Hungarian)
Currently translated at 8.9% (22 of 245 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (15 of 15 strings)
Translated using Weblate (Hungarian)
Currently translated at 96.2% (790 of 821 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (22 of 22 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (432 of 432 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (240 of 240 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 95.4% (784 of 821 strings)
Translated using Weblate (Hungarian)
Currently translated at 91.5% (752 of 821 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (91 of 91 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (397 of 397 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (47 of 47 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (193 of 193 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (114 of 114 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (60 of 60 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (397 of 397 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (193 of 193 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (193 of 193 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (260 of 260 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (94 of 94 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (134 of 134 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (240 of 240 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (47 of 47 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (260 of 260 strings)
Translated using Weblate (German)
Currently translated at 99.2% (133 of 134 strings)
Translated using Weblate (German)
Currently translated at 99.2% (133 of 134 strings)
Translated using Weblate (Czech)
Currently translated at 95.2% (159 of 167 strings)
Translated using Weblate (Russian)
Currently translated at 91.2% (2978 of 3265 strings)
Translated using Weblate (Russian)
Currently translated at 99.3% (890 of 896 strings)
Translated using Weblate (German)
Currently translated at 100.0% (3265 of 3265 strings)
Translated using Weblate (German)
Currently translated at 100.0% (3265 of 3265 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (134 of 134 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (47 of 47 strings)
Translated using Weblate (French)
Currently translated at 100.0% (134 of 134 strings)
Translated using Weblate (French)
Currently translated at 100.0% (47 of 47 strings)
Translated using Weblate (Korean)
Currently translated at 100.0% (56 of 56 strings)
Translated using Weblate (Korean)
Currently translated at 98.5% (132 of 134 strings)
Translated using Weblate (Korean)
Currently translated at 6.9% (17 of 245 strings)
Translated using Weblate (Korean)
Currently translated at 71.9% (645 of 896 strings)
Translated using Weblate (Korean)
Currently translated at 49.2% (129 of 262 strings)
Translated using Weblate (Korean)
Currently translated at 100.0% (13 of 13 strings)
Translated using Weblate (Korean)
Currently translated at 81.9% (77 of 94 strings)
Translated using Weblate (Korean)
Currently translated at 91.6% (153 of 167 strings)
Translated using Weblate (Korean)
Currently translated at 67.3% (291 of 432 strings)
Translated using Weblate (Korean)
Currently translated at 79.5% (191 of 240 strings)
Translated using Weblate (Korean)
Currently translated at 54.6% (1785 of 3265 strings)
Translated using Weblate (Korean)
Currently translated at 88.8% (48 of 54 strings)
Translated using Weblate (Korean)
Currently translated at 89.3% (42 of 47 strings)
Translated using Weblate (Korean)
Currently translated at 93.9% (373 of 397 strings)
Translated using Weblate (Korean)
Currently translated at 54.9% (50 of 91 strings)
Translated using Weblate (German)
Currently translated at 100.0% (182 of 182 strings)
Translated using Weblate (German)
Currently translated at 100.0% (182 of 182 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 73.0% (179 of 245 strings)
Translated using Weblate (French)
Currently translated at 100.0% (245 of 245 strings)
Translated using Weblate (German)
Currently translated at 99.1% (243 of 245 strings)
Translated using Weblate (French)
Currently translated at 99.5% (244 of 245 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 62.0% (152 of 245 strings)
Translated using Weblate (Indonesian)
Currently translated at 73.4% (180 of 245 strings)
Translated using Weblate (Indonesian)
Currently translated at 96.0% (861 of 896 strings)
Translated using Weblate (Spanish (Latin America))
Currently translated at 62.0% (152 of 245 strings)
Translated using Weblate (German)
Currently translated at 98.7% (242 of 245 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (245 of 245 strings)
Translated using Weblate (Spanish)
Currently translated at 99.5% (244 of 245 strings)
Translated using Weblate (Portuguese)
Currently translated at 33.7% (82 of 243 strings)
Translated using Weblate (Portuguese)
Currently translated at 73.3% (602 of 821 strings)
Translated using Weblate (Portuguese)
Currently translated at 56.0% (51 of 91 strings)
Translated using Weblate (German)
Currently translated at 100.0% (167 of 167 strings)
Translated using Weblate (German)
Currently translated at 100.0% (167 of 167 strings)
Translated using Weblate (Portuguese)
Currently translated at 97.2% (107 of 110 strings)
Translated using Weblate (Portuguese)
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Portuguese)
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Portuguese)
Currently translated at 100.0% (896 of 896 strings)
Co-authored-by: César Orlando Pallares Delgado <copdeb@gmail.com>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Diego Benitez <diego.benitez@bigpond.com>
Co-authored-by: Finrod <963505255@qq.com>
Co-authored-by: ForbiddenFigs <sorautai@outlook.com>
Co-authored-by: Hexe des Windes (she/her) <krausanna1@gmail.com>
Co-authored-by: Icaro <icaro.mascarenhas@outlook.com>
Co-authored-by: Ikmal <ikmal.s.16@gmail.com>
Co-authored-by: Jackal <qwerty70244@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Katharina <katharinaanna.wilding@gmail.com>
Co-authored-by: Leslie Munguía <moongeeuh@gmail.com>
Co-authored-by: Lio Zam <zerofux@web.de>
Co-authored-by: Marius <mariusschmid11@gmail.com>
Co-authored-by: Miya <baddybadges@gmail.com>
Co-authored-by: Natalie Luhrs <eilatan@gmail.com>
Co-authored-by: Raul Ernesto Ceron Lara <raztreuzz1234@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Viktor Révész <rviktor@ivankapal.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: 小王 <963505255@qq.com>
Co-authored-by: 이채린 <cofls1256@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/cs/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/de/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/id/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/character/es/
Translate-URL: https://translate.habitica.com/projects/habitica/character/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/character/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/character/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/content/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/content/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/content/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/es/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/death/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/defaulttasks/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/de/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/es/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/id/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/front/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/noscript/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/de/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/es/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/rebirth/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/es/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/spells/ko/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ko/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Communityguidelines
Translation: Habitica/Content
Translation: Habitica/Contrib
Translation: Habitica/Death
Translation: Habitica/Defaulttasks
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Loginincentives
Translation: Habitica/Messages
Translation: Habitica/Noscript
Translation: Habitica/Npc
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Rebirth
Translation: Habitica/Settings
Translation: Habitica/Spells
Translation: Habitica/Subscriber
5.33.3
March 2025 Content Build (#15392)
* build: March 2025 css, backgrounds, subscriber gear, armoire
* build: March 2025 quests, seasonal gear, various fixes
* fix: fix string
* fix: fixes to string errors
* fix: string fixes
wait for mongoose connection on timetravel
rework broken cron recovery
remove lodash from cron code
remove old cron notification
Simplify cron code
fix unit tests
Remove unnecessary user fetch
Further code simplification
fix test check
lint fix
disable world boss calculation during cron for now
prevent saving user twice in paralllel when leaving group plan
correctly call cron in api call
remove console
fix tests failing
mark cronSignature as modified
fix test
Translated using Weblate (Spanish)
Currently translated at 99.5% (3288 of 3303 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (836 of 836 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (836 of 836 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (899 of 899 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (836 of 836 strings)
Translated using Weblate (Spanish)
Currently translated at 99.5% (832 of 836 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (836 of 836 strings)
Translated using Weblate (German)
Currently translated at 98.8% (826 of 836 strings)
Translated using Weblate (Russian)
Currently translated at 40.8% (100 of 245 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (836 of 836 strings)
Translated using Weblate (French)
Currently translated at 100.0% (836 of 836 strings)
Translated using Weblate (Russian)
Currently translated at 40.4% (99 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 40.0% (98 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 40.0% (98 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 39.1% (96 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 91.2% (219 of 240 strings)
Translated using Weblate (Russian)
Currently translated at 38.7% (95 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 38.7% (95 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 38.7% (95 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 37.5% (92 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 37.1% (91 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 36.7% (90 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 100.0% (60 of 60 strings)
Translated using Weblate (Russian)
Currently translated at 90.8% (218 of 240 strings)
Translated using Weblate (Russian)
Currently translated at 90.8% (218 of 240 strings)
Translated using Weblate (Russian)
Currently translated at 36.3% (89 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 36.3% (89 of 245 strings)
Translated using Weblate (Russian)
Currently translated at 100.0% (899 of 899 strings)
Translated using Weblate (Russian)
Currently translated at 99.3% (893 of 899 strings)
Translated using Weblate (Russian)
Currently translated at 99.2% (892 of 899 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (836 of 836 strings)
Translated using Weblate (Spanish)
Currently translated at 99.4% (831 of 836 strings)
Translated using Weblate (Hungarian)
Currently translated at 62.6% (2068 of 3303 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 97.9% (804 of 821 strings)
Translated using Weblate (Portuguese)
Currently translated at 72.0% (602 of 836 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (836 of 836 strings)
Translated using Weblate (Spanish)
Currently translated at 99.1% (829 of 836 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 98.4% (885 of 899 strings)
Translated using Weblate (Portuguese)
Currently translated at 99.6% (896 of 899 strings)
Translated using Weblate (Hungarian)
Currently translated at 57.9% (1915 of 3303 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 97.4% (800 of 821 strings)
Translated using Weblate (Hungarian)
Currently translated at 57.6% (1903 of 3303 strings)
Translated using Weblate (Hungarian)
Currently translated at 57.5% (1900 of 3303 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 97.0% (797 of 821 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (836 of 836 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (899 of 899 strings)
Translated using Weblate (Bulgarian)
Currently translated at 84.0% (79 of 94 strings)
Translated using Weblate (Bulgarian)
Currently translated at 84.0% (79 of 94 strings)
Translated using Weblate (Spanish)
Currently translated at 98.4% (823 of 836 strings)
Translated using Weblate (Spanish)
Currently translated at 98.7% (3263 of 3303 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.8% (795 of 821 strings)
Translated using Weblate (Spanish)
Currently translated at 98.3% (822 of 836 strings)
Translated using Weblate (Spanish)
Currently translated at 98.5% (3256 of 3303 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.8% (795 of 821 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.8% (795 of 821 strings)
Translated using Weblate (French)
Currently translated at 98.4% (823 of 836 strings)
Translated using Weblate (French)
Currently translated at 100.0% (3303 of 3303 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.5% (793 of 821 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (899 of 899 strings)
Translated using Weblate (French)
Currently translated at 99.8% (3297 of 3303 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.4% (792 of 821 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (899 of 899 strings)
Translated using Weblate (French)
Currently translated at 99.3% (3280 of 3303 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 99.7% (897 of 899 strings)
Translated using Weblate (French)
Currently translated at 99.1% (3275 of 3303 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 99.7% (897 of 899 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 99.7% (897 of 899 strings)
Translated using Weblate (German)
Currently translated at 100.0% (899 of 899 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 99.7% (897 of 899 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 97.6% (3187 of 3265 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 99.7% (897 of 899 strings)
Translated using Weblate (French)
Currently translated at 100.0% (899 of 899 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (262 of 262 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (272 of 272 strings)
Translated using Weblate (Hungarian)
Currently translated at 58.1% (1898 of 3265 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 62.8% (154 of 245 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (15 of 15 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.4% (792 of 821 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (91 of 91 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 95.2% (378 of 397 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (397 of 397 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (896 of 896 strings)
Translated using Weblate (Hungarian)
Currently translated at 100.0% (896 of 896 strings)
Co-authored-by: Anna <shiloanna007@gmail.com>
Co-authored-by: Besogon <victoria_murka@mail.ru>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: ForbiddenFigs <sorautai@outlook.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Nell Chant <doubletailor@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Viktor Révész <rviktor@ivankapal.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: razil <boss.razmarin@gmail.com>
Co-authored-by: 小王 <963505255@qq.com>
Co-authored-by: 海岛钓鱼佬 <963505255@qq.com>
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/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/content/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/death/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ru/
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/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/bg/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/hu/
Translation: Habitica/Backgrounds
Translation: Habitica/Communityguidelines
Translation: Habitica/Content
Translation: Habitica/Death
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Limited
Translation: Habitica/Messages
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Subscriber
5.34.0
Update test.yml (#15397)
combined messages restyling - next round (#15386)
* split component prepare new views / states
* extract empty and disabled state as components
* fix empty state mail icon
* first logic switching between modes, move page to /private-messages/index.vue
* extract autoCompleteHelper.js
* style header + start new message input
* style plus button + focus input
* state logic, types for sanity
* WIP PM new Message started
* add /members/username test
* first design changes to messageCard
* delete private message or chat - based on the mode
* copy as todo
* mention links to modal
* report chat or private message
* WIP likeButton
* likeButton styling
* hide like on private message cards
* fix unit test
* replace copy as todo - to just a copy to clipboard
* style changes
* menu position + like button width
* dropdown items background + like font
* fix like button padding
* move api endpoints and tests around to group inbox methods + like for inbox private messages
* restyle system messages
* Dropdown Radius and Padding
* WIP system messages
* fix lint
* copy delta commit of allowing liking own private messages
* enable liking private messages
* fix menu non hovered item icon color
* fix import path
* ignore background on system messages
* requested changes + migration
* update migration to update the unique id to some messages and delete the duplicates
* migration based on users pagination
* fix(migration): use Promise.all
* change to bulkWrites per User, and all messages in one run (of a user)
* check for array
* use rest operator ...
* skip sorting to get the users
* remove migration, disable like for private messages without uniqueMessageId
* lean+bulkWrite for likes, add time checks for like and auth for further debugging
* add a limit 2 get the messages by uniqueId
* Adding a simple server start script
* remove pinned nodemon dep
* fix inbox controller/tests
* fix / requested style changes
* fix empty state padding /
* hide avatar weapons on messages - fix avatar spacing on messages
* Hourglass Simplification (#15323)
* begin removing obsolete tests
* begin refactoring
* update cron tests
* cleanup
* finish basic implementation of new logic
* add more subscription tests
* subscription test improvements
* return nextHourglassDate again
* fix gem limit
* fix(test): short circuit this.
* fix(admin): correct logic and style for shrimple subs
* WIP(frontend): draft of main subs page view
* fix hourglass count
* Fix hourglass logic for upgrades
* fix admin panel display
* WIP(subs): extant Stripe state
* fix admin panel strings
* fix missing transaction type
* add new field for cumulative subscription count
* show date for hourglass bonus if it was received
* fix test
* feat(subscription): max Gems progress readout
* fix(css): correct and refactor heights and selection states
* fix(subs): correct border-radius and redirect
* fix(stripe): correct redirect after success
* Admin panel display fixes
* don’t give additional HG for new sub if they already got one this month
* fix issue with promo hourglasses
* fix(subscription): update layout when gifting
* fix(subscriptions): more gift layout revisions
* fix(subscriptions): minor visual updates
* fix(subs): pass autoRenews through Stripe
* fix(subs): gifts DON't renew
* fix(lint): unnecessary ternary
* fix(lint): do negate object ig
* fix(subs): try again on gifts
* fix(subs): unhovery and un-12-monthy
* fix bug with incorrectly giving HG bonus
* remove only
* fix test
* fix test
* fix(subs): also redirect to subs after gift sub
* fix(subs): fix typeError
* fix(g1g1): don't try to find Gems promo during bogo
---------
Co-authored-by: Phillip Thelen <phillip@habitica.com>
Co-authored-by: Kalista Payne <sabe@habitica.com>
* chore(sprites): update subproject
* fix(layout): tighten cancellation note
* fix(subs): Google wording and HG escape
* chore(testing): fake g1g1 dates
* fix(subs): don't hide HG preview entirely
* fix(subs): center next hourglass message
* working validatedTextInput.vue within start-new-conversation-input-header.vue 🎉
* fix(git): remove changes from old develop
* Revert "fix(git): remove changes from old develop"
This reverts commit 0e30f7df00.
* fix(git): no actually just this file i guesss
* adding an empty loading state, hiding
* fought the avatar arch nemesis again
* fix chatMessages (party chat) message spacing
* move disabled text back to above the input area - re-enable input area
* show disabled private messages top panel
* fix font color
* fixing uiStates - removing disabled - moving the own user check to the last
* fix(lint): add missing prop defaults
* fix(lint): object default should be fn
* fix(chat): correct grammar in error
* remove weapon position relative
* revert most of avatar.vue changes, add back weapons in chat message UI
* show date tooltip above system / skill messages
* fix toggle disable icon position
* trivial CSS cleanup
* fix(typo): English syntax in test
* chore(test): small style cleanup
* chore(logging): revert debug function
* chore(debug): remove timers from inbox like
---------
Co-authored-by: SabreCat <sabe@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
Co-authored-by: Phillip Thelen <phillip@habitica.com>
* improve method signature
* add fallback
* syntax fix
* fix merge error
* facepalm
---------
Co-authored-by: SabreCat <sabe@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
1680 lines
54 KiB
JavaScript
1680 lines
54 KiB
JavaScript
import moment from 'moment';
|
|
import mongoose from 'mongoose';
|
|
import _ from 'lodash';
|
|
import validator from 'validator';
|
|
import nconf from 'nconf';
|
|
import { // eslint-disable-line import/no-cycle
|
|
model as User,
|
|
nameFields,
|
|
} from './user';
|
|
import shared from '../../common';
|
|
import { model as Challenge } from './challenge'; // eslint-disable-line import/no-cycle
|
|
import {
|
|
chatModel as Chat,
|
|
setUserStyles,
|
|
messageDefaults,
|
|
} from './message';
|
|
import * as Tasks from './task';
|
|
import { removeFromArray } from '../libs/collectionManipulators';
|
|
import payments from '../libs/payments/payments'; // eslint-disable-line import/no-cycle
|
|
import { // eslint-disable-line import/no-cycle
|
|
groupChatReceivedWebhook,
|
|
questActivityWebhook,
|
|
} from '../libs/webhook';
|
|
import {
|
|
InternalServerError,
|
|
BadRequest,
|
|
NotAuthorized,
|
|
} from '../libs/errors';
|
|
import baseModel from '../libs/baseModel';
|
|
import { sendTxn as sendTxnEmail } from '../libs/email'; // eslint-disable-line import/no-cycle
|
|
import { sendNotification as sendPushNotification } from '../libs/pushNotifications'; // eslint-disable-line import/no-cycle
|
|
import {
|
|
schema as SubscriptionPlanSchema,
|
|
} from './subscriptionPlan';
|
|
import logger from '../libs/logger';
|
|
import amazonPayments from '../libs/payments/amazon'; // eslint-disable-line import/no-cycle
|
|
import stripePayments from '../libs/payments/stripe'; // eslint-disable-line import/no-cycle
|
|
import { getGroupChat, translateMessage } from '../libs/chat/group-chat'; // eslint-disable-line import/no-cycle
|
|
import { model as UserNotification } from './userNotification';
|
|
import { sendChatPushNotifications } from '../libs/chat'; // eslint-disable-line import/no-cycle
|
|
import { model as UserHistory } from './userHistory'; // eslint-disable-line import/no-cycle
|
|
|
|
const questScrolls = shared.content.quests;
|
|
const { questSeriesAchievements } = shared.content;
|
|
const { Schema } = mongoose;
|
|
|
|
export const INVITES_LIMIT = 100; // must not be greater than MAX_EMAIL_INVITES_BY_USER
|
|
export const PARTY_PENDING_LIMIT = 10;
|
|
export const { TAVERN_ID } = shared;
|
|
|
|
const NO_CHAT_NOTIFICATIONS = [TAVERN_ID];
|
|
const { LARGE_GROUP_COUNT_MESSAGE_CUTOFF } = shared.constants;
|
|
const { MAX_SUMMARY_SIZE_FOR_GUILDS } = shared.constants;
|
|
|
|
const { CHAT_FLAG_LIMIT_FOR_HIDING } = shared.constants;
|
|
|
|
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
|
|
const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
|
|
const MAX_UPDATE_RETRIES = 5;
|
|
|
|
/*
|
|
# Spam constants to limit people from sending too many messages too quickly
|
|
# SPAM_MESSAGE_LIMIT - The amount of messages that can be sent in a time window
|
|
# SPAM_WINDOW_LENGTH - The window length for spam protection in milliseconds
|
|
# SPAM_MIN_EXEMPT_CONTRIB_LEVEL - Anyone at or above this level is exempt
|
|
*/
|
|
export const SPAM_MESSAGE_LIMIT = 2;
|
|
export const SPAM_WINDOW_LENGTH = 60000; // 1 minute
|
|
export const SPAM_MIN_EXEMPT_CONTRIB_LEVEL = 4;
|
|
|
|
export const MAX_CHAT_COUNT = 400;
|
|
export const MAX_SUBBED_GROUP_CHAT_COUNT = 400;
|
|
|
|
export const schema = new Schema({
|
|
name: { $type: String, required: true },
|
|
summary: { $type: String, maxlength: MAX_SUMMARY_SIZE_FOR_GUILDS },
|
|
description: String,
|
|
leader: {
|
|
$type: String, ref: 'User', validate: [v => validator.isUUID(v), 'Invalid uuid for group leader.'], required: true,
|
|
},
|
|
type: { $type: String, enum: ['guild', 'party'], required: true },
|
|
privacy: {
|
|
$type: String, enum: ['private', 'public'], default: 'private', required: true,
|
|
},
|
|
chat: Array, // Used for backward compatibility, but messages aren't stored here
|
|
bannedWordsAllowed: { $type: Boolean, required: false },
|
|
leaderOnly: { // restrict group actions to leader (members can't do them)
|
|
challenges: { $type: Boolean, default: false, required: true },
|
|
// invites: {$type: Boolean, default: false, required: true},
|
|
// Some group plans prevent members from getting gems
|
|
getGems: { $type: Boolean, default: false },
|
|
},
|
|
memberCount: { $type: Number, default: 1 },
|
|
challengeCount: { $type: Number, default: 0 },
|
|
chatLimitCount: { $type: Number },
|
|
balance: { $type: Number, default: 0 },
|
|
logo: String,
|
|
leaderMessage: String,
|
|
quest: {
|
|
key: String,
|
|
active: { $type: Boolean, default: false },
|
|
leader: { $type: String, ref: 'User' },
|
|
progress: {
|
|
hp: Number,
|
|
collect: {
|
|
$type: Schema.Types.Mixed,
|
|
default: () => ({}),
|
|
}, // {feather: 5, ingot: 3}
|
|
rage: Number, // limit break / "energy stored in shell", for explosion-attacks
|
|
},
|
|
|
|
// Shows boolean for each party-member who has accepted the quest.
|
|
// Eg {UUID: true, UUID: false}. Once all users click
|
|
// 'Accept', the quest begins.
|
|
// If a false user waits too long, probably a good sign to prod them or boot them.
|
|
// TODO when booting user, remove from .joined and check again if we can now start the quest
|
|
members: {
|
|
$type: Schema.Types.Mixed,
|
|
default: () => ({}),
|
|
},
|
|
extra: {
|
|
$type: Schema.Types.Mixed,
|
|
default: () => ({}),
|
|
},
|
|
},
|
|
tasksOrder: {
|
|
habits: [{ $type: String, ref: 'Task' }],
|
|
dailys: [{ $type: String, ref: 'Task' }],
|
|
todos: [{ $type: String, ref: 'Task' }],
|
|
rewards: [{ $type: String, ref: 'Task' }],
|
|
},
|
|
purchased: {
|
|
plan: {
|
|
$type: SubscriptionPlanSchema,
|
|
default: () => ({}),
|
|
},
|
|
},
|
|
managers: {
|
|
$type: Schema.Types.Mixed,
|
|
default: () => ({}),
|
|
},
|
|
categories: [{
|
|
slug: { $type: String },
|
|
name: { $type: String },
|
|
}],
|
|
cron: {
|
|
lastProcessed: { $type: Date },
|
|
},
|
|
}, {
|
|
strict: true,
|
|
minimize: false, // So empty objects are returned
|
|
typeKey: '$type', // So that we can use fields named `type`
|
|
});
|
|
|
|
schema.plugin(baseModel, {
|
|
noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'bannedWordsAllowed', 'challengeCount', 'tasksOrder', 'purchased', 'managers'],
|
|
private: ['purchased.plan'],
|
|
toJSONTransform (plainObj, originalDoc) {
|
|
if (plainObj.purchased) plainObj.purchased.active = originalDoc.hasActiveGroupPlan();
|
|
},
|
|
});
|
|
|
|
schema.pre('init', group => {
|
|
// The Vue website makes the summary be mandatory for all new groups, but the
|
|
// Angular website did not, and the API does not yet for backwards-compatibility.
|
|
// When any guild without a summary is fetched from the database, this code
|
|
// supplies the name as the summary. This can be removed when all guilds have
|
|
// a summary and the API makes it mandatory (a breaking change!)
|
|
// NOTE: the Tavern and parties do NOT need summaries so ensure they don't break
|
|
// if we remove this code.
|
|
if (!group.summary) {
|
|
group.summary = group.name ? group.name.substring(0, MAX_SUMMARY_SIZE_FOR_GUILDS) : ' ';
|
|
}
|
|
});
|
|
|
|
// A list of additional fields that cannot be updated (but can be set on creation)
|
|
const noUpdate = ['privacy', 'type'];
|
|
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
|
|
return this.sanitize(updateObj, noUpdate);
|
|
};
|
|
|
|
// Basic fields to fetch for populating a group info
|
|
export const basicFields = 'name type privacy leader summary categories';
|
|
|
|
schema.pre('deleteOne', { document: true }, async function preRemoveGroup (next, done) {
|
|
next();
|
|
try {
|
|
await this.removeGroupInvitations();
|
|
done();
|
|
} catch (err) {
|
|
done(err);
|
|
}
|
|
});
|
|
|
|
// return clean updates for each user in a party without resetting their progress
|
|
function _cleanQuestParty (merge) {
|
|
const updates = {
|
|
$set: {
|
|
'party.quest.key': null,
|
|
'party.quest.completed': null,
|
|
'party.quest.RSVPNeeded': false,
|
|
},
|
|
};
|
|
|
|
if (merge) _.merge(updates, merge);
|
|
|
|
return updates;
|
|
}
|
|
|
|
// return a clean user.quest of a particular user while keeping his progress
|
|
function _cleanQuestUser (userProgress) {
|
|
if (!userProgress) {
|
|
userProgress = { // eslint-disable-line no-param-reassign
|
|
up: 0,
|
|
down: 0,
|
|
collect: {},
|
|
collectedItems: 0,
|
|
};
|
|
} else {
|
|
userProgress = userProgress.toObject(); // eslint-disable-line no-param-reassign
|
|
}
|
|
|
|
const clean = {
|
|
key: null,
|
|
progress: userProgress,
|
|
completed: null,
|
|
RSVPNeeded: false,
|
|
};
|
|
|
|
return clean;
|
|
}
|
|
|
|
schema.statics.getGroup = async function getGroup (options = {}) {
|
|
const {
|
|
user, groupId, fields, optionalMembership = false,
|
|
populateLeader = false, requireMembership = false,
|
|
} = options;
|
|
let query;
|
|
|
|
const isUserParty = groupId === 'party' || user.party._id === groupId;
|
|
const isUserGuild = user.guilds.indexOf(groupId) !== -1;
|
|
const isTavern = ['habitrpg', TAVERN_ID].indexOf(groupId) !== -1;
|
|
|
|
// When requireMembership is true check that user is member even in public guild
|
|
if (requireMembership && !isUserParty && !isUserGuild && !isTavern) {
|
|
return null;
|
|
}
|
|
|
|
// When optionalMembership is true it's not required for the user to be a member of the group
|
|
if (isUserParty) {
|
|
query = { type: 'party', _id: user.party._id };
|
|
} else if (isTavern) {
|
|
query = { _id: TAVERN_ID };
|
|
} else if (optionalMembership === true) {
|
|
query = { _id: groupId };
|
|
} else if (isUserGuild) {
|
|
query = { type: 'guild', _id: groupId };
|
|
} else {
|
|
query = { type: 'guild', privacy: 'public', _id: groupId };
|
|
}
|
|
|
|
const mQuery = this.findOne(query);
|
|
if (fields) mQuery.select(fields);
|
|
if (populateLeader === true) mQuery.populate('leader', nameFields);
|
|
const group = await mQuery.exec();
|
|
|
|
if (!group) {
|
|
if (groupId === user.party._id) {
|
|
// reset party object to default state
|
|
user.party = {};
|
|
await user.save();
|
|
} else {
|
|
const item = removeFromArray(user.guilds, groupId);
|
|
if (item) {
|
|
await user.save();
|
|
}
|
|
}
|
|
}
|
|
|
|
return group;
|
|
};
|
|
|
|
export const VALID_QUERY_TYPES = ['party', 'guilds', 'privateGuilds', 'tavern'];
|
|
|
|
schema.statics.getGroups = async function getGroups (options = {}) {
|
|
const {
|
|
user, types, groupFields = basicFields,
|
|
sort = '-memberCount', populateLeader = false,
|
|
filters = {},
|
|
} = options;
|
|
const queries = [];
|
|
|
|
// Throw error if an invalid type is supplied
|
|
const areValidTypes = types.every(type => VALID_QUERY_TYPES.indexOf(type) !== -1);
|
|
if (!areValidTypes) throw new BadRequest(shared.i18n.t('groupTypesRequired'));
|
|
|
|
types.forEach(type => {
|
|
switch (type) { // eslint-disable-line default-case
|
|
case 'party': {
|
|
queries.push(this.getGroup({
|
|
user, groupId: 'party', fields: groupFields, populateLeader,
|
|
}));
|
|
break;
|
|
}
|
|
case 'guilds':
|
|
case 'privateGuilds': {
|
|
const query = {
|
|
type: 'guild',
|
|
privacy: 'private',
|
|
_id: { $in: user.guilds },
|
|
'purchased.plan.customerId': { $exists: true },
|
|
$or: [
|
|
{ 'purchased.plan.dateTerminated': null },
|
|
{ 'purchased.plan.dateTerminated': { $exists: false } },
|
|
{ 'purchased.plan.dateTerminated': { $gt: new Date() } },
|
|
],
|
|
};
|
|
_.assign(query, filters);
|
|
const privateGuildsQuery = this.find(query).select(groupFields);
|
|
if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields);
|
|
privateGuildsQuery.sort(sort);
|
|
queries.push(privateGuildsQuery);
|
|
break;
|
|
}
|
|
case 'tavern': {
|
|
if (types.indexOf('publicGuilds') === -1) {
|
|
queries.push(this.getGroup({ user, groupId: TAVERN_ID, fields: groupFields }));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
const groupsArray = _.reduce(await Promise.all(queries), (previousValue, currentValue) => {
|
|
// don't add anything to the results if the query returned null or an empty array
|
|
if (_.isEmpty(currentValue)) return previousValue;
|
|
// otherwise concat the new results to the previousValue
|
|
return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]);
|
|
}, []);
|
|
|
|
return groupsArray;
|
|
};
|
|
|
|
// When converting to json remove chat messages with more than 1 flag and remove all flags info
|
|
// unless the user is an admin or said chat is posted by that user
|
|
// Not putting into toJSON because there we can't access user
|
|
// It also removes the _meta field that can be stored inside a chat message
|
|
schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, user) {
|
|
// @TODO: Adding this here for support the old chat,
|
|
// but we should depreciate accessing chat like this
|
|
// Also only return chat if requested, eventually we don't want to return chat here
|
|
if (group && group.chat) {
|
|
await getGroupChat(group);
|
|
}
|
|
|
|
const groupToJson = group.toJSON();
|
|
const userLang = user.preferences.language;
|
|
|
|
groupToJson.chat = groupToJson.chat
|
|
.map(chatMsg => {
|
|
// Translate system messages
|
|
if (!_.isEmpty(chatMsg.info)) {
|
|
chatMsg.unformattedText = translateMessage(userLang, chatMsg.info);
|
|
chatMsg.text = chatMsg.unformattedText;
|
|
if (!chatMsg.text.includes('`')) {
|
|
chatMsg.text = `\`${chatMsg.text}\``;
|
|
}
|
|
}
|
|
|
|
// Convert to timestamps because Android expects it
|
|
// old chats are saved with a numeric timestamp
|
|
// new chats use `Date` which then has to be converted to the numeric timestamp
|
|
if (chatMsg.timestamp && chatMsg.timestamp.getTime) {
|
|
chatMsg.timestamp = chatMsg.timestamp.getTime();
|
|
}
|
|
|
|
if (!user.hasPermission('moderator')) {
|
|
// Flags are hidden to non admins
|
|
chatMsg.flags = {};
|
|
if (chatMsg._meta) chatMsg._meta = undefined;
|
|
|
|
// Messages with too many flags are hidden to non-admins and non-authors
|
|
if (user._id !== chatMsg.uuid && chatMsg.flagCount >= CHAT_FLAG_LIMIT_FOR_HIDING) {
|
|
return undefined;
|
|
}
|
|
chatMsg.flagCount = 0;
|
|
}
|
|
|
|
return chatMsg;
|
|
})
|
|
// Used to filter for undefined chat messages that should not be shown to non-admins
|
|
.filter(chatMsg => chatMsg !== undefined);
|
|
|
|
return groupToJson;
|
|
};
|
|
|
|
function getInviteError (uuids, emails, usernames) {
|
|
const uuidsIsArray = Array.isArray(uuids);
|
|
const emailsIsArray = Array.isArray(emails);
|
|
const usernamesIsArray = Array.isArray(usernames);
|
|
const emptyEmails = emailsIsArray && emails.length < 1;
|
|
const emptyUuids = uuidsIsArray && uuids.length < 1;
|
|
const emptyUsernames = usernamesIsArray && usernames.length < 1;
|
|
|
|
let errorString;
|
|
|
|
if (!uuids && !emails && !usernames) {
|
|
errorString = 'canOnlyInviteEmailUuid';
|
|
} else if (uuids && !uuidsIsArray) {
|
|
errorString = 'uuidsMustBeAnArray';
|
|
} else if (emails && !emailsIsArray) {
|
|
errorString = 'emailsMustBeAnArray';
|
|
} else if (usernames && !usernamesIsArray) {
|
|
errorString = 'usernamesMustBeAnArray';
|
|
} else if ((!emails || emptyEmails) && (!uuids || emptyUuids) && (!usernames || emptyUsernames)) {
|
|
errorString = 'inviteMustNotBeEmpty';
|
|
}
|
|
|
|
return errorString;
|
|
}
|
|
|
|
function getInviteCount (uuids, emails, usernames) {
|
|
let totalInvites = 0;
|
|
|
|
if (uuids) {
|
|
totalInvites += uuids.length;
|
|
}
|
|
|
|
if (emails) {
|
|
totalInvites += emails.length;
|
|
}
|
|
|
|
if (usernames) {
|
|
totalInvites += usernames.length;
|
|
}
|
|
|
|
return totalInvites;
|
|
}
|
|
|
|
/**
|
|
* Checks invitation uuids and emails for possible errors.
|
|
*
|
|
* @param uuids An array of User IDs
|
|
* @param emails An array of emails
|
|
* @param res Express res object for use with translations
|
|
* @throws BadRequest An error describing the issue with the invitations
|
|
*/
|
|
schema.statics.validateInvitations = async function getInvitationErr (invites, res, group = null) {
|
|
const {
|
|
uuids,
|
|
emails,
|
|
usernames,
|
|
} = invites;
|
|
const errorString = getInviteError(uuids, emails, usernames);
|
|
if (errorString) throw new BadRequest(res.t(errorString));
|
|
|
|
const totalInvites = getInviteCount(uuids, emails, usernames);
|
|
if (totalInvites > INVITES_LIMIT) {
|
|
throw new BadRequest(res.t('canOnlyInviteMaxInvites', { maxInvites: INVITES_LIMIT }));
|
|
}
|
|
|
|
// If party, check the limit of members
|
|
if (group && group.type === 'party') {
|
|
let memberCount = 0;
|
|
|
|
// Counting the members that already joined the party
|
|
memberCount += group.memberCount;
|
|
|
|
// Count how many invitations currently exist in the party
|
|
const query = {};
|
|
query['invitations.party.id'] = group._id;
|
|
// @TODO invitations are now stored like this: `'invitations.parties': []`
|
|
const groupInvites = await User.countDocuments(query).exec();
|
|
if (groupInvites + totalInvites > PARTY_PENDING_LIMIT) {
|
|
throw new BadRequest(res.t('partyExceedsInvitesLimit', { maxInvites: PARTY_PENDING_LIMIT }));
|
|
}
|
|
memberCount += groupInvites;
|
|
|
|
// Counting the members that are going to be invited by email and uuids
|
|
memberCount += totalInvites;
|
|
|
|
if (memberCount > shared.constants.PARTY_LIMIT_MEMBERS) {
|
|
throw new BadRequest(res.t('partyExceedsMembersLimit', { maxMembersParty: shared.constants.PARTY_LIMIT_MEMBERS }));
|
|
}
|
|
}
|
|
};
|
|
|
|
schema.methods.getParticipatingQuestMembers = function getParticipatingQuestMembers () {
|
|
return Object.keys(this.quest.members).filter(member => this.quest.members[member]);
|
|
};
|
|
|
|
schema.methods.removeGroupInvitations = async function removeGroupInvitations () {
|
|
const group = this;
|
|
|
|
const usersToRemoveInvitationsFrom = await User.find({
|
|
[`invitations.${group.type}${group.type === 'guild' ? 's' : ''}.id`]: group._id,
|
|
}).exec();
|
|
|
|
const userUpdates = usersToRemoveInvitationsFrom.map(user => {
|
|
if (group.type === 'party') {
|
|
removeFromArray(user.invitations.parties, { id: group._id });
|
|
user.invitations.party = user.invitations.parties.length > 0
|
|
? user.invitations.parties[user.invitations.parties.length - 1] : {};
|
|
this.markModified('invitations.party');
|
|
} else {
|
|
removeFromArray(user.invitations.guilds, { id: group._id });
|
|
}
|
|
return user.save();
|
|
});
|
|
|
|
return Promise.all(userUpdates);
|
|
};
|
|
|
|
// Return true if user is a member of the group
|
|
schema.methods.isMember = function isGroupMember (user) {
|
|
if (this._id === TAVERN_ID) {
|
|
return true; // everyone is considered part of the tavern
|
|
} if (this.type === 'party') {
|
|
return user.party._id === this._id;
|
|
} // guilds
|
|
return user.guilds.indexOf(this._id) !== -1;
|
|
};
|
|
|
|
schema.methods.getMemberCount = async function getMemberCount (options) {
|
|
let excludeUserId = null;
|
|
if (options && options.excludeUserId) {
|
|
excludeUserId = options.excludeUserId;
|
|
}
|
|
let query = { guilds: this._id };
|
|
|
|
if (this.type === 'party') {
|
|
query = { 'party._id': this._id };
|
|
}
|
|
|
|
if (excludeUserId) {
|
|
query._id = { $ne: excludeUserId };
|
|
}
|
|
|
|
return User.countDocuments(query).exec();
|
|
};
|
|
|
|
schema.methods.sendChat = async function sendChat (options = {}) {
|
|
const {
|
|
message, user, metaData,
|
|
client, flagCount = 0, info = {},
|
|
translate, mentions, mentionedMembers,
|
|
} = options;
|
|
const newMessage = messageDefaults(message, user, client, flagCount, info);
|
|
|
|
let newChatMessage = new Chat();
|
|
newChatMessage = Object.assign(newChatMessage, newMessage);
|
|
newChatMessage.groupId = this._id;
|
|
|
|
if (user) setUserStyles(newChatMessage, user);
|
|
|
|
// Optional data stored in the chat message but not returned
|
|
// to the users that can be stored for debugging purposes
|
|
if (metaData) {
|
|
newChatMessage._meta = metaData;
|
|
}
|
|
|
|
// Activate the webhook for receiving group chat messages before
|
|
// newChatMessage is possibly returned
|
|
this.sendGroupChatReceivedWebhooks(newChatMessage);
|
|
|
|
// do not send notifications for:
|
|
// - groups that never send notifications (e.g., Tavern)
|
|
// - groups with very many users
|
|
// - messages that have already been flagged to hide them
|
|
if (
|
|
NO_CHAT_NOTIFICATIONS.indexOf(this._id) !== -1
|
|
|| this.memberCount > LARGE_GROUP_COUNT_MESSAGE_CUTOFF
|
|
|| newChatMessage.flagCount >= CHAT_FLAG_LIMIT_FOR_HIDING
|
|
) {
|
|
return newChatMessage;
|
|
}
|
|
|
|
// Kick off chat notifications in the background.
|
|
|
|
const query = {
|
|
_id: { $ne: user ? user._id : '' },
|
|
'notifications.data.group.id': { $ne: this._id },
|
|
};
|
|
|
|
if (this.type === 'party') {
|
|
query['party._id'] = this._id;
|
|
} else {
|
|
query.guilds = this._id;
|
|
}
|
|
|
|
// Add the new notification
|
|
const lastSeenUpdateAddNew = {
|
|
$set: { // old notification, supported until mobile is updated and we release api v4
|
|
[`newMessages.${this._id}`]: { name: this.name, value: true },
|
|
},
|
|
$push: {
|
|
notifications: new UserNotification({
|
|
type: 'NEW_CHAT_MESSAGE',
|
|
data: { group: { id: this._id, name: this.name } },
|
|
}).toObject(),
|
|
},
|
|
};
|
|
|
|
User
|
|
.updateMany(query, lastSeenUpdateAddNew).exec()
|
|
.catch(err => logger.error(err));
|
|
|
|
if (this.type === 'party' && user) {
|
|
sendChatPushNotifications(user, this, newChatMessage, mentions, translate);
|
|
}
|
|
if (mentionedMembers) {
|
|
await mentionedMembers.forEach(async member => {
|
|
if (member._id === user._id) return;
|
|
const pushNotifPrefs = member.preferences.pushNotifications;
|
|
if (this.type === 'party') {
|
|
if (pushNotifPrefs.mentionParty !== true || !this.isMember(member)) {
|
|
return;
|
|
}
|
|
} else if (this.isMember(member)) {
|
|
if (pushNotifPrefs.mentionJoinedGuild !== true) {
|
|
return;
|
|
}
|
|
} else {
|
|
if (this.privacy !== 'public') {
|
|
return;
|
|
}
|
|
if (pushNotifPrefs.mentionUnjoinedGuild !== true) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (newChatMessage.unformattedText) {
|
|
await sendPushNotification(member, {
|
|
identifier: 'chatMention',
|
|
title: `${user.profile.name} mentioned you in ${this.name}`,
|
|
message: newChatMessage.unformattedText,
|
|
payload: { type: this.type, groupID: this._id },
|
|
});
|
|
}
|
|
});
|
|
}
|
|
return newChatMessage;
|
|
};
|
|
|
|
schema.methods.handleQuestInvitation = async function handleQuestInvitation (user, accept) {
|
|
if (!user) throw new InternalServerError('Must provide user to handle quest invitation');
|
|
if (accept !== true && accept !== false) throw new InternalServerError('Must provide accept param handle quest invitation');
|
|
|
|
// Handle quest invitation atomically (update only current member when still undecided)
|
|
// to prevent multiple concurrent requests overriding updates
|
|
// see https://github.com/HabitRPG/habitica/issues/11398
|
|
const Group = this.constructor;
|
|
const result = await Group.updateOne(
|
|
{
|
|
_id: this._id,
|
|
[`quest.members.${user._id}`]: { $type: 10 }, // match BSON Type Null (type number 10)
|
|
},
|
|
{ $set: { [`quest.members.${user._id}`]: accept } },
|
|
).exec();
|
|
|
|
if (result.modifiedCount) {
|
|
// update also current instance so future operations will work correctly
|
|
this.quest.members[user._id] = accept;
|
|
}
|
|
|
|
return Boolean(result.modifiedCount);
|
|
};
|
|
|
|
schema.methods.startQuest = async function startQuest (user) {
|
|
// not using i18n strings because these errors are meant
|
|
// for devs who forgot to pass some parameters
|
|
if (this.type !== 'party') throw new InternalServerError('Must be a party to use this method');
|
|
if (!this.quest.key) throw new InternalServerError('Party does not have a pending quest');
|
|
if (this.quest.active) throw new InternalServerError('Quest is already active');
|
|
|
|
const userIsParticipating = this.quest.members[user._id];
|
|
const quest = questScrolls[this.quest.key];
|
|
let collected = {};
|
|
if (quest.collect) {
|
|
collected = _.transform(quest.collect, (result, n, itemToCollect) => {
|
|
result[itemToCollect] = 0;
|
|
});
|
|
}
|
|
|
|
this.markModified('quest');
|
|
this.quest.active = true;
|
|
if (quest.boss) {
|
|
this.quest.progress.hp = quest.boss.hp;
|
|
if (quest.boss.rage) this.quest.progress.rage = 0;
|
|
} else if (quest.collect) {
|
|
this.quest.progress.collect = collected;
|
|
}
|
|
|
|
const nonMembers = Object.keys(_.pickBy(this.quest.members, member => !member));
|
|
const noResponseMembers = Object.keys(_.pickBy(this.quest.members, member => member === null));
|
|
// Changes quest.members to only include participating members
|
|
this.quest.members = _.pickBy(this.quest.members, _.identity);
|
|
|
|
// Persist quest.members early to avoid simultaneous handling of accept/reject
|
|
// while processing the rest of this script
|
|
await this.updateOne({ $set: { 'quest.members': this.quest.members } }).exec();
|
|
|
|
const nonUserQuestMembers = _.keys(this.quest.members);
|
|
removeFromArray(nonUserQuestMembers, user._id);
|
|
|
|
// remove any users from quest.members who aren't in the party
|
|
// and get the data necessary to send webhooks
|
|
const members = [];
|
|
|
|
await User.find({
|
|
_id: { $in: Object.keys(this.quest.members) },
|
|
})
|
|
.select('party.quest party._id items.quests auth preferences.emailNotifications preferences.pushNotifications preferences.language pushDevices profile.name webhooks')
|
|
.lean()
|
|
.exec()
|
|
.then(partyMembers => {
|
|
partyMembers.forEach(member => {
|
|
if (!member.party || member.party._id !== this._id) {
|
|
delete this.quest.members[member._id];
|
|
} else {
|
|
members.push(member);
|
|
}
|
|
});
|
|
});
|
|
|
|
if (userIsParticipating) {
|
|
user.party.quest.key = this.quest.key;
|
|
user.party.quest.progress.down = 0;
|
|
user.party.quest.completed = null;
|
|
user.markModified('party.quest');
|
|
}
|
|
|
|
const promises = [];
|
|
|
|
// Remove the quest from the quest leader items (if they are the current user)
|
|
if (this.quest.leader === user._id) {
|
|
user.items.quests[this.quest.key] -= 1;
|
|
user.markModified('items.quests');
|
|
promises.push(user.save());
|
|
} else { // another user is starting the quest, update the leader separately
|
|
promises.push(User.updateOne({ _id: this.quest.leader }, {
|
|
$inc: {
|
|
[`items.quests.${this.quest.key}`]: -1,
|
|
},
|
|
}).exec());
|
|
}
|
|
|
|
// update the remaining users
|
|
promises.push(User.updateMany({
|
|
_id: { $in: nonUserQuestMembers },
|
|
}, {
|
|
$set: {
|
|
'party.quest.key': this.quest.key,
|
|
'party.quest.progress.down': 0,
|
|
'party.quest.completed': null,
|
|
},
|
|
}).exec());
|
|
|
|
await Promise.all(promises);
|
|
|
|
// update the users who are not participating
|
|
// Do not block updates
|
|
User.updateMany({
|
|
_id: { $in: nonMembers },
|
|
}, _cleanQuestParty()).exec();
|
|
|
|
noResponseMembers.forEach(member => {
|
|
UserHistory.beginUserHistoryUpdate(member)
|
|
.withQuestInviteResponse(this.quest.key, 'no response')
|
|
.commit();
|
|
});
|
|
|
|
const newMessage = await this.sendChat({
|
|
message: `\`${shared.i18n.t('chatQuestStarted', { questName: quest.text('en') }, 'en')}\``,
|
|
metaData: {
|
|
participatingMembers: this.getParticipatingQuestMembers().join(', '),
|
|
},
|
|
info: {
|
|
type: 'quest_start',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
await newMessage.save();
|
|
|
|
const membersToEmail = [];
|
|
|
|
// send notifications and webhooks in the background without blocking
|
|
for (const member of members) {
|
|
if (member._id === user._id) {
|
|
// early "exit", saving one indention level
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
|
|
// add email to send if that user did not disabled this email
|
|
if (member.preferences.emailNotifications.questStarted !== false) {
|
|
membersToEmail.push(member);
|
|
}
|
|
|
|
// send push notifications if that user did not disabled this notifications
|
|
if (member.preferences.pushNotifications.questStarted !== false) {
|
|
const memberLang = member.preferences.language;
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await sendPushNotification(member, {
|
|
title: quest.text(memberLang),
|
|
message: shared.i18n.t('questStarted', memberLang),
|
|
identifier: 'questStarted',
|
|
});
|
|
}
|
|
|
|
// Send webhooks
|
|
questActivityWebhook.send(member, {
|
|
type: 'questStarted',
|
|
group: this,
|
|
quest,
|
|
});
|
|
}
|
|
|
|
// Send emails in bulk
|
|
sendTxnEmail(membersToEmail, 'quest-started', [
|
|
{ name: 'PARTY_URL', content: '/party' },
|
|
]);
|
|
};
|
|
|
|
schema.methods.sendGroupChatReceivedWebhooks = function sendGroupChatReceivedWebhooks (chat) {
|
|
const query = {
|
|
webhooks: {
|
|
$elemMatch: {
|
|
type: 'groupChatReceived',
|
|
'options.groupId': this._id,
|
|
},
|
|
},
|
|
};
|
|
|
|
if (this.type === 'party') {
|
|
query['party._id'] = this._id;
|
|
} else {
|
|
query.guilds = this._id;
|
|
}
|
|
|
|
User.find(query).select({ webhooks: 1 }).lean().exec()
|
|
.then(users => {
|
|
users.forEach(user => {
|
|
groupChatReceivedWebhook.send(user, {
|
|
group: this,
|
|
chat,
|
|
});
|
|
});
|
|
})
|
|
.catch(err => logger.error(err));
|
|
};
|
|
|
|
schema.statics.cleanQuestParty = _cleanQuestParty;
|
|
schema.statics.cleanQuestUser = _cleanQuestUser;
|
|
|
|
// returns a clean object for group.quest
|
|
schema.statics.cleanGroupQuest = function cleanGroupQuest () {
|
|
return {
|
|
key: null,
|
|
active: false,
|
|
leader: null,
|
|
progress: {
|
|
collect: {},
|
|
},
|
|
members: {},
|
|
};
|
|
};
|
|
|
|
function _getUserUpdateForQuestReward (itemToAward, allAwardedItems) {
|
|
let updates = {
|
|
$set: {},
|
|
$inc: {},
|
|
$pull: {},
|
|
};
|
|
const dropK = itemToAward.key;
|
|
|
|
switch (itemToAward.type) { // eslint-disable-line default-case
|
|
case 'gear': {
|
|
// TODO This means they can lose their new gear on death, is that what we want?
|
|
updates.$set[`items.gear.owned.${dropK}`] = true;
|
|
updates.$pull.pinnedItems = { path: `gear.flat.${dropK}` };
|
|
break;
|
|
}
|
|
case 'eggs':
|
|
case 'food':
|
|
case 'hatchingPotions':
|
|
case 'quests': {
|
|
updates.$inc[`items.${itemToAward.type}.${dropK}`] = _.filter(allAwardedItems, { type: itemToAward.type, key: itemToAward.key }).length;
|
|
break;
|
|
}
|
|
case 'pets': {
|
|
updates.$set[`items.pets.${dropK}`] = 5;
|
|
break;
|
|
}
|
|
case 'mounts': {
|
|
updates.$set[`items.mounts.${dropK}`] = true;
|
|
break;
|
|
}
|
|
}
|
|
updates = _.omitBy(updates, _.isEmpty);
|
|
return updates;
|
|
}
|
|
|
|
async function _updateUserWithRetries (userId, updates, numTry = 1, query = {}) {
|
|
query._id = userId;
|
|
try {
|
|
return await User.updateOne(query, updates).exec();
|
|
} catch (err) {
|
|
if (numTry < MAX_UPDATE_RETRIES) {
|
|
numTry += 1; // eslint-disable-line no-param-reassign
|
|
return _updateUserWithRetries(userId, updates, numTry, query);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Participants: Grant rewards & achievements, finish quest.
|
|
// Changes the group object update members
|
|
schema.methods.finishQuest = async function finishQuest (quest) {
|
|
const questK = quest.key;
|
|
const updates = {
|
|
$inc: {
|
|
[`achievements.quests.${questK}`]: 1,
|
|
'stats.gp': Number(quest.drop.gp),
|
|
'stats.exp': Number(quest.drop.exp),
|
|
},
|
|
$set: {},
|
|
};
|
|
|
|
if (this._id === TAVERN_ID) {
|
|
updates.$set['party.quest.completed'] = questK; // Just show the notif
|
|
} else {
|
|
_.merge(updates, _cleanQuestParty({ $set: { 'party.quest.completed': questK } })); // clear quest progress
|
|
}
|
|
|
|
_.each(_.reject(quest.drop.items, 'onlyOwner'), item => {
|
|
_.merge(updates, _getUserUpdateForQuestReward(item, quest.drop.items));
|
|
});
|
|
|
|
const questOwnerUpdates = {};
|
|
const questLeader = this.quest.leader;
|
|
|
|
_.each(_.filter(quest.drop.items, 'onlyOwner'), item => {
|
|
_.merge(questOwnerUpdates, _getUserUpdateForQuestReward(item, quest.drop.items));
|
|
});
|
|
_.merge(questOwnerUpdates, updates);
|
|
|
|
const participants = this._id === TAVERN_ID ? {} : this.getParticipatingQuestMembers();
|
|
this.quest = {};
|
|
this.markModified('quest');
|
|
|
|
if (this._id === TAVERN_ID) {
|
|
return User.updateMany({}, updates).exec();
|
|
}
|
|
|
|
const promises = participants.map(userId => {
|
|
if (userId === questLeader) {
|
|
return _updateUserWithRetries(userId, questOwnerUpdates);
|
|
}
|
|
return _updateUserWithRetries(userId, updates);
|
|
});
|
|
|
|
// Send webhooks in background
|
|
// @TODO move the find users part to a worker as well, not just the http request
|
|
User.find({
|
|
_id: { $in: participants },
|
|
webhooks: {
|
|
$elemMatch: {
|
|
type: 'questActivity',
|
|
'options.questFinished': true,
|
|
},
|
|
},
|
|
})
|
|
.select('_id webhooks')
|
|
.lean()
|
|
.exec()
|
|
.then(participantsWithWebhook => {
|
|
participantsWithWebhook.forEach(participantWithWebhook => {
|
|
// Send webhooks
|
|
questActivityWebhook.send(participantWithWebhook, {
|
|
type: 'questFinished',
|
|
group: this,
|
|
quest,
|
|
});
|
|
});
|
|
})
|
|
.catch(err => logger.error(err));
|
|
|
|
_.forEach(questSeriesAchievements, (questList, achievement) => {
|
|
if (questList.includes(questK)) {
|
|
const questAchievementQuery = {};
|
|
questAchievementQuery[`achievements.${achievement}`] = { $ne: true };
|
|
|
|
_.forEach(questList, questName => {
|
|
if (questName !== questK) {
|
|
questAchievementQuery[`achievements.quests.${questName}`] = { $gt: 0 };
|
|
}
|
|
});
|
|
|
|
const questAchievementUpdate = { $set: {}, $push: {} };
|
|
questAchievementUpdate.$set[`achievements.${achievement}`] = true;
|
|
const achievementTitleCase = `${achievement.slice(0, 1).toUpperCase()}${achievement.slice(1, achievement.length)}`;
|
|
questAchievementUpdate.$push = {
|
|
notifications: new UserNotification({
|
|
type: 'ACHIEVEMENT_QUESTS',
|
|
data: {
|
|
achievement,
|
|
message: `${shared.i18n.t('modalAchievement')} ${shared.i18n.t(`achievement${achievementTitleCase}`)}`,
|
|
modalText: shared.i18n.t(`achievement${achievementTitleCase}ModalText`),
|
|
},
|
|
}).toObject(),
|
|
};
|
|
|
|
promises.push(participants.map(userId => _updateUserWithRetries(
|
|
userId,
|
|
questAchievementUpdate,
|
|
null,
|
|
questAchievementQuery,
|
|
)));
|
|
}
|
|
});
|
|
|
|
return Promise.all(promises);
|
|
};
|
|
|
|
function _isOnQuest (user, progress, group) {
|
|
return group && progress && group.quest && group.quest.active
|
|
&& group.quest.members[user._id] === true;
|
|
}
|
|
|
|
schema.methods._processBossQuest = async function processBossQuest (options) {
|
|
const {
|
|
user,
|
|
progress,
|
|
} = options;
|
|
|
|
const group = this;
|
|
const quest = questScrolls[group.quest.key];
|
|
const down = progress.down * quest.boss.str; // multiply by boss strength
|
|
// Everyone takes damage
|
|
const updates = {
|
|
$inc: { 'stats.hp': down },
|
|
};
|
|
const promises = [];
|
|
|
|
group.quest.progress.hp -= progress.up;
|
|
if (CRON_SAFE_MODE || CRON_SEMI_SAFE_MODE) {
|
|
const groupMessage = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatBossDontAttack', { bossName: quest.boss.name('en') }, 'en')}\``,
|
|
info: {
|
|
type: 'boss_dont_attack',
|
|
user: user.profile.name,
|
|
quest: group.quest.key,
|
|
userDamage: progress.up.toFixed(1),
|
|
},
|
|
});
|
|
promises.push(groupMessage.save());
|
|
} else {
|
|
const groupMessage = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatBossDamage', {
|
|
username: user.profile.name, bossName: quest.boss.name('en'), userDamage: progress.up.toFixed(1), bossDamage: Math.abs(down).toFixed(1),
|
|
}, user.preferences.language)}\``,
|
|
info: {
|
|
type: 'boss_damage',
|
|
user: user.profile.name,
|
|
quest: group.quest.key,
|
|
userDamage: progress.up.toFixed(1),
|
|
bossDamage: Math.abs(down).toFixed(1),
|
|
},
|
|
});
|
|
promises.push(groupMessage.save());
|
|
}
|
|
|
|
// If boss has Rage, increment Rage as well
|
|
if (quest.boss.rage) {
|
|
group.quest.progress.rage += Math.abs(down);
|
|
if (group.quest.progress.rage >= quest.boss.rage.value) {
|
|
const rageMessage = await group.sendChat({
|
|
message: quest.boss.rage.effect('en'),
|
|
info: {
|
|
type: 'boss_rage',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
promises.push(rageMessage.save());
|
|
group.quest.progress.rage = 0;
|
|
|
|
// TODO To make Rage effects more expandable,
|
|
// let's turn these into functions in quest.boss.rage
|
|
if (quest.boss.rage.healing) {
|
|
group.quest.progress.hp += group.quest.progress.hp * quest.boss.rage.healing;
|
|
}
|
|
if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp;
|
|
if (quest.boss.rage.mpDrain) {
|
|
updates.$mul = { 'stats.mp': 1 - quest.boss.rage.mpDrain };
|
|
}
|
|
if (quest.boss.rage.progressDrain) {
|
|
updates.$mul = { 'party.quest.progress.up': quest.boss.rage.progressDrain };
|
|
}
|
|
}
|
|
}
|
|
|
|
await User.updateMany(
|
|
{
|
|
_id:
|
|
{ $in: this.getParticipatingQuestMembers() },
|
|
},
|
|
updates,
|
|
).exec();
|
|
// Apply changes the currently cronning user locally
|
|
// so we don't have to reload it to get the updated state
|
|
// TODO how to mark not modified? https://github.com/Automattic/mongoose/pull/1167
|
|
// must be notModified or otherwise could overwrite future changes:
|
|
// if the user is saved it'll save
|
|
// the modified user.stats.hp but that must not happen as the hp value has already been updated
|
|
// by the User.update above
|
|
// if (down) user.stats.hp += down;
|
|
|
|
// Boss slain, finish quest
|
|
if (group.quest.progress.hp <= 0) {
|
|
const questFinishChat = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatBossDefeated', { bossName: quest.boss.name('en') }, 'en')}\``,
|
|
info: {
|
|
type: 'boss_defeated',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
promises.push(questFinishChat.save());
|
|
|
|
// Participants: Grant rewards & achievements, finish quest
|
|
await group.finishQuest(shared.content.quests[group.quest.key]);
|
|
}
|
|
|
|
promises.unshift(group.save());
|
|
return Promise.all(promises);
|
|
};
|
|
|
|
schema.methods._processCollectionQuest = async function processCollectionQuest (options) {
|
|
const {
|
|
user,
|
|
progress,
|
|
} = options;
|
|
|
|
const group = this;
|
|
const quest = questScrolls[group.quest.key];
|
|
const itemsFound = {};
|
|
Object.keys(quest.collect).forEach(item => {
|
|
itemsFound[item] = 0;
|
|
});
|
|
|
|
// Create an array of item names, one item name per item that still needs to
|
|
// be collected so that items are found proportionally to how many are needed.
|
|
const remainingItems = [].concat(...Object.keys(quest.collect).map(item => {
|
|
let count = quest.collect[item].count - (group.quest.progress.collect[item] || 0);
|
|
if (count < 0) { // This could only happen if there's a bug, but just in case.
|
|
count = 0;
|
|
}
|
|
return Array(count).fill(item);
|
|
}));
|
|
|
|
// slice() will grab only what is available even if requested slice is larger
|
|
// than the array, so we don't need to worry about overfilling quest items.
|
|
const collectedItems = _.shuffle(remainingItems).slice(0, progress.collectedItems);
|
|
collectedItems.forEach(item => {
|
|
itemsFound[item] += 1;
|
|
group.quest.progress.collect[item] += 1;
|
|
});
|
|
|
|
let foundText = _.reduce(itemsFound, (m, v, k) => {
|
|
m.push(`${v} ${quest.collect[k].text('en')}`);
|
|
return m;
|
|
}, []);
|
|
|
|
foundText = foundText.join(', ');
|
|
const foundChat = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatFindItems', { username: user.profile.name, items: foundText }, 'en')}\``,
|
|
info: {
|
|
type: 'user_found_items',
|
|
user: user.profile.name,
|
|
quest: quest.key,
|
|
items: itemsFound,
|
|
},
|
|
});
|
|
group.markModified('quest.progress.collect');
|
|
|
|
const promises = [group.save(), foundChat.save()];
|
|
|
|
const questFinished = collectedItems.length === remainingItems.length;
|
|
if (questFinished) {
|
|
await group.finishQuest(quest);
|
|
const allItemsFoundChat = await group.sendChat({
|
|
message: `\`${shared.i18n.t('chatItemQuestFinish', 'en')}\``,
|
|
info: {
|
|
type: 'all_items_found',
|
|
},
|
|
});
|
|
|
|
promises.push(allItemsFoundChat.save());
|
|
}
|
|
|
|
return Promise.all(promises);
|
|
};
|
|
|
|
schema.statics.processQuestProgress = async function processQuestProgress (user, progress) {
|
|
if (user.preferences.sleep) return;
|
|
|
|
const group = await this.getGroup({ user, groupId: 'party' });
|
|
|
|
if (!_isOnQuest(user, progress, group)) return;
|
|
|
|
const quest = shared.content.quests[group.quest.key];
|
|
|
|
if (!quest) return; // TODO should this throw an error instead?
|
|
|
|
const questType = quest.boss ? 'Boss' : 'Collection';
|
|
|
|
await group[`_process${questType}Quest`]({ // _processBossQuest, _processCollectionQuest
|
|
user,
|
|
progress,
|
|
group,
|
|
});
|
|
};
|
|
|
|
// to set a boss:
|
|
// `db.groups.updateOne({_id:TAVERN_ID},
|
|
// {$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}}).exec()`
|
|
// we export an empty object that is then populated with the query-returned data
|
|
export const tavernQuest = {};
|
|
const tavernQ = { _id: TAVERN_ID, 'quest.key': { $ne: null } };
|
|
|
|
// we use process.nextTick because at this point the model is not yet available
|
|
process.nextTick(() => {
|
|
model // eslint-disable-line no-use-before-define
|
|
.findOne(tavernQ).exec()
|
|
.then(tavern => {
|
|
if (!tavern) return; // No tavern quest
|
|
|
|
// Using _assign so we don't lose the reference to the exported tavernQuest
|
|
_.assign(tavernQuest, tavern.quest.toObject());
|
|
})
|
|
.catch(err => {
|
|
throw err;
|
|
});
|
|
});
|
|
|
|
// returns a promise
|
|
schema.statics.tavernBoss = async function tavernBoss (user, progress) {
|
|
if (!progress) return null;
|
|
if (user.preferences.sleep) return null;
|
|
|
|
// hack: prevent crazy damage to world boss
|
|
const dmg = Math.min(900, Math.abs(progress.up || 0));
|
|
const rage = -Math.min(900, Math.abs(progress.down || 0));
|
|
|
|
const tavern = await this.findOne(tavernQ).exec();
|
|
if (!(tavern && tavern.quest && tavern.quest.key)) return null;
|
|
|
|
const quest = shared.content.quests[tavern.quest.key];
|
|
|
|
const chatPromises = [];
|
|
|
|
if (tavern.quest.progress.hp <= 0) {
|
|
const completeChat = await tavern.sendChat({
|
|
message: quest.completionChat('en'),
|
|
info: {
|
|
type: 'tavern_quest_completed',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
chatPromises.push(completeChat.save());
|
|
await tavern.finishQuest(quest);
|
|
_.assign(tavernQuest, { extra: null });
|
|
return tavern.save();
|
|
}
|
|
|
|
// Deal damage. Note a couple things here, str & def are calculated.
|
|
// If str/def are defined in the database,
|
|
// use those first - which allows us to update the boss on the go if things are too easy/hard.
|
|
if (!tavern.quest.extra) tavern.quest.extra = {};
|
|
tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def);
|
|
tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str);
|
|
|
|
if (tavern.quest.progress.rage >= quest.boss.rage.value) {
|
|
if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {};
|
|
|
|
const wd = tavern.quest.extra.worldDmg;
|
|
// Dysheartener attacks Seasonal Sorceress, Alex, Ian
|
|
let scene;
|
|
if (wd.quests) {
|
|
scene = false;
|
|
} else if (wd.market) {
|
|
scene = 'quests';
|
|
} else if (wd.seasonalShop) {
|
|
scene = 'market';
|
|
} else {
|
|
scene = 'seasonalShop';
|
|
}
|
|
|
|
if (!scene) {
|
|
const tiredChat = await tavern.sendChat({
|
|
message: `\`${shared.i18n.t('tavernBossTired', { rageName: quest.boss.rage.title('en'), bossName: quest.boss.name('en') }, 'en')}\``,
|
|
info: {
|
|
type: 'tavern_boss_rage_tired',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
chatPromises.push(tiredChat.save());
|
|
tavern.quest.progress.rage = 0; // quest.boss.rage.value;
|
|
} else {
|
|
const rageChat = await tavern.sendChat({
|
|
message: quest.boss.rage[scene]('en'),
|
|
info: {
|
|
type: 'tavern_boss_rage',
|
|
quest: quest.key,
|
|
scene,
|
|
},
|
|
});
|
|
chatPromises.push(rageChat.save());
|
|
tavern.quest.extra.worldDmg[scene] = true;
|
|
tavern.markModified('quest.extra.worldDmg');
|
|
tavern.quest.progress.rage = 0;
|
|
if (quest.boss.rage.healing) {
|
|
tavern.quest.progress.hp += quest.boss.rage.healing * tavern.quest.progress.hp;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
quest.boss.desperation
|
|
&& tavern.quest.progress.hp < quest.boss.desperation.threshold
|
|
&& !tavern.quest.extra.desperate
|
|
) {
|
|
const progressChat = await tavern.sendChat({
|
|
message: quest.boss.desperation.text('en'),
|
|
info: {
|
|
type: 'tavern_boss_desperation',
|
|
quest: quest.key,
|
|
},
|
|
});
|
|
chatPromises.push(progressChat.save());
|
|
tavern.quest.extra.desperate = true;
|
|
tavern.quest.extra.def = quest.boss.desperation.def;
|
|
tavern.quest.extra.str = quest.boss.desperation.str;
|
|
tavern.markModified('quest.extra');
|
|
}
|
|
|
|
_.assign(tavernQuest, tavern.quest.toObject());
|
|
|
|
chatPromises.unshift(tavern.save());
|
|
return Promise.all(chatPromises);
|
|
};
|
|
|
|
schema.methods.leave = async function leaveGroup (user, keep = 'keep-all', keepChallenges = 'leave-challenges') {
|
|
const group = this;
|
|
const update = {};
|
|
|
|
if (group.memberCount <= 1 && group.privacy === 'private' && group.hasNotCancelled()) {
|
|
throw new NotAuthorized(shared.i18n.t('cannotDeleteActiveGroup'));
|
|
}
|
|
|
|
if (group.leader === user._id && group.hasNotCancelled()) {
|
|
throw new NotAuthorized(shared.i18n.t('leaderCannotLeaveGroupWithActiveGroup'));
|
|
}
|
|
|
|
if (group.purchased.plan.customerId) {
|
|
await payments.cancelGroupSubscriptionForUser(user, this);
|
|
}
|
|
|
|
// only remove user from challenges if it's set to leave-challenges
|
|
if (keepChallenges === 'leave-challenges') {
|
|
const challenges = await Challenge.find({
|
|
_id: { $in: user.challenges },
|
|
group: group._id,
|
|
}).exec();
|
|
|
|
const challengesToRemoveUserFrom = challenges.map(chal => chal.unlinkTasks(user, keep, false));
|
|
await Promise.all(challengesToRemoveUserFrom);
|
|
}
|
|
|
|
// Unlink group tasks
|
|
const assignedTasks = await Tasks.Task.find({
|
|
'group.id': group._id,
|
|
userId: { $exists: false },
|
|
'group.assignedUsers': user._id,
|
|
}).exec();
|
|
const assignedTasksToRemoveUserFrom = assignedTasks
|
|
.map(task => this.unlinkTask(task, user, keep, false));
|
|
await Promise.all(assignedTasksToRemoveUserFrom);
|
|
|
|
this.unlinkTags(user);
|
|
|
|
// the user could be modified by calls to `unlinkTask` for challenge and group tasks
|
|
// it has not been saved before to avoid multiple saves in parallel
|
|
const promises = user.isModified() ? [user.save()] : [];
|
|
|
|
// remove the group from the user's groups
|
|
const userUpdate = { $pull: { 'preferences.tasks.mirrorGroupTasks': group._id } };
|
|
if (group.type === 'guild') {
|
|
userUpdate.$pull.guilds = group._id;
|
|
promises.push(User.updateOne({ _id: user._id }, userUpdate).exec());
|
|
} else {
|
|
userUpdate.$set = { party: {} };
|
|
promises.push(User.updateOne({ _id: user._id }, userUpdate).exec());
|
|
|
|
update.$unset = { [`quest.members.${user._id}`]: 1 };
|
|
}
|
|
|
|
// If user is the last one in group and group is private, delete it
|
|
if (group.memberCount <= 1 && group.privacy === 'private') {
|
|
// double check the member count is correct
|
|
// so we don't accidentally delete a group that still has users in it
|
|
let members;
|
|
if (group.type === 'guild') {
|
|
members = await User.find({ guilds: group._id }).select('_id').exec();
|
|
} else {
|
|
members = await User.find({ 'party._id': group._id }).select('_id').exec();
|
|
}
|
|
_.remove(members, { _id: user._id });
|
|
|
|
if (members.length === 0) {
|
|
promises.push(group.deleteOne());
|
|
return Promise.all(promises);
|
|
}
|
|
}
|
|
// otherwise If the leader is leaving
|
|
// (or if the leader previously left, and this wasn't accounted for)
|
|
update.$inc = { memberCount: -1 };
|
|
if (group.leader === user._id) {
|
|
const query = group.type === 'party' ? { 'party._id': group._id } : { guilds: group._id };
|
|
query._id = { $ne: user._id };
|
|
const seniorMember = await User.findOne(query).select('_id').exec();
|
|
|
|
// could be missing in case of public guild (that can have 0 members)
|
|
// with 1 member who is leaving
|
|
if (seniorMember) update.$set = { leader: seniorMember._id };
|
|
}
|
|
promises.push(group.updateOne(update).exec());
|
|
|
|
return Promise.all(promises);
|
|
};
|
|
|
|
schema.methods.unlinkTags = function unlinkTags (user) {
|
|
const group = this;
|
|
user.tags.forEach(tag => {
|
|
if (tag.group && tag.group === group._id) {
|
|
tag.group = undefined;
|
|
}
|
|
});
|
|
};
|
|
|
|
schema.methods.syncTask = async function groupSyncTask (taskToSync, users, assigningUser) {
|
|
const group = this;
|
|
const toSave = [];
|
|
for (const user of users) {
|
|
const assignmentData = {
|
|
assignedDate: new Date(),
|
|
assignedUsername: user.auth.local.username,
|
|
assigningUsername: assigningUser.auth.local.username,
|
|
completed: false,
|
|
};
|
|
|
|
if (!taskToSync.group.assignedUsersDetail) {
|
|
taskToSync.group.assignedUsersDetail = {};
|
|
}
|
|
if (!taskToSync.group.assignedUsersDetail[user._id]) {
|
|
taskToSync.group.assignedUsersDetail[user._id] = assignmentData;
|
|
}
|
|
taskToSync.markModified('group.assignedUsersDetail');
|
|
|
|
taskToSync.group.assignedUsers = _.keys(taskToSync.group.assignedUsersDetail);
|
|
|
|
// Sync tags
|
|
const userTags = user.tags;
|
|
const i = _.findIndex(userTags, { id: group._id });
|
|
|
|
if (i !== -1) {
|
|
if (userTags[i].name !== group.name) {
|
|
// update the name - it's been changed since
|
|
userTags[i].name = group.name;
|
|
userTags[i].group = group._id;
|
|
}
|
|
} else {
|
|
userTags.push({
|
|
id: group._id,
|
|
name: group.name,
|
|
group: group._id,
|
|
});
|
|
}
|
|
toSave.push(user.save());
|
|
}
|
|
toSave.push(taskToSync.save());
|
|
return Promise.all(toSave);
|
|
};
|
|
|
|
schema.methods.unlinkTask = async function groupUnlinkTask (
|
|
unlinkingTask,
|
|
user,
|
|
keep,
|
|
saveUser = true,
|
|
) {
|
|
const findQuery = {
|
|
'group.taskId': unlinkingTask._id,
|
|
'group.assignedUsers': user._id,
|
|
};
|
|
|
|
delete unlinkingTask.group.assignedUsersDetail[user._id];
|
|
unlinkingTask.group.assignedUsers = _.keys(unlinkingTask.group.assignedUsersDetail);
|
|
unlinkingTask.markModified('group');
|
|
|
|
const promises = [unlinkingTask.save()];
|
|
|
|
if (keep === 'keep-all') {
|
|
await Tasks.Task.updateOne(findQuery, {
|
|
$set: { group: {} },
|
|
}).exec();
|
|
|
|
// When multiple tasks are being unlinked at the same time,
|
|
// save the user once outside of this function
|
|
if (saveUser) await user.save();
|
|
} else { // keep = 'remove-all'
|
|
const task = await Tasks.Task.findOne(findQuery).select('_id type completed').exec();
|
|
// Remove task from user.tasksOrder and delete them
|
|
if (task && (task.type !== 'todo' || !task.completed)) {
|
|
removeFromArray(user.tasksOrder[`${task.type}s`], task._id);
|
|
user.markModified('tasksOrder');
|
|
}
|
|
|
|
if (task) {
|
|
promises.push(task.deleteOne());
|
|
}
|
|
// When multiple tasks are being unlinked at the same time,
|
|
// save the user once outside of this function
|
|
if (saveUser) promises.push(user.save());
|
|
}
|
|
await Promise.all(promises);
|
|
};
|
|
|
|
schema.methods.removeTask = async function groupRemoveTask (task) {
|
|
const group = this;
|
|
const removalPromises = [];
|
|
|
|
// Delete individual task copies and related notifications
|
|
const userTasks = await Tasks.Task.find({
|
|
userId: { $exists: true },
|
|
'group.id': group.id,
|
|
'group.taskId': task._id,
|
|
}, { userId: 1, _id: 1 }).exec();
|
|
|
|
userTasks.forEach(async userTask => {
|
|
const assignedUser = await User.findOne({ _id: userTask.userId }, 'notifications tasksOrder').exec();
|
|
|
|
let notificationIndex = assignedUser.notifications.findIndex(notification => notification
|
|
&& notification.type === 'GROUP_TASK_ASSIGNED'
|
|
&& notification.data && notification.data.taskId === task._id);
|
|
|
|
if (notificationIndex !== -1) {
|
|
assignedUser.notifications.splice(notificationIndex, 1);
|
|
}
|
|
|
|
notificationIndex = assignedUser.notifications.findIndex(notification => notification
|
|
&& notification.type === 'GROUP_TASK_NEEDS_WORK'
|
|
&& notification.data && notification.data.task
|
|
&& notification.data.task.id === userTask._id);
|
|
|
|
if (notificationIndex !== -1) {
|
|
assignedUser.notifications.splice(notificationIndex, 1);
|
|
}
|
|
|
|
notificationIndex = assignedUser.notifications.findIndex(notification => notification
|
|
&& notification.type === 'GROUP_TASK_APPROVED'
|
|
&& notification.data && notification.data.task
|
|
&& notification.data.task._id === userTask._id);
|
|
|
|
if (notificationIndex !== -1) {
|
|
assignedUser.notifications.splice(notificationIndex, 1);
|
|
}
|
|
|
|
await Tasks.Task.deleteOne({ _id: userTask._id });
|
|
removeFromArray(assignedUser.tasksOrder[`${task.type}s`], userTask._id);
|
|
removalPromises.push(assignedUser.save());
|
|
});
|
|
|
|
// Get Managers
|
|
const managerIds = Object.keys(group.managers);
|
|
managerIds.push(group.leader);
|
|
const managers = await User.find({ _id: managerIds }, 'notifications').exec(); // Use this method so we can get access to notifications
|
|
|
|
// Remove old notifications
|
|
managers.forEach(manager => {
|
|
const notificationIndex = manager.notifications.findIndex(notification => notification
|
|
&& notification.data && notification.data.groupTaskId === task._id
|
|
&& notification.type === 'GROUP_TASK_APPROVAL');
|
|
|
|
if (notificationIndex !== -1) {
|
|
manager.notifications.splice(notificationIndex, 1);
|
|
}
|
|
removalPromises.push(manager.save());
|
|
});
|
|
|
|
removeFromArray(group.tasksOrder[`${task.type}s`], task._id);
|
|
group.markModified('tasksOrder');
|
|
removalPromises.push(group.save());
|
|
|
|
return Promise.all(removalPromises);
|
|
};
|
|
|
|
// Returns true if the user has reached the spam message limit
|
|
schema.methods.checkChatSpam = function groupCheckChatSpam (user) {
|
|
if (this._id !== TAVERN_ID) {
|
|
return false;
|
|
} if (user.contributor && user.contributor.level >= SPAM_MIN_EXEMPT_CONTRIB_LEVEL) {
|
|
return false;
|
|
}
|
|
|
|
const currentTime = Date.now();
|
|
let userMessages = 0;
|
|
for (let i = 0; i < this.chat.length; i += 1) {
|
|
const message = this.chat[i];
|
|
if (message.uuid === user._id && currentTime - message.timestamp <= SPAM_WINDOW_LENGTH) {
|
|
userMessages += 1;
|
|
if (userMessages >= SPAM_MESSAGE_LIMIT) {
|
|
return true;
|
|
}
|
|
} else if (currentTime - message.timestamp > SPAM_WINDOW_LENGTH) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
schema.methods.hasActiveGroupPlan = function hasActiveGroupPlan () {
|
|
const now = new Date();
|
|
const { plan } = this.purchased;
|
|
return plan && plan.customerId
|
|
&& (!plan.dateTerminated || moment(plan.dateTerminated).isAfter(now));
|
|
};
|
|
|
|
schema.methods.hasNotCancelled = function hasNotCancelled () {
|
|
const { plan } = this.purchased;
|
|
return Boolean(this.hasActiveGroupPlan() && !plan.dateTerminated);
|
|
};
|
|
|
|
schema.methods.hasCancelled = function hasCancelled () {
|
|
const { plan } = this.purchased;
|
|
return Boolean(this.hasActiveGroupPlan() && plan.dateTerminated);
|
|
};
|
|
|
|
schema.methods.updateGroupPlan = async function updateGroupPlan (removingMember) {
|
|
// Recheck the group plan count
|
|
this.memberCount = await this.getMemberCount();
|
|
|
|
if (this.purchased.plan.paymentMethod === stripePayments.constants.PAYMENT_METHOD) {
|
|
await stripePayments.chargeForAdditionalGroupMember(this);
|
|
} else if (
|
|
this.purchased.plan.paymentMethod === amazonPayments.constants.PAYMENT_METHOD
|
|
&& !removingMember
|
|
) {
|
|
await amazonPayments.chargeForAdditionalGroupMember(this);
|
|
}
|
|
};
|
|
|
|
export const model = mongoose.model('Group', schema);
|
|
|
|
// initialize tavern if !exists (fresh installs)
|
|
// do not run when testing as it's handled by the tests and can easily cause a race condition
|
|
if (!nconf.get('IS_TEST')) {
|
|
model.countDocuments({ _id: TAVERN_ID }).then(count => {
|
|
if (count === 0) {
|
|
new model({ // eslint-disable-line new-cap
|
|
_id: TAVERN_ID,
|
|
leader: '7bde7864-ebc5-4ee2-a4b7-1070d464cdb0', // Siena Leslie
|
|
name: 'Tavern',
|
|
type: 'guild',
|
|
privacy: 'public',
|
|
}).save();
|
|
}
|
|
});
|
|
}
|