Compare commits
1847 Commits
1.248.0
...
9db8c5ccef
Author | SHA1 | Date | |
---|---|---|---|
9db8c5ccef | |||
589eefaa76 | |||
b260c4f450 | |||
44850e0802 | |||
25320e05d9 | |||
72f1246b39 | |||
12ae54e01b | |||
86a05c0636 | |||
ddb50295b2 | |||
3a09996ca1 | |||
e1218699bc | |||
d923e6f2e2 | |||
c40aa11827 | |||
533481fc56 | |||
f3764f32bb | |||
f63d171678 | |||
3b68400a23 | |||
7def725815 | |||
5fa7b7a231 | |||
51f0ecbfc1 | |||
8b353fbf66 | |||
8f4f29571b | |||
b4656322c7 | |||
6cdcf9622e | |||
a8d1248461 | |||
b3954b047f | |||
035306d4d8 | |||
2e6a126686 | |||
8462b4238f | |||
2f35f7225f | |||
622393a7cf | |||
1b61089475 | |||
168a679535 | |||
9cc9bee934 | |||
0349e17a8e | |||
c25b45a2b8 | |||
8f165d46e0 | |||
aa43b674f7 | |||
634bdf16d6 | |||
61db5f24c0 | |||
190abdf9cc | |||
301bdc3055 | |||
7577a452f0 | |||
05b0728b2a | |||
53ded6e105 | |||
fc3613be4e | |||
9c43d9ef44 | |||
c90dca565f | |||
f1acff1c76 | |||
69a0de28cd | |||
78494dd480 | |||
46878ea5a8 | |||
ea05e61bd4 | |||
b2698fccbd | |||
2413244bf1 | |||
da79cf406f | |||
57957a7b30 | |||
fe6dcdf682 | |||
ec098c8d68 | |||
3a97054cae | |||
02a4e27083 | |||
57673046e7 | |||
e26b015407 | |||
239adc1045 | |||
ab9133fa24 | |||
b8c6a73f30 | |||
f1ab3ff8e8 | |||
4f76ee6758 | |||
1c604c0dbe | |||
ec8fce44a6 | |||
58dfba8e63 | |||
842a7509d0 | |||
ab379f9abf | |||
ece8c80aa1 | |||
c7f39d3884 | |||
cda06623fc | |||
b616274380 | |||
65e4f83c4f | |||
f92c877769 | |||
35ef06d27a | |||
bbcef36abe | |||
56f943824e | |||
7d4339e262 | |||
72d5c713c5 | |||
e028351015 | |||
a11e9c7f48 | |||
a8d353f29d | |||
a7b4b2effe | |||
dee612b2a2 | |||
e81bd4b51f | |||
5b786ba143 | |||
e496c49555 | |||
cb72c2d93e | |||
b6c4ef1997 | |||
691bdf85a2 | |||
b69f578972 | |||
8cdef05516 | |||
06298089db | |||
f638b102da | |||
5ed14708d4 | |||
6416485dba | |||
8a986211b0 | |||
8878212487 | |||
738107ca7e | |||
3037f7cec6 | |||
e6416d5a00 | |||
d711fed4f5 | |||
9905c428af | |||
6e60cdeae8 | |||
9ab21508a5 | |||
14cb0c98ce | |||
2be43d81d8 | |||
f708d98710 | |||
f09946f72f | |||
27a2ee5f54 | |||
e2b27fb6e9 | |||
c095d08816 | |||
d4f28e2759 | |||
582355ecb5 | |||
a75599bf5d | |||
5d2f763ca2 | |||
0137b57735 | |||
8bd869e1b2 | |||
954cf765b8 | |||
fa27a05bcf | |||
7eb5493dd7 | |||
eb26707e8c | |||
657cb510d8 | |||
38178c774b | |||
5ce8a7ab5c | |||
6533f00bae | |||
4cb4375514 | |||
512b84016c | |||
4b65b6277e | |||
cca26040bf | |||
59f0307dcf | |||
553c10ac91 | |||
39ac6f352f | |||
96fb720cd8 | |||
04d7792b8b | |||
b6f202c94e | |||
9b98bb07be | |||
fc0f2e30c0 | |||
d1688242b0 | |||
c72d219246 | |||
a932230fb4 | |||
663cee9a05 | |||
7481296e76 | |||
2e4ad7b0ea | |||
6f16e0a650 | |||
4e51a973c7 | |||
e3633aaa30 | |||
4d201acdbe | |||
59f84aa46f | |||
511a2d6d0d | |||
40b628e0e7 | |||
75f34101b8 | |||
0400cb0551 | |||
996f7f3f40 | |||
d5e64eaed4 | |||
e4968dbea7 | |||
ec79a9efb6 | |||
9fe19868b9 | |||
5aad1b4434 | |||
d7171b9221 | |||
ffa21edd8e | |||
ca45098de3 | |||
80bb1b1f64 | |||
ce85a14b11 | |||
86683a5bf3 | |||
a593caa94c | |||
8c7cb78c0b | |||
587ce8dd72 | |||
68868df41a | |||
f6262e2021 | |||
a4ee33f6df | |||
9259c7b605 | |||
2e72ac7faf | |||
b1340a96e5 | |||
02681cc479 | |||
87f6357d74 | |||
bbbd974be6 | |||
1cf7ffdee8 | |||
b9917e72b2 | |||
7a602ea2d6 | |||
aca4c3d46d | |||
ee6b723ba5 | |||
f410ca775d | |||
189808f9bf | |||
96112955ff | |||
167abe4107 | |||
8fc9d1c75c | |||
3f84caa9f6 | |||
74bc8222d6 | |||
ff7caf9c5c | |||
28f7781fa2 | |||
46cbbd3bc4 | |||
615278a887 | |||
81f874bbc2 | |||
15639cb3d0 | |||
c9047e7c17 | |||
38908f0e19 | |||
be4ac17a5c | |||
f2638614d4 | |||
9a579dd884 | |||
bb04a3a74f | |||
08a9202dea | |||
7a19fc53b0 | |||
c5fffed795 | |||
09c186f560 | |||
7fadbd79ea | |||
46422f731e | |||
6d8240dfed | |||
6ee6121317 | |||
c3bd433ac9 | |||
a776ea8864 | |||
943ff2f0dc | |||
6ad9661d7f | |||
de68841843 | |||
57659d2c04 | |||
0bd1a94a7b | |||
bc3535946c | |||
f3712b293c | |||
6341a6de6a | |||
41eb9c56dd | |||
febb4119c6 | |||
801030ce64 | |||
618a918423 | |||
0841f8bd5b | |||
291be3e605 | |||
d6357487ea | |||
ebef361d63 | |||
17ffb29275 | |||
758a52087d | |||
0e01674552 | |||
1be0a64417 | |||
13582afd93 | |||
45095cfac0 | |||
fea90876bb | |||
73be7f3969 | |||
f97075d82b | |||
11d5f36c31 | |||
c85a1be3cf | |||
dbfac54af9 | |||
66cace0c56 | |||
9461b32a39 | |||
ea24a618b0 | |||
3194ed2145 | |||
11a32a75c6 | |||
d8ab48efc2 | |||
2067e8ea40 | |||
c6525ec0f4 | |||
66bbbc2cb8 | |||
14eeec8f4f | |||
8fe767a4e3 | |||
4e368e09a6 | |||
fc7e350cf7 | |||
e4e05fac94 | |||
9ca901f6e6 | |||
cc88bd9069 | |||
18de790f36 | |||
5f98dfa5d6 | |||
0bc52fd80e | |||
a8ea8a4599 | |||
707c77f0cf | |||
6d440eb777 | |||
c9693d2d58 | |||
2568fe4828 | |||
e7356dc170 | |||
4b0e75b26c | |||
b1b6ea1c24 | |||
0e7482938b | |||
9d8f116dd1 | |||
a1934ee82b | |||
8c0de59414 | |||
95cb6dcb8d | |||
92b025bff3 | |||
15856264f8 | |||
09a9148fec | |||
6057794eb6 | |||
9f72835d58 | |||
f800650c4d | |||
70f2f01f8f | |||
8fb484af4d | |||
190bb4b6fb | |||
04d5416c6d | |||
9c27fb70aa | |||
316b7e82f1 | |||
93001b68ad | |||
a1fbdc2ebe | |||
c857bd1b2e | |||
b2aa31f4ba | |||
de74d5c3d6 | |||
5652d19a88 | |||
a80ca507f8 | |||
5f56812125 | |||
0004c5dabe | |||
45c0487ba7 | |||
1ee9cd3de1 | |||
d7f69020c2 | |||
db53ef54c4 | |||
10e725b51a | |||
53ae0d8aa7 | |||
ab44773945 | |||
5de176a7ef | |||
6a195a7a36 | |||
e61d797d2d | |||
0f19cb37e6 | |||
c9c066c8f7 | |||
c790d0df21 | |||
8e2e73137c | |||
e05f481344 | |||
d4c5d58781 | |||
6a10b932b0 | |||
906e526cb9 | |||
ed97029e1b | |||
4104fb2f8f | |||
9a885e821e | |||
fccac5640d | |||
405ec0d2b2 | |||
6bc4ffe03d | |||
76ff34b0c4 | |||
0c2063343d | |||
3baab79b54 | |||
3984d4363b | |||
52164b4994 | |||
a0445740c4 | |||
e311ede3af | |||
19b8c514f2 | |||
e25aebf26f | |||
0718a2e3f9 | |||
75b8f5828d | |||
d325f8bfaf | |||
2b3fcf4105 | |||
51b68e561f | |||
bdf9461148 | |||
2951fb37f9 | |||
277a1cb38d | |||
a5331dd32b | |||
4c658a1f6a | |||
6d67d037eb | |||
6abb94ba33 | |||
6ecd7ac768 | |||
524adfc672 | |||
788c9d853c | |||
308d3b64b1 | |||
a414cfab52 | |||
68cb4b27d1 | |||
6e4758a183 | |||
6bb7c0d196 | |||
d59dfc3d5b | |||
6e630bd5a5 | |||
a9d26b319d | |||
ba52e385a1 | |||
2903cb396e | |||
cfd5b6bbf2 | |||
6e490e5710 | |||
7f30424792 | |||
021ef11daa | |||
d5fb32fb52 | |||
04b056e4e5 | |||
ce6f175f9a | |||
6696a4447a | |||
0403117e8c | |||
144e5da753 | |||
7f97168aa7 | |||
ab10b9da54 | |||
6be8565442 | |||
662bfb647e | |||
a14c10bad2 | |||
67a147bd96 | |||
f8da265f5f | |||
aee5e833a5 | |||
3289bd5cc9 | |||
4db5e68f5c | |||
c44da0bce3 | |||
f9c6a0cef3 | |||
7a11bb93d5 | |||
d158d0c326 | |||
5f4cbe3af7 | |||
69ab792b46 | |||
f5c0d803a0 | |||
7252a54ae0 | |||
73cd2f9cb7 | |||
f3927b25a4 | |||
98957d282e | |||
8db7996539 | |||
fc5ed887ff | |||
d93fec71b1 | |||
7e339972ad | |||
2725b94169 | |||
14b88edff6 | |||
29d3b762a8 | |||
adf9a71cbd | |||
bce18f7261 | |||
f0f67cdacb | |||
f48ce4e1ae | |||
b50a1fc63d | |||
8cbefbc1f4 | |||
b0c2d3cddf | |||
dc3f25583a | |||
ecd75b5d8a | |||
e715ce14e5 | |||
e2ae43bf28 | |||
9a2ca9cd08 | |||
fd26253e7a | |||
f3e2091ff4 | |||
a597a58dad | |||
bcc8cb1e5b | |||
252fb3fe11 | |||
6fa0400980 | |||
c4742d3a53 | |||
dd28f38e60 | |||
9b8f552ee2 | |||
413076141c | |||
85a7838a96 | |||
3e418b10fd | |||
8364f7f703 | |||
84457f4816 | |||
7f6b8145d7 | |||
2ad91e5796 | |||
a36404d375 | |||
24a3d92da0 | |||
1b2a7dc2e4 | |||
a6d68ae296 | |||
feb3de768a | |||
5deeb8861e | |||
d00d7ac1dd | |||
04e0bbd4c1 | |||
0b596b6541 | |||
a05203c785 | |||
00f06d6aef | |||
85cc72627b | |||
e8f0d2bb14 | |||
33de8a10bb | |||
ed30549f6b | |||
7d8f34a90f | |||
8d0890a400 | |||
64c1d25e64 | |||
395d7f08ac | |||
ba438c25ef | |||
bb445ddf2e | |||
4a97e2bb54 | |||
e301dc5612 | |||
e7f10ad4ad | |||
1ed71ed7ec | |||
46fb075ecb | |||
98e9b5d895 | |||
67aa76d31c | |||
f59e8c8798 | |||
336f7b002c | |||
be09acdb24 | |||
6f227e677c | |||
e918970feb | |||
5e4201d831 | |||
20d709380a | |||
7053aba2da | |||
c2f69501c7 | |||
583c14128b | |||
9059d4f971 | |||
9b07b19523 | |||
7761e4d712 | |||
3cd77523a1 | |||
2bd14b135c | |||
f0df8a5254 | |||
cb472c0884 | |||
d1f6601c5e | |||
97467a3809 | |||
22127b5915 | |||
4865aa1665 | |||
520c176cd6 | |||
df9a0ec35a | |||
d703088611 | |||
38ac3d387b | |||
1b90fba656 | |||
fbf377f67f | |||
3de192c65e | |||
9fb80e5067 | |||
d236ecfe85 | |||
323cfbfcaa | |||
8735fc3fad | |||
3eba400d21 | |||
7b8dc480f4 | |||
5a4f1c03cb | |||
0ef2b82852 | |||
c4cbdfc643 | |||
403ee2741d | |||
383a02519a | |||
a5211f6a29 | |||
9edffd100e | |||
6db881b08f | |||
557a0bf808 | |||
bdb3a8f1dc | |||
9cd4321bd0 | |||
728f84e7eb | |||
1bc2b47452 | |||
8c322b4e81 | |||
1204240ed0 | |||
df5e2f5f0e | |||
fb44933c9c | |||
7ea9061852 | |||
8018236942 | |||
d6dbc0d9e3 | |||
c48e4ec4c6 | |||
a6d9f5dd69 | |||
8fab73f122 | |||
3fa32b2ae9 | |||
676be076c3 | |||
adcddae44e | |||
5358f13e93 | |||
71a568264d | |||
c6f804f68c | |||
e4ba27850d | |||
0fc83486dc | |||
267023f2c9 | |||
68114d99be | |||
b794c4dcc8 | |||
2c54e566ed | |||
7a695f34f2 | |||
72f11d8f13 | |||
208e5c8adb | |||
c4a28c6bff | |||
4505441691 | |||
da38abff62 | |||
bc51f106e9 | |||
7d80d1ad3c | |||
80330782d2 | |||
d08e8b4fd8 | |||
633995c9c8 | |||
4a8142b326 | |||
7db7eeecf2 | |||
e4074f95c9 | |||
e23019a115 | |||
a8e0bb5a21 | |||
b334ab351b | |||
c28af12cbd | |||
f360a12823 | |||
4984d79bc7 | |||
84d23764db | |||
1aed45769d | |||
d034eb04c4 | |||
099896f46b | |||
724188847a | |||
ea1b6fdf6e | |||
2b212078b8 | |||
ac5aec9262 | |||
32f3c3a1f2 | |||
f883469b55 | |||
9f5707d4f5 | |||
dc3a21936b | |||
12c722afe1 | |||
6d931134dd | |||
2de959f080 | |||
6c79ccb2a9 | |||
bce9b2f4bb | |||
25ea451eb6 | |||
d99b1a4cec | |||
bb892449ca | |||
36e987d0f1 | |||
ac1ac67b21 | |||
581651ef1d | |||
8bd3e6c79b | |||
7b2d8e4d3a | |||
952c2b71a2 | |||
26277803c6 | |||
130c6da6fc | |||
75dd43637c | |||
56ddbaf972 | |||
8945f5c784 | |||
efed1d3e1a | |||
18df8cebeb | |||
706784f7a0 | |||
716f979502 | |||
e42d2b1702 | |||
001091a413 | |||
2893d71377 | |||
9246a73f41 | |||
d1276dc1a7 | |||
475bc3b86d | |||
ed85520143
|
|||
8c536b45b9
|
|||
46553da2c3 | |||
c34959896c | |||
2bbad8f4b0 | |||
49485f79d2 | |||
0f46ca4db2 | |||
8bb3c809eb
|
|||
9f5da976f2 | |||
b8d9bafef6 | |||
1fd0c08477 | |||
ae1b0a5bd2 | |||
4504462352 | |||
99558fec44 | |||
7462ccd612 | |||
bd1233b0e2 | |||
40de0cead5 | |||
43f5bb7773 | |||
e85cc0fcfc | |||
dc1948016f | |||
4410040a14 | |||
b2ed0b2c80 | |||
42fe653e1e | |||
8a81fa814f | |||
98f3fa9d7c | |||
202e27fe25 | |||
757ff527d0 | |||
41f5801b5e | |||
4c7657a90e | |||
aef650753e | |||
420f331be9 | |||
e0068c4d5d | |||
85661884a6 | |||
8f6203d296 | |||
2fa723dc3c | |||
a500fb72c5 | |||
02db0db733 | |||
c87b08ca8b | |||
fcc2ab1a48 | |||
7efda2f890 | |||
3794a61d2d | |||
c1d1ea9dde | |||
0d676a46c8 | |||
97db144e01 | |||
cec55127c8 | |||
f3f359bcfb | |||
601e6f4147 | |||
e228b4925c | |||
62e3ffe413 | |||
6af885fde0 | |||
dd15bba359 | |||
43fca7ff43 | |||
faa6af5694 | |||
d2ea7a0bfb | |||
3f6319e00b | |||
5601299648 | |||
6060c7cfe0 | |||
ba78c2783d | |||
48eee5f865 | |||
f4a8acdb46 | |||
1d6ba22598 | |||
e38be8d710 | |||
da5be3fb57 | |||
b5317a7f95 | |||
43afb16808 | |||
d5c56fb16c | |||
b94c1f280b | |||
acc59866a3 | |||
c9fc3e402d | |||
6c1317f978 | |||
89be438e66 | |||
9d6214e93a | |||
0640b24290 | |||
6eb9d9d973 | |||
9ecc3176a5 | |||
96434c5a54 | |||
4063c62a17 | |||
890c5b986c | |||
423bd92b89 | |||
5dc331e386 | |||
744dc51dcd | |||
b0c53d050a | |||
830569b38e | |||
35b4aef06f | |||
bc2fd9c970 | |||
c42a8aebed | |||
fad1adb91b | |||
9cd37f8de0 | |||
d49b90d7a5 | |||
130a9ea062 | |||
ffc6309850 | |||
976cc7f243 | |||
7067aca04b | |||
1c9805bb96 | |||
8227a2d91a | |||
194aee97db | |||
0f77169952 | |||
0f8dc62c53 | |||
554136cdcd | |||
83b5cfff1f | |||
dcec3accf0 | |||
f08b0b570b | |||
8386fec98a | |||
4d3dff3e5b | |||
76890e63fa | |||
4fb2aebf4f | |||
ed5cd3b978 | |||
469c1936b4 | |||
8b3cc5c11a | |||
ee086638f3 | |||
58d1abbd38 | |||
ba979cbae2 | |||
8cda43bb63 | |||
c4499df74c | |||
24bcc15b6a | |||
ff121243e4 | |||
70e633b997 | |||
0780ee4adb | |||
09613f9324 | |||
8642b1a7af | |||
f96f861341 | |||
a201fc7a97 | |||
a97110348c | |||
a25d5b9dc0 | |||
6c2acf2aa6 | |||
519827045a | |||
79a7e12a9f | |||
bf20a5de82 | |||
0adefe14e1 | |||
f24561cc3d | |||
873fd53715 | |||
e5d8faf2dc | |||
65d3bd2802 | |||
ad60373813 | |||
b725e6e2ec | |||
88c420ca5e | |||
118e17f78c | |||
cc92592d86 | |||
46eb3254a9 | |||
2477491f18 | |||
5fc9fde129 | |||
00e50c6abe | |||
8131a7ad03 | |||
f5e6f7dcfe | |||
87501e094d | |||
d3bfdf78c3 | |||
fc4e6ae6db | |||
23e4d5454d | |||
fdcf5fd396 | |||
8a9ae9bb33 | |||
3fb7e746df | |||
137e8e090a | |||
6d941500cd | |||
9c4e22978d | |||
af65a99398 | |||
1a0cb561cd | |||
9f875adf0c | |||
6b0dadb895 | |||
98a9523eee | |||
dfb3365efb | |||
0e08d8830e | |||
69d85eadfd | |||
c009f8c12f | |||
60ef46accf | |||
b12ac1fe84 | |||
4355c96ab6 | |||
fb326fe0cc | |||
02cfebd98c | |||
dd2936d703 | |||
918d0b85d4 | |||
a061595101 | |||
dcd496ac50 | |||
6b9ec549da | |||
b5bd4df483 | |||
766a2d7c2f | |||
6dabf7516a | |||
5d49ff7a4a | |||
8998c18836 | |||
741a0e36d2 | |||
e31b4c64cb | |||
812ff5cbdc | |||
035b90a689 | |||
5ea3a187f4 | |||
5d9c38663d | |||
5616bc4956 | |||
9ad1c2177c | |||
15bf9f2f9c | |||
ebc5008569 | |||
37759ba03f | |||
8319b216bb | |||
782d131b0d | |||
72e75208df | |||
4b1c27c245 | |||
61f0da35bc | |||
80464c7846 | |||
74f4323903 | |||
127dbf9dcd | |||
66bdb374e8 | |||
4ad4fa2b30 | |||
1fd836194f | |||
2090db1199 | |||
053c7e591e | |||
9b5e350e3b | |||
378e57c3bc | |||
6765191a8c | |||
8438a45bcf | |||
30a64e7fc1 | |||
f2cb671c7f | |||
3f41e5c5de | |||
c1ad483f33 | |||
f3d961bc16 | |||
42b70ef568 | |||
77beaaba08 | |||
d9c07456cd | |||
0a53df4293 | |||
4416ba0c88 | |||
486de968a2 | |||
a5833566a8 | |||
261f5844dd | |||
2173c418a7 | |||
4efd5cefd8 | |||
d735e4db75 | |||
e10707fde4 | |||
ac953df809 | |||
bb4ee50738 | |||
4f41bac328 | |||
cd07802400 | |||
b692b7432c | |||
a4efbc0131 | |||
55b0fe232c | |||
46432edce9 | |||
990028316e | |||
37871fbabc | |||
0fdeef7953 | |||
cdbe6eedeb | |||
a6dde8ad43 | |||
39bd4a349b | |||
ab59eb5c92 | |||
1132dc9bdd | |||
2d70b18593 | |||
b6ea7d23fa | |||
95382581f1 | |||
551b83a6e3 | |||
895c4fe299 | |||
ccb1bf881e | |||
7788b5a987 | |||
47fd029e0c | |||
458ee159e1 | |||
dfacbed66d | |||
22d63c6102 | |||
212aa6a63b | |||
bed9ae916c | |||
b6ad362850 | |||
ab86dd5318 | |||
dba73d80a3 | |||
92fb05320a | |||
73d62bb51f | |||
127b7d4f25 | |||
e79d607ab8 | |||
5f7d083f7c | |||
15857118fe | |||
ff91ed21df | |||
9241c04d5a | |||
5d4e2fba8c | |||
6c57609db8 | |||
b31bbbe2d1 | |||
7d308917dd | |||
4e7d93db13 | |||
45340b581f | |||
6f8fe45fc2 | |||
34d9ceb009 | |||
2b97bbd05d | |||
3bf7ac76a0 | |||
71892e67b2 | |||
6e2885ed20 | |||
07c0e5a612 | |||
719bbe156e | |||
b51255a543 | |||
50dbbf0569 | |||
ca2e748c56 | |||
5c480109d5 | |||
6152ff4b44 | |||
c6641fde36 | |||
4ae7e9fcbe | |||
c10ae431a2 | |||
883e30e451 | |||
f1f4f6247d | |||
82fe1de1a7 | |||
371c999fbc | |||
26b9660e11 | |||
ca7717f9c5 | |||
6f3cce1c5f | |||
efdc9b387f | |||
d7b579e3e8 | |||
b8533050b0 | |||
1b81409b35 | |||
8cd6c34ed8 | |||
0c68474802 | |||
34997f91db | |||
084467ee9a | |||
af47889d65 | |||
51203ec96e | |||
a2277dea2c | |||
debd233c32 | |||
f1eeee0525 | |||
5ffc39c32f | |||
a668a66e84 | |||
0581b8b9ec | |||
63a61fb492 | |||
5788c6474e | |||
5529fdc0ee | |||
88a9b518f6 | |||
98de2355c4 | |||
b41eb60348 | |||
0edebe30e1 | |||
e3abe4feee | |||
50391e199a | |||
a33f8d5bed | |||
636be8441e | |||
654dc2ba32 | |||
458ef169f4 | |||
5bb01bb03c | |||
43e9528d8c | |||
522c54c9b4 | |||
0004ced4e1 | |||
274c60e961 | |||
754e98099c | |||
87bf8df1c3 | |||
3f7d6b25c7 | |||
8a062e03ab | |||
a70f45cbf3 | |||
f268264c46 | |||
bbe5d70720 | |||
f1d2a52cba | |||
87cc887865 | |||
61ecd15f1d | |||
eb853f05ae | |||
6285417903 | |||
ca674a654e | |||
2729c5651f | |||
7e28e42995 | |||
e21563d903 | |||
3ede69650c | |||
c289793c6d | |||
a90c067da0 | |||
38c2baf943 | |||
82c78cad6b | |||
bffe6060bd | |||
841bd5c33f | |||
3b895afc9e | |||
00c2ede85e | |||
8420cb830c | |||
a0ddd1f9b9 | |||
40d93066ff | |||
671e4e316b | |||
473136e9aa | |||
9a3db91982 | |||
d23cb5f190 | |||
7a364472c8 | |||
59c064e3c8 | |||
e792924606 | |||
d32dd5e860 | |||
bb86f85203 | |||
0bca8897d6 | |||
ba73f6de2e | |||
eb75be8535 | |||
6d2a897366 | |||
d8bfb23f20 | |||
d9d71e7827 | |||
b642ce08e5 | |||
bc8d8309d4 | |||
1f2f9f22f2 | |||
7a3237f1ff | |||
07661d9262 | |||
77358eed65 | |||
c641c28b12 | |||
c54392b7bb | |||
f3a8822a77 | |||
f1dc075c36 | |||
144d831954 | |||
c37ad9bad4 | |||
4ab3f81384 | |||
b932bac9aa | |||
bcdd873222 | |||
25b3de5828 | |||
40b454d2f3 | |||
5596e5f03b | |||
66992ef915 | |||
7f67430685 | |||
8a49a04324 | |||
5d7c19b0ed | |||
cde74b6c62 | |||
633c65e33c | |||
d1617f2d87 | |||
68e558f198 | |||
12ca01c862 | |||
2115745471 | |||
2cabd21315 | |||
3615e2f057 | |||
d3679d41b3 | |||
f2d431a6b8 | |||
2bc8bebfb8 | |||
5b20ba3382 | |||
15cc294581 | |||
b060b81204 | |||
a8d557eb1b | |||
6ae3a47b54 | |||
88c19eb45e | |||
7728706bc8 | |||
2e9d40c201 | |||
c002e37285 | |||
6be38a1c19 | |||
a3178fb213 | |||
e7158f6e16 | |||
dbea0456bc | |||
fefee11301 | |||
40836b745b | |||
07eabac059 | |||
48b412cfb8 | |||
b62488628c | |||
982c71c728 | |||
5aa16a3779 | |||
93de25e5b6 | |||
9acdb41aa2 | |||
ffbdfb86ec | |||
be7f6bb657 | |||
6f7cbc93b9 | |||
0b5c71130d | |||
0578c645d1 | |||
67ae86763e | |||
266c0a9a2c | |||
a3cdb23776 | |||
e1371a8d2b | |||
448cea0b69 | |||
ad42c0bf28 | |||
f50670c7fe | |||
c0029d3b1d | |||
2518a8fd9d | |||
572dcf075a | |||
29cb83d469 | |||
cac73ac111 | |||
02cf4295a9 | |||
78b3328bf7 | |||
e0d6d9e8ca | |||
54310f2214 | |||
1fec49fbc2 | |||
d00489b547 | |||
2985dd67c5 | |||
5eba764c04 | |||
cc0ce18627 | |||
b758654158 | |||
d5d40c0ea1 | |||
fd294d4d2b | |||
e82cf2e7d0 | |||
446c7cb517 | |||
e921ed7f52 | |||
865402be3a | |||
6eb659d7e6 | |||
37430b7bdc | |||
ef9d77312e | |||
ccaf06360a | |||
f83e75df44 | |||
00a2b60eb5 | |||
fcbf2f1645 | |||
460266a501 | |||
9fe90273c7 | |||
4078229fe6 | |||
609c03f174 | |||
e7d4641d13 | |||
cc1d9811e0 | |||
35450ac004 | |||
9c18f48a32 | |||
87529490c3 | |||
893e76f83f | |||
06ba7a4b1b | |||
c68d113d27 | |||
69e3bee52c | |||
cea569c987 | |||
2a38a16f6b | |||
0f9455cf02 | |||
d4afa03505 | |||
c9237146e2 | |||
faad65b6f3 | |||
e459c72100 | |||
a8add30125 | |||
b535aee91d | |||
4434d0315f | |||
8b10695353 | |||
e82dcc8ace | |||
6dcb0d8583 | |||
40b6777814 | |||
25deba16df | |||
be93ca8968 | |||
0436cc6487 | |||
857708dc4d | |||
1ca4f885b0 | |||
c9368c5cf2 | |||
29423efea3 | |||
f3ee99fb2b | |||
3df8810412 | |||
b8ca88c6df | |||
2c068c412d | |||
9fdbd22cb5 | |||
8f5f4c5875 | |||
50fb82a6e6 | |||
2c10cd7edf | |||
bbde86c66e | |||
73c0843d51 | |||
04fc2cd3e1 | |||
b39c97ab9f | |||
1dd5e9c787 | |||
a9985b65b8 | |||
0a35d5f236 | |||
09ce8b1cd0 | |||
a5ed49fe4c | |||
5c23ece62c | |||
4e9e3f7b6b | |||
5fc84a06cc | |||
12186e1c6c | |||
f2803aecbc | |||
5ba5b86d5f | |||
6167f105fe | |||
8d5f2fd91d | |||
4ac661fb94 | |||
e763bfb2e2 | |||
88c7e34cc3 | |||
0ee632470e | |||
c918deeb1c | |||
1877b31f00 | |||
00895b7bb1 | |||
bff60ddbe0 | |||
d46de0a15e | |||
7b45a8b3fc | |||
693791d113 | |||
1b2d2a9860 | |||
bde8be1385 | |||
74ca058364 | |||
ba3cf82c6e | |||
217bb6aa5a | |||
440dc470fa | |||
165ca94f5b | |||
c418e75139 | |||
76bf839010 | |||
3bdc4c9b4a | |||
005890d785 | |||
256c020e88 | |||
5fa3388609 | |||
be801b481e | |||
a72e98f73c | |||
f5df970685 | |||
edfdc0c346 | |||
fcfe7b1787 | |||
170b8acc65 | |||
a47829082e | |||
48ab5fcf08 | |||
dc8b60eeb1 | |||
ee67432ffc | |||
7755a6b655 | |||
d7f72819de | |||
2a4d7bf14f | |||
d49287922f | |||
ac0f6f40cf | |||
d91f947ab0 | |||
af71274ea9 | |||
0feba4b8d9 | |||
62f85293e2 | |||
6a048cee85 | |||
0d93612d16 | |||
9bf68b0d20 | |||
371f1dc451 | |||
5cb2ec6411 | |||
3723a1d8b8 | |||
4c30e9459d | |||
23d323073d | |||
0ad734262a | |||
0649f9fd2c | |||
d089662dab | |||
8c1c336fc6 | |||
43b4f14ace | |||
3717e38845 | |||
265d4d0450 | |||
726e727c7d | |||
cb664774c0 | |||
b89bf1d5e8 | |||
53ce37a83a | |||
e9ac9057ff | |||
7020fc2a93 | |||
efcd9539dd | |||
61ecc48d0e | |||
e465f1b791 | |||
01b6c14bcc | |||
34b02210df | |||
0034776b34 | |||
b183c45027 | |||
7d68905f1b | |||
0953c072fe | |||
d152187ee8 | |||
3c5affce88 | |||
f27e21f9a0 | |||
337ca328c3 | |||
beb9e2c43f | |||
4d79df90a7 | |||
aa72d9b730 | |||
80e899a5d3 | |||
7c33120546 | |||
7f3c86038f | |||
c1446f8559 | |||
88d5dfe435 | |||
7dc8f80fdf | |||
96f90c7259 | |||
a10d9cb6ba | |||
4547c5da1d | |||
28706d7b26 | |||
492bc5e17b | |||
6c37737051 | |||
8677d20c2c | |||
4d905065ad | |||
5599b41b83 | |||
8d5a60d777 | |||
695acf4f3f | |||
67dbef3b7a | |||
0e94112dc7 | |||
b22edff16b | |||
ffb7cbff50 | |||
25424ad280 | |||
a768902b00 | |||
2c7ece50fe | |||
51a0ede3e4 | |||
531964636b | |||
e461fff1d7 | |||
4f9a5f0340 | |||
8d80e840b8 | |||
833982a9de | |||
c85966e5ed | |||
43f67ba832 | |||
cbea8ac9d3 | |||
d4c939e41d | |||
c1f129501a | |||
377ba75e4c | |||
77b13b88f0 | |||
813e73a0a3 | |||
1d796a9597 | |||
4eedf64a3c | |||
ed4dd79c72 | |||
6f4fd0826c | |||
8e3a144a37 | |||
07b0a2c40a | |||
c5dc3d4272 | |||
73e69273b4 | |||
e0b74ef418 | |||
2b491dc732 | |||
79fc22b5ae | |||
0a83bcd697 | |||
52540d460b | |||
6ff2e0f952 | |||
b3e72383bc | |||
bdfba4d509 | |||
8a411b707d | |||
e21601202e | |||
8f66040df1 | |||
5ad248a643 | |||
fa36c42af4 | |||
d4ddc781e1 | |||
386dd56590 | |||
f28b13604a | |||
d827858d0b | |||
c758ca4bfa | |||
37183a07bd | |||
fb294fc6e2 | |||
8898d02442 | |||
232d30234c | |||
e2234c4966 | |||
272a34195b | |||
8c25294da7 | |||
6f11627006 | |||
215098e418 | |||
781496383b | |||
f0f304c012 | |||
4bf97c104b | |||
0b35a3c7a7 | |||
1586cd3a59 | |||
ae763cbb87 | |||
aa72287d54 | |||
d155ab6f28 | |||
913ca71aa5 | |||
1ffde2a27e | |||
fcf0cea982 | |||
ae1968aadf | |||
3e6333ef95 | |||
c69686651e | |||
93b6011ddc | |||
f567e25f27 | |||
5dc538bafb | |||
b4de06fcf0 | |||
27da0eb26e | |||
8ff80c10e5 | |||
5db5d5e79a | |||
12aac101bd | |||
3a66ccdebe | |||
6a722d1bb7 | |||
7c9407d5dc | |||
8abb517ac6 | |||
dec1d89c5c | |||
24e9ecc3e2 | |||
4a1e05b8cd | |||
39d1a85267 | |||
7cb86de7af | |||
aa078588e8 | |||
fcef0a72d5 | |||
29987d3e2f | |||
6284b4dfe8 | |||
00342ca1f7 | |||
234c4fd511 | |||
669f1fb60c | |||
52df0c62ab | |||
e8e1bb83bf | |||
45510702d0 | |||
1b7e3a1e47 | |||
35f98b9d2d | |||
e980aed9e7 | |||
d993067e9a | |||
3d09bfdb0c | |||
3fbc4f500f | |||
373201a98f | |||
681f88f002 | |||
8a523a981a | |||
81ded53363 | |||
5272407af8 | |||
c48f89d117 | |||
046fdd3ae7 | |||
e69c7a753c | |||
5191415b5a | |||
a704378702 | |||
cf7ce64de7 | |||
8c1b45f35b | |||
6ad1528d01 | |||
4a6fbe4d30 | |||
e31741f0c7 | |||
b26aa7f51d | |||
c0fccd186f | |||
a7baad10d1 | |||
16f1b16e41 | |||
409ddc90ce | |||
95bc84956e | |||
20cefaba19 | |||
379c651ce0 | |||
7804c6879d | |||
de2255f9ba | |||
e4ec5f213e | |||
f3c2fb853d | |||
f5ad1d2d24 | |||
0af37ca1d7 | |||
2992a0da4c | |||
2dcc7e161c | |||
fa627f686f | |||
0567083fc1 | |||
3212efef17 | |||
6077e7c2f9 | |||
96b5dcfaf8 | |||
c4e8e37884 | |||
281d33f825 | |||
5822e4d186 | |||
cb166dcc78 | |||
4e7b7375a9 | |||
b8626c2086 | |||
a59f9fa037 | |||
1666486940 | |||
ac0ad48a65 | |||
6a19eab425 | |||
750c627613 | |||
60b2115e3b | |||
e7956943ba | |||
f66edf8de0 | |||
29028a81f5 | |||
c9878c9050 | |||
73ac4b4197 | |||
016634a77f | |||
ea65dc5034 | |||
84db54babd | |||
653c9c62a8 | |||
74278073b3 | |||
0375b938a2 | |||
32df7620d9 | |||
8492a8fed0 | |||
30e561c06f | |||
7243090c0e | |||
7ae49eb839 | |||
bf816c3b89 | |||
20f9225daa | |||
b6101c6375 | |||
e1022846b9 | |||
9ba79f6721 | |||
0ac97bd112 | |||
827270704a | |||
8634463597 | |||
3905782ad6 | |||
5db984ffef | |||
fb3cd4b689 | |||
3b5a34f6f3 | |||
22b43b5bfc | |||
6c66033eb4 | |||
162fc25e23 | |||
45f385a483 | |||
e9ef911548 | |||
d8d4d8f001 | |||
f47c7313af | |||
31f0056a2d | |||
550e646079 | |||
37ff7acf04 | |||
8236091477 | |||
2a71cb66de | |||
e60fe48fdd | |||
d40bc5070a | |||
fda4e0ea7d | |||
08d696ce33 | |||
46614a7c24 | |||
02b433eb1e | |||
25112a450b | |||
727340748b | |||
8ad6492477 | |||
4af76f6f6d | |||
10940214a5 | |||
d9a6c22e1e | |||
692309988c | |||
42a54263f9 | |||
4fb88859b2 | |||
aa24b5e8c6 | |||
90e18338f6 | |||
ad5ae938ef | |||
c9a8dd4958 | |||
f1ec5e704e | |||
f40f0653c2 | |||
5f7a230fd3 | |||
71feb531e8 | |||
ec3552d7f6 | |||
41875e70d6 | |||
5fa0540936 | |||
5b69dee246 | |||
19b0fe04a6 | |||
19ea4479ff | |||
0b2f6a312c | |||
f79d60014b | |||
5b7409d08e | |||
6230aa87e2 | |||
8b615d2f56 | |||
4100446cac | |||
ad3e6d637c | |||
aa87262954 | |||
01b6bb5b99 | |||
884b7f4de7 | |||
3f8a2b47f9 | |||
e2e4c9be3c | |||
0f7c6ff0fe | |||
703a96f4db | |||
42c0560422 | |||
eb63802d01 | |||
6d9191a46f | |||
6744245d8b | |||
8f64a77a9d | |||
0d5fc7655b | |||
c511ec7e33 | |||
b12349a148 | |||
f7e3a4c727 | |||
5f276469b7 | |||
69e1d92ed3 | |||
ef2849aa6c | |||
c668d7b456 | |||
e23bf62859 | |||
54c5746d21 | |||
7130ac7565 | |||
1851ae137f | |||
6f6ff94979 | |||
7f25066f0f | |||
fc795aaa8c | |||
d0112968e8 | |||
522025ffa0 | |||
27bf662281 | |||
93c27277c6 | |||
5e6adfcef5 | |||
ab691bb27a | |||
8fc5676443 | |||
1fe1e2fe0c | |||
921d38a706 | |||
6161d5e77c | |||
369386f976 | |||
41437636b1 | |||
b21884eb66 | |||
1c5437e1fd | |||
58278ba5e6 | |||
921f3e9807 | |||
75ca125a70 | |||
a1fd4e7a38 | |||
0d5a8eb33e | |||
b088df2fa3 | |||
f45d8f616a | |||
d8300502ce | |||
502d51ad29 | |||
bc33e5f147 | |||
48ba8f936b | |||
05ec4cce05 | |||
d74f283707 | |||
0f8bc7db32 | |||
431500f28a | |||
9672de174e | |||
c6aa06b933 | |||
1f46a6b6f3 | |||
1bed940bc0 | |||
f9eb3cc3c5 | |||
2519c3ffb0 | |||
91013d1d10 | |||
6deefb9c43 | |||
d0744e07df | |||
93e1ee3ba7 | |||
dceaa55a6c | |||
8b4d55925d | |||
754b49e50f | |||
6ccbda8169 | |||
b0fb986208 | |||
0b59fc639d | |||
7ddd6f27b5 | |||
c5d56f4b47 | |||
2f2b712999 | |||
c2fd31f5e5 | |||
f2d70f9070 | |||
f41dd9cd8e | |||
7d238b4935 | |||
da6591fca0 | |||
1f9b9e9998 | |||
49c4ea306d | |||
ccb5c664ef | |||
97e165ff69 | |||
45aefb6a45 | |||
2435535975 | |||
bd3d43bf05 | |||
02dc7c52b1 | |||
ff59fd4196 | |||
4955555ddd | |||
a98c788a26 | |||
9c16af81c7 | |||
2df27100f0 | |||
6cf6538719 | |||
0fd3db3228 | |||
18835149e2 | |||
6c9779fb0d | |||
3e98f097ef | |||
183ac8fa2b | |||
9036f53e7d | |||
f7c04e469a | |||
b5f01c0d15 | |||
5a23cd34ad | |||
6e87f34c6f | |||
6618aa2e9b | |||
0d25a96f7e | |||
4f6d9d3a76 | |||
928f6f0c45 | |||
09e95ddcee | |||
2d003225bc | |||
de93cabd69 | |||
51489cca81 | |||
f7f4c3afb1 | |||
0821086e41 | |||
7a905fde63 | |||
d2882b1119 | |||
3a500598c5 | |||
42274917e0 | |||
8ba50f2729 | |||
f22071f061 | |||
d2312371a6 | |||
ba837c3c30 | |||
d85d83a0f5 | |||
62e8594c57 | |||
509f95ea30 | |||
43d0b55004 | |||
c0f130a077 | |||
90dc34380e | |||
286e41eb21 | |||
4973d0261d | |||
c4a62dfd68 | |||
4d6be0a507 | |||
b259ab7b0c | |||
e1ac5245c7 | |||
d4fea075af | |||
cef7fa79de | |||
ca05397dcd | |||
2a11977001 | |||
fb1a5c93ef | |||
77e9791e03 | |||
efd9e7a5c7 | |||
d9ced885e1 | |||
5fe07cb85f | |||
af008aa74f | |||
ca7bf27c20 | |||
0866587cab | |||
622bb8b0cf | |||
16b9fbe00e | |||
c9353d0a39 | |||
ea101dd3bd | |||
cd67ce82fa | |||
d5b3c52602 | |||
bdf72164b1 | |||
455a2d2e92 | |||
9c0f46b587 | |||
8533606177 | |||
6728e04ff7 | |||
2bf4f1237a | |||
4857b2e620 | |||
68a9a7f6f9 | |||
81ef95e13e | |||
b633132757 | |||
2b0f961370 | |||
30f1a3514a | |||
ed735e0b29 | |||
b89ccd2dde | |||
df6d39377f | |||
d5d14497d6 | |||
09c300661a | |||
92382e0b4d | |||
c25f532487 | |||
5d26d94586 | |||
73b6784e9f | |||
6159f48a62 | |||
7d34fba7c1 | |||
c434b730a8 | |||
2d23c566f1 | |||
ba220eaee9 | |||
09023214ce | |||
1ceabb6e6b | |||
421072c7fa | |||
0d421e7181 | |||
f5180ce88f | |||
aabf27dc96 | |||
421809ae95 | |||
d3234f9e77 | |||
a40be2f744 | |||
e62da06c5c | |||
b7f635bdfc | |||
0a465f125d | |||
c02e390bc1 | |||
f9bec0d793 | |||
2f44748f79 | |||
97504756be | |||
6a802a62a0 | |||
51ca26bb4d | |||
2ecc8dbc4e | |||
c0e0e2401e | |||
1a30c180bc | |||
39d4f80f36 | |||
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 | |||
6f7e370fca | |||
b4a126280f | |||
2d009aacc4 | |||
9116443305 | |||
0adaf12a01 | |||
b6562b6e2c | |||
b0a4b09ef5 | |||
ad8b9ad333 | |||
809956f210 | |||
6077bfa754 | |||
09498bd804 | |||
fd84f4ec14 | |||
c711a11d6e | |||
8232b05f62 | |||
0ea66aebcb | |||
64087de3fc | |||
7082ff12f8 | |||
1c7d92e15e | |||
a53461d257 | |||
d630fb900d | |||
51e8555fa5 | |||
9db675b955 | |||
45bd8ed029 | |||
707fd31550 | |||
6e5f0086a1 | |||
97bcd8ff49 | |||
1809fc8a80 | |||
beb24f9bd4 | |||
ae57a188f5 | |||
23db85e940 | |||
bd8bb1a36a | |||
c48670ccdc | |||
fc019002e2 | |||
4282cb66b8 | |||
1d0ba5fe4b | |||
24cfb26c5b | |||
26a70aa208 | |||
ab7e050066 | |||
26b1fd6572 | |||
d7e682b65a | |||
f589ccb775 | |||
206b6567fd | |||
6857e0314f | |||
c8682a7393 | |||
144b6b2211 | |||
16a5ace4be | |||
b24ddc30c9 | |||
19333ab084 | |||
7529a7a26c | |||
21ebaae6ef | |||
3bc8b3c836 | |||
bb9415cc15 | |||
b3baeb8a5d | |||
1f393e78f6 | |||
215f5eafa6 | |||
1916e5343d | |||
fa9863fc54 | |||
7bf48ef351 | |||
faef3606fd | |||
d0ccd4d238 | |||
51e3650790 | |||
db29e2b666 | |||
655a68a847 | |||
86296b3591 | |||
73c127f10c | |||
cf4c981cd9 | |||
1b9541b933 | |||
5bca8de44e | |||
136c4bf50b | |||
4d700e3b83 | |||
740fa6fc84 | |||
cdb8dc72c7 | |||
4b3afb5c97 | |||
abf208432a | |||
19e6df4fb2 | |||
7fc3fff431 | |||
edd690850c | |||
302339e1cd | |||
739796bc79 | |||
9c30139b86 | |||
0af528b649 | |||
9636c87a2e | |||
ad46fb6d61 | |||
8e000baef2 | |||
a2e1209196 | |||
ef4a75d1f0 | |||
3db20feb54 | |||
b9ec381ea2 | |||
7d6a74a67d | |||
b923cf7752 | |||
e32e457ff8 | |||
32c1e6b390 | |||
b42c0c8355 | |||
7140ed8512 | |||
27d9b075ce | |||
5249257dd8 | |||
606f6159c4 | |||
2e095603b5 | |||
3a99b81ade | |||
577a487301 | |||
086d43376c | |||
31a4c2ff1f | |||
6a1fad611c | |||
e1892d2870 | |||
8ba15f8f72 | |||
876b66f324 | |||
2c5bfb19d3 | |||
1bb94a04e3 | |||
e3c9316486 | |||
c19984c3d0 | |||
9002c20165 | |||
15c96a9757 | |||
1ca3792a4b | |||
90fe467114 | |||
e61b3b34a7 | |||
1326418ffc | |||
a5f0f48ddb | |||
e500ccb61b | |||
4090b03406 | |||
431d1d5fec | |||
d74d79198b | |||
623a284ba4 | |||
f79c36edbb | |||
f4c748f67a | |||
672d8dfab2 | |||
0464adccce | |||
c3df6c3194 | |||
29d53c7df4 | |||
7b77dc044a | |||
67e758365f | |||
475231ffd8 | |||
513a564e2c | |||
cddea0401f | |||
3dafbf7fef | |||
fcd75414be | |||
c1b5bfff8c | |||
3c322cca0d | |||
e965d12e31 | |||
3daf55a0dd | |||
aafedd5f75 | |||
32956ae04c | |||
bfd0241b2d | |||
5eff8402db | |||
ffa020ee2a | |||
80a3668aa9 | |||
7378900050 | |||
9be457943c | |||
93454c6c15 | |||
fccbd76993 | |||
922876a893 | |||
654446f068 | |||
947460abdd | |||
c5635b0050 | |||
8a3a6308a3 | |||
290a07fe79 | |||
4c907d56f0 | |||
56b437ca74 | |||
e23ff33e6f | |||
f2d206262e | |||
1ed5690b33 | |||
4451514ec5 | |||
8f73f85276 | |||
9a5d7b664b | |||
7d2d1d971a | |||
d111493eed | |||
e975f92a96 | |||
739cb4242d | |||
a37eebc9f1 | |||
e92730879e | |||
464973f9b0 | |||
f6228c099f | |||
a57fdfb2bb | |||
24716f0561 | |||
3453100afd | |||
84de2c0c68 | |||
1b7b082003 | |||
1928c2c2cc | |||
52e7a7886d | |||
36298b217e | |||
9bce57894e | |||
9d6bb325cd | |||
a5f833c612 | |||
732b14c6ab | |||
b74a042da8 | |||
d55c052f57 | |||
864f585efa | |||
6d56146054 | |||
2c9f29a3c6 | |||
9bef2e960c | |||
17b8c41673 | |||
f0afbd7346 | |||
5dc7429f6a | |||
7b39b32293 | |||
e5b5a9e7e9 | |||
1f3511368a | |||
b37df2c84f |
25
.env.dev
Normal file
25
.env.dev
Normal file
@ -0,0 +1,25 @@
|
||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||
|
||||
# CACHE
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||
|
||||
# POSTGRES
|
||||
POSTGRES_DB=ghostfolio-db
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
# VARIOUS
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
|
||||
# DEVELOPMENT
|
||||
|
||||
# Nx 18 enables using plugins to infer targets by default
|
||||
# This is disabled for existing workspaces to maintain compatibility
|
||||
# For more info, see: https://nx.dev/concepts/inferred-tasks
|
||||
NX_ADD_PLUGINS=false
|
||||
|
||||
NX_NATIVE_COMMAND_RUNNER=false
|
@ -1,7 +1,7 @@
|
||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||
COMPOSE_PROJECT_NAME=ghostfolio
|
||||
|
||||
# CACHE
|
||||
REDIS_HOST=localhost
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||
|
||||
@ -10,7 +10,7 @@ POSTGRES_DB=ghostfolio-db
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
# VARIOUS
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
ALPHA_VANTAGE_API_KEY=
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
|
117
.eslintrc.json
117
.eslintrc.json
@ -1,117 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": ["**/*"],
|
||||
"plugins": ["@nrwl/nx"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {
|
||||
"@nrwl/nx/enforce-module-boundaries": [
|
||||
"error",
|
||||
{
|
||||
"enforceBuildableLibDependency": true,
|
||||
"allow": [],
|
||||
"depConstraints": [
|
||||
{
|
||||
"sourceTag": "*",
|
||||
"onlyDependOnLibsWithTags": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"extends": ["plugin:@nrwl/nx/typescript"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"extends": ["plugin:@nrwl/nx/javascript"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"plugins": ["eslint-plugin-import", "@typescript-eslint"],
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-type-definitions": "error",
|
||||
"@typescript-eslint/dot-notation": "off",
|
||||
"@typescript-eslint/explicit-member-accessibility": [
|
||||
"off",
|
||||
{
|
||||
"accessibility": "explicit"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/member-ordering": "error",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-interface": "error",
|
||||
"@typescript-eslint/no-inferrable-types": [
|
||||
"error",
|
||||
{
|
||||
"ignoreParameters": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-misused-new": "error",
|
||||
"@typescript-eslint/no-non-null-assertion": "error",
|
||||
"@typescript-eslint/no-shadow": [
|
||||
"error",
|
||||
{
|
||||
"hoist": "all"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-expressions": "error",
|
||||
"@typescript-eslint/prefer-function-type": "error",
|
||||
"@typescript-eslint/unified-signatures": "error",
|
||||
"arrow-body-style": "off",
|
||||
"constructor-super": "error",
|
||||
"eqeqeq": ["error", "smart"],
|
||||
"guard-for-in": "error",
|
||||
"id-blacklist": "off",
|
||||
"id-match": "off",
|
||||
"import/no-deprecated": "warn",
|
||||
"no-bitwise": "error",
|
||||
"no-caller": "error",
|
||||
"no-console": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"log",
|
||||
"warn",
|
||||
"dir",
|
||||
"timeLog",
|
||||
"assert",
|
||||
"clear",
|
||||
"count",
|
||||
"countReset",
|
||||
"group",
|
||||
"groupEnd",
|
||||
"table",
|
||||
"dirxml",
|
||||
"error",
|
||||
"groupCollapsed",
|
||||
"Console",
|
||||
"profile",
|
||||
"profileEnd",
|
||||
"timeStamp",
|
||||
"context"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-debugger": "error",
|
||||
"no-empty": "off",
|
||||
"no-eval": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-restricted-imports": ["error", "rxjs/Rx"],
|
||||
"no-throw-literal": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-var": "error",
|
||||
"prefer-const": "error",
|
||||
"radix": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
14
.github/ISSUE_TEMPLATE/bug_report.md
vendored
14
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,13 @@ labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
**Important Notice**
|
||||
|
||||
The issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
|
||||
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
|
||||
|
||||
Thank you for your understanding and cooperation!
|
||||
|
||||
**Bug Description**
|
||||
|
||||
@ -20,7 +26,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected behavior**
|
||||
**Expected Behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
@ -37,9 +43,11 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Cloud or Self-hosted
|
||||
- Experimental Features enabled or disabled
|
||||
- Browser
|
||||
- OS
|
||||
|
||||
**Additional context**
|
||||
**Additional Context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
||||
|
36
.github/workflows/build-code.yml
vendored
36
.github/workflows/build-code.yml
vendored
@ -1,36 +0,0 @@
|
||||
name: Build code
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 16
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node_version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Check formatting
|
||||
run: yarn format:check
|
||||
|
||||
- name: Execute tests
|
||||
run: yarn test
|
||||
|
||||
- name: Build application
|
||||
run: yarn build:all
|
48
.github/workflows/docker-image.yml
vendored
48
.github/workflows/docker-image.yml
vendored
@ -4,24 +4,25 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_and_push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker metadata
|
||||
- name: Get Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ghostfolio/ghostfolio
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
run: |
|
||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
@ -34,16 +35,35 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
registry: gitea.suda.codes
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
# platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.output.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: |
|
||||
gitea.suda.codes/sudacode/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
gitea.suda.codes/sudacode/${{ steps.meta.outputs.REPO_NAME }}:latest
|
||||
cache-from: type=local,src=${{ runner.temp }}/.buildx-cache
|
||||
cache-to: type=local,dest=${{ runner.temp }}/.buildx-cache-new,mode=max
|
||||
|
||||
- # Temp fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
name: Move cache
|
||||
run: |
|
||||
rm -rf ${{ runner.temp }}/.buildx-cache
|
||||
mv ${{ runner.temp }}/.buildx-cache-new ${{ runner.temp }}/.buildx-cache
|
||||
|
||||
- name: Invoke deployment hook
|
||||
uses: distributhor/workflow-webhook@v3
|
||||
with:
|
||||
webhook_url: ${{ secrets.WEBHOOK_URL }}
|
||||
webhook_auth: ${{ secrets.WEBHOOK_AUTH }}
|
||||
webhook_secret: ${{ secrets.WEBHOOK_SECRET }}
|
||||
webhook_auth_type: bearer
|
||||
|
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,12 +1,14 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
scripts/*
|
||||
|
||||
# compiled output
|
||||
/out-tsc
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
/.yarn
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
@ -27,15 +29,15 @@
|
||||
/.angular/cache
|
||||
.env
|
||||
.env.prod
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/dist
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
testem.log
|
||||
/typings
|
||||
yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
|
6
.husky/pre-commit
Normal file
6
.husky/pre-commit
Normal file
@ -0,0 +1,6 @@
|
||||
# Run linting and stop the commit process if any errors are found
|
||||
# --quiet suppresses warnings (temporary until all warnings are fixed)
|
||||
npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1
|
||||
|
||||
# Check formatting on modified and uncommitted files, stop the commit if issues are found
|
||||
npm run format:check --uncommitted || exit 1
|
@ -1,2 +1,5 @@
|
||||
/.nx/cache
|
||||
/.nx/workspace-data
|
||||
/apps/client/src/polyfills.ts
|
||||
/dist
|
||||
/test/import
|
||||
|
20
.prettierrc
20
.prettierrc
@ -9,6 +9,26 @@
|
||||
],
|
||||
"attributeSort": "ASC",
|
||||
"endOfLine": "auto",
|
||||
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.ts",
|
||||
"options": {
|
||||
"importOrderParserPlugins": ["decorators-legacy", "typescript"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
"prettier-plugin-organize-attributes",
|
||||
"@trivago/prettier-plugin-sort-imports"
|
||||
],
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
|
@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
||||
// webpackFinal: async (config, { configType }) => {
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||
// // Return the altered config
|
||||
// return config;
|
||||
// },
|
||||
};
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"exclude": [
|
||||
"../**/*.spec.js",
|
||||
"../**/*.spec.ts",
|
||||
"../**/*.spec.tsx",
|
||||
"../**/*.spec.jsx"
|
||||
],
|
||||
"include": ["../**/*"]
|
||||
}
|
27
.vscode/launch.json
vendored
27
.vscode/launch.json
vendored
@ -2,32 +2,33 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Jest File",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||
"args": [
|
||||
"test",
|
||||
"--codeCoverage=false",
|
||||
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
|
||||
],
|
||||
"console": "internalConsole",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "internalConsole"
|
||||
"name": "Debug Jest",
|
||||
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||
"request": "launch",
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
||||
"autoAttachChildProcesses": true,
|
||||
"console": "integratedTerminal",
|
||||
"cwd": "${workspaceFolder}/apps/api",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"name": "Debug API",
|
||||
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
||||
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
||||
"request": "launch",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/node_modules/**/*.js",
|
||||
"<node_internals>/**/*.js"
|
||||
],
|
||||
"console": "integratedTerminal"
|
||||
"type": "node"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -1,4 +1,7 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
"editor.formatOnSave": true,
|
||||
"vim.highlightedyank.enable": true,
|
||||
"vim.hlsearch": true,
|
||||
"vim.leader": "<space>",
|
||||
}
|
||||
|
3090
CHANGELOG.md
3090
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,65 @@
|
||||
# Ghostfolio Development Guide
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `npm install`
|
||||
1. Run `docker compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `npm run database:setup` to initialize the database schema
|
||||
1. Start the [server](#start-server) and the [client](#start-client)
|
||||
1. Open https://localhost:4200/en in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
|
||||
### Start Server
|
||||
|
||||
#### Debug
|
||||
|
||||
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
Run `npm run start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
Run `npm run start:storybook`
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync:
|
||||
|
||||
```bash
|
||||
npm run database:push
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run `npm test`
|
||||
|
||||
## Experimental Features
|
||||
|
||||
New functionality can be enabled using a feature flag switch from the user settings.
|
||||
|
||||
### Backend
|
||||
|
||||
Remove permission in `UserService` using `without()`
|
||||
|
||||
### Frontend
|
||||
|
||||
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||
|
||||
## Git
|
||||
|
||||
### Rebase
|
||||
@ -8,18 +68,36 @@
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Angular
|
||||
|
||||
#### Upgrade (minor versions)
|
||||
|
||||
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
|
||||
|
||||
### Nx
|
||||
|
||||
#### Upgrade
|
||||
|
||||
1. Run `yarn nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||
1. Run `yarn nx migrate --run-migrations`
|
||||
1. Run `npx nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `npm install`
|
||||
1. Run `npx nx migrate --run-migrations`
|
||||
|
||||
### Prisma
|
||||
|
||||
#### Create schema migration (local)
|
||||
#### Access database via GUI
|
||||
|
||||
Run `yarn prisma migrate dev --name added_job_title`
|
||||
Run `npm run database:gui`
|
||||
|
||||
https://www.prisma.io/studio
|
||||
|
||||
#### Synchronize schema with database for prototyping
|
||||
|
||||
Run `npm run database:push`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||
|
||||
#### Create schema migration
|
||||
|
||||
Run `npm run prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||
|
62
Dockerfile
62
Dockerfile
@ -1,61 +1,67 @@
|
||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Only add basic files without the application itself to avoid rebuilding
|
||||
# layers when files (package.json etc.) have not changed
|
||||
COPY ./CHANGELOG.md CHANGELOG.md
|
||||
COPY ./LICENSE LICENSE
|
||||
COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./.yarnrc .yarnrc
|
||||
COPY ./package-lock.json package-lock.json
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
git \
|
||||
g++ \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN yarn install
|
||||
RUN npm install
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
RUN node decorate-angular-cli.js
|
||||
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.js replace.build.js
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
COPY ./jest.config.ts jest.config.ts
|
||||
COPY ./tsconfig.base.json tsconfig.base.json
|
||||
COPY ./libs libs
|
||||
COPY ./apps apps
|
||||
COPY ./libs libs
|
||||
COPY ./jest.config.ts jest.config.ts
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.mjs replace.build.mjs
|
||||
COPY ./tsconfig.base.json tsconfig.base.json
|
||||
|
||||
RUN yarn build:all
|
||||
RUN npm run build:production
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
# package.json was generated by the build process, however the original
|
||||
# yarn.lock needs to be used to ensure the same versions
|
||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||
|
||||
RUN yarn
|
||||
RUN npm install
|
||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||
|
||||
# Overwrite the generated package.json with the original one to ensure having
|
||||
# all the scripts
|
||||
COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
RUN npm run database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:16-slim
|
||||
RUN apt update && apt install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
FROM node:20-slim
|
||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE ${PORT:-3333}
|
||||
CMD [ "yarn", "start:prod" ]
|
||||
USER node
|
||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||
|
212
README.md
212
README.md
@ -7,11 +7,11 @@
|
||||
**Open Source Wealth Management Software**
|
||||
|
||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
|
||||
|
||||
[](https://www.buymeacoffee.com/ghostfolio)
|
||||
[](#contributing)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](#contributing) [](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||
</div>
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
|
||||
## Ghostfolio Premium
|
||||
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||
|
||||
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
|
||||
## Self-hosting
|
||||
|
||||
@ -85,19 +85,24 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| Name | Type | Default Value | Description |
|
||||
| ------------------------ | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | `string` (optional) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
|
||||
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
|
||||
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -113,7 +118,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
#### b. Build and run environment
|
||||
@ -121,8 +126,8 @@ docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||
Run the following commands to build and start the Docker images:
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
docker compose -f docker/docker-compose.build.yml build
|
||||
docker compose -f docker/docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
#### Setup
|
||||
@ -132,63 +137,27 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
|
||||
#### Upgrade Version
|
||||
|
||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||
At each start, the container will automatically apply the database schema migrations if needed.
|
||||
1. Update the _Ghostfolio_ Docker image
|
||||
|
||||
### Run with _Unraid_ (Community)
|
||||
- Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||
- Run the following command if `ghostfolio:latest` is set:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
```
|
||||
|
||||
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
||||
1. Run the following command to start the new Docker image:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
The container will automatically apply any required database schema migrations during startup.
|
||||
|
||||
### Home Server Systems (Community)
|
||||
|
||||
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `yarn build:dev` to build the source code including the assets
|
||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Open http://localhost:4200/en in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
|
||||
### Start Server
|
||||
|
||||
#### Debug
|
||||
|
||||
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
Run `yarn start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
Run `yarn start:storybook`
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync:
|
||||
|
||||
```bash
|
||||
yarn database:push
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run `yarn test`
|
||||
For detailed information on the environment setup and development process, please refer to [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||
|
||||
## Public API
|
||||
|
||||
@ -200,10 +169,36 @@ Set the header for each request as follows:
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
|
||||
|
||||
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||
|
||||
### Health Check (experimental)
|
||||
|
||||
#### Request
|
||||
|
||||
`GET http://localhost:3333/api/v1/health`
|
||||
|
||||
**Info:** No Bearer Token is required for health check
|
||||
|
||||
#### Response
|
||||
|
||||
##### Success
|
||||
|
||||
`200 OK`
|
||||
|
||||
```
|
||||
{
|
||||
"status": "OK"
|
||||
}
|
||||
```
|
||||
|
||||
### Import Activities
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
[Bearer Token](#authorization-bearer-token) for authorization
|
||||
|
||||
#### Request
|
||||
|
||||
`POST http://localhost:3333/api/v1/import`
|
||||
@ -227,17 +222,18 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------------------- | -------------------------------------------------- |
|
||||
| accountId | string (`optional`) | Id of the account |
|
||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| date | string | Date in the format `ISO-8601` |
|
||||
| fee | number | Fee of the activity |
|
||||
| quantity | number | Quantity of the activity |
|
||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
|
||||
| unitPrice | number | Price per unit of the activity |
|
||||
| Field | Type | Description |
|
||||
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
|
||||
| `accountId` | `string` (optional) | Id of the account |
|
||||
| `comment` | `string` (optional) | Comment of the activity |
|
||||
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| `date` | `string` | Date in the format `ISO-8601` |
|
||||
| `fee` | `number` | Fee of the activity |
|
||||
| `quantity` | `number` | Quantity of the activity |
|
||||
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
|
||||
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||
| `unitPrice` | `number` | Price per unit of the activity |
|
||||
|
||||
#### Response
|
||||
|
||||
@ -258,20 +254,58 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
||||
}
|
||||
```
|
||||
|
||||
### Portfolio (experimental)
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_.
|
||||
|
||||
#### Request
|
||||
|
||||
`GET http://localhost:3333/api/v1/public/<INSERT_ACCESS_ID>/portfolio`
|
||||
|
||||
**Info:** No Bearer Token is required for authorization
|
||||
|
||||
#### Response
|
||||
|
||||
##### Success
|
||||
|
||||
```
|
||||
{
|
||||
"performance": {
|
||||
"1d": {
|
||||
"relativeChange": 0 // normalized from -1 to 1
|
||||
};
|
||||
"ytd": {
|
||||
"relativeChange": 0 // normalized from -1 to 1
|
||||
},
|
||||
"max": {
|
||||
"relativeChange": 0 // normalized from -1 to 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Community Projects
|
||||
|
||||
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
||||
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||
|
||||
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
|
||||
|
||||
## Contributing
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
||||
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
## Analytics
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
© 2023 [Ghostfolio](https://ghostfol.io)
|
||||
© 2021 - 2025 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
13
SECURITY.md
Normal file
13
SECURITY.md
Normal file
@ -0,0 +1,13 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps.
|
||||
|
||||
To help us resolve the issue, please include the following details:
|
||||
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce the vulnerability
|
||||
- Affected versions of the software
|
||||
|
||||
We appreciate your responsible disclosure and will work to address the issue promptly.
|
@ -1,22 +0,0 @@
|
||||
{
|
||||
"extends": "../../.eslintrc.json",
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"rules": {},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"parserOptions": {
|
||||
"project": ["apps/api/tsconfig.*?.json"]
|
||||
},
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
31
apps/api/eslint.config.cjs
Normal file
31
apps/api/eslint.config.cjs
Normal file
@ -0,0 +1,31 @@
|
||||
const baseConfig = require('../../eslint.config.cjs');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: ['**/dist']
|
||||
},
|
||||
...baseConfig,
|
||||
{
|
||||
rules: {}
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['apps/api/tsconfig.*?.json']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
// Override or add rules here
|
||||
rules: {}
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.jsx'],
|
||||
// Override or add rules here
|
||||
rules: {}
|
||||
}
|
||||
];
|
@ -2,17 +2,17 @@
|
||||
export default {
|
||||
displayName: 'api',
|
||||
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
}
|
||||
},
|
||||
globals: {},
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': 'ts-jest'
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
}
|
||||
]
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
testTimeout: 10000,
|
||||
testEnvironment: 'node',
|
||||
preset: '../../jest.preset.js'
|
||||
};
|
||||
|
@ -7,14 +7,16 @@
|
||||
"generators": {},
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/webpack:webpack",
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"compiler": "tsc",
|
||||
"deleteOutputPath": false,
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"],
|
||||
"outputPath": "dist/apps/api",
|
||||
"sourceMap": true,
|
||||
"target": "node",
|
||||
"compiler": "tsc"
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"webpackConfig": "apps/api/webpack.config.js"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@ -32,23 +34,42 @@
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"copy-assets": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "shx rm -rf dist/apps/api"
|
||||
},
|
||||
{
|
||||
"command": "shx mkdir -p dist/apps/api/assets/locales"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales"
|
||||
}
|
||||
],
|
||||
"parallel": false
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nrwl/node:node",
|
||||
"executor": "@nx/js:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"executor": "@nx/eslint:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"executor": "@nx/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
"jestConfig": "apps/api/jest.config.ts"
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||
}
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -17,7 +21,6 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { Access as AccessModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccessModule } from './access.module';
|
||||
import { AccessService } from './access.service';
|
||||
import { CreateAccessDto } from './create-access.dto';
|
||||
|
||||
@ -25,11 +28,12 @@ import { CreateAccessDto } from './create-access.dto';
|
||||
export class AccessController {
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getAllAccesses(): Promise<Access[]> {
|
||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||
include: {
|
||||
@ -39,32 +43,38 @@ export class AccessController {
|
||||
where: { userId: this.request.user.id }
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map((access) => {
|
||||
if (access.GranteeUser) {
|
||||
return accessesWithGranteeUser.map(
|
||||
({ alias, GranteeUser, id, permissions }) => {
|
||||
if (GranteeUser) {
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: GranteeUser?.id,
|
||||
type: 'PRIVATE'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
alias: access.alias,
|
||||
grantee: access.GranteeUser?.id,
|
||||
id: access.id,
|
||||
type: 'RESTRICTED_VIEW'
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: 'Public',
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
alias: access.alias,
|
||||
grantee: 'Public',
|
||||
id: access.id,
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createAccess)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createAccess(
|
||||
@Body() data: CreateAccessDto
|
||||
): Promise<AccessModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -72,25 +82,30 @@ export class AccessController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
try {
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
permissions: data.permissions,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||
@HasPermission(permissions.deleteAccess)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
||||
const access = await this.accessService.access({ id });
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
||||
!access ||
|
||||
access.userId !== this.request.user.id
|
||||
) {
|
||||
if (!access || access.userId !== this.request.user.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessController } from './access.controller';
|
||||
@ -7,7 +9,7 @@ import { AccessService } from './access.service';
|
||||
@Module({
|
||||
controllers: [AccessController],
|
||||
exports: [AccessService],
|
||||
imports: [PrismaModule],
|
||||
imports: [ConfigurationModule, PrismaModule],
|
||||
providers: [AccessService]
|
||||
})
|
||||
export class AccessModule {}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Access, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { AccessPermission } from '@prisma/client';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccessDto {
|
||||
@IsOptional()
|
||||
@ -6,6 +7,10 @@ export class CreateAccessDto {
|
||||
alias?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
granteeUserId?: string;
|
||||
|
||||
@IsEnum(AccessPermission, { each: true })
|
||||
@IsOptional()
|
||||
permissions?: AccessPermission[];
|
||||
}
|
||||
|
@ -0,0 +1,84 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Body,
|
||||
Post,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AccountBalance } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||
|
||||
@Controller('account-balance')
|
||||
export class AccountBalanceController {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly accountService: AccountService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.createAccountBalance)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createAccountBalance(
|
||||
@Body() data: CreateAccountBalanceDto
|
||||
): Promise<AccountBalance> {
|
||||
const account = await this.accountService.account({
|
||||
id_userId: {
|
||||
id: data.accountId,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: account.id,
|
||||
balance: data.balance,
|
||||
date: data.date,
|
||||
userId: account.userId
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.deleteAccountBalance)
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccountBalance(
|
||||
@Param('id') id: string
|
||||
): Promise<AccountBalance> {
|
||||
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (!accountBalance) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountBalanceService.deleteAccountBalance({
|
||||
id: accountBalance.id,
|
||||
userId: accountBalance.userId
|
||||
});
|
||||
}
|
||||
}
|
16
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
16
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccountBalanceController } from './account-balance.controller';
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountBalanceController],
|
||||
exports: [AccountBalanceService],
|
||||
imports: [ExchangeRateDataModule, PrismaModule],
|
||||
providers: [AccountBalanceService, AccountService]
|
||||
})
|
||||
export class AccountBalanceModule {}
|
187
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
187
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AccountBalancesResponse,
|
||||
Filter,
|
||||
HistoricalDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { AccountBalance, Prisma } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccountBalanceService {
|
||||
public constructor(
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async accountBalance(
|
||||
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput
|
||||
): Promise<AccountBalance | null> {
|
||||
return this.prismaService.accountBalance.findFirst({
|
||||
include: {
|
||||
Account: true
|
||||
},
|
||||
where: accountBalanceWhereInput
|
||||
});
|
||||
}
|
||||
|
||||
public async createOrUpdateAccountBalance({
|
||||
accountId,
|
||||
balance,
|
||||
date,
|
||||
userId
|
||||
}: CreateAccountBalanceDto & {
|
||||
userId: string;
|
||||
}): Promise<AccountBalance> {
|
||||
const accountBalance = await this.prismaService.accountBalance.upsert({
|
||||
create: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
}
|
||||
},
|
||||
date: resetHours(parseISO(date)),
|
||||
value: balance
|
||||
},
|
||||
update: {
|
||||
value: balance
|
||||
},
|
||||
where: {
|
||||
accountId_date: {
|
||||
accountId,
|
||||
date: resetHours(parseISO(date))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId
|
||||
})
|
||||
);
|
||||
|
||||
return accountBalance;
|
||||
}
|
||||
|
||||
public async deleteAccountBalance(
|
||||
where: Prisma.AccountBalanceWhereUniqueInput
|
||||
): Promise<AccountBalance> {
|
||||
const accountBalance = await this.prismaService.accountBalance.delete({
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: where.userId as string
|
||||
})
|
||||
);
|
||||
|
||||
return accountBalance;
|
||||
}
|
||||
|
||||
public async getAccountBalanceItems({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<HistoricalDataItem[]> {
|
||||
const { balances } = await this.getAccountBalances({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: false // TODO
|
||||
});
|
||||
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
|
||||
{};
|
||||
const lastBalancesByAccount: { [accountId: string]: Big } = {};
|
||||
|
||||
for (const { accountId, date, valueInBaseCurrency } of balances) {
|
||||
const formattedDate = format(date, DATE_FORMAT);
|
||||
|
||||
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);
|
||||
|
||||
const totalBalance = getSum(Object.values(lastBalancesByAccount));
|
||||
|
||||
// Add or update the accumulated balance for this date
|
||||
accumulatedBalancesByDate[formattedDate] = {
|
||||
date: formattedDate,
|
||||
value: totalBalance.toNumber()
|
||||
};
|
||||
}
|
||||
|
||||
return Object.values(accumulatedBalancesByDate);
|
||||
}
|
||||
|
||||
@LogPerformance
|
||||
public async getAccountBalances({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<AccountBalancesResponse> {
|
||||
const where: Prisma.AccountBalanceWhereInput = { userId };
|
||||
|
||||
const accountFilter = filters?.find(({ type }) => {
|
||||
return type === 'ACCOUNT';
|
||||
});
|
||||
|
||||
if (accountFilter) {
|
||||
where.accountId = accountFilter.id;
|
||||
}
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.Account = { isExcluded: false };
|
||||
}
|
||||
|
||||
const balances = await this.prismaService.accountBalance.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
select: {
|
||||
Account: true,
|
||||
date: true,
|
||||
id: true,
|
||||
value: true
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
balances: balances.map((balance) => {
|
||||
return {
|
||||
...balance,
|
||||
accountId: balance.Account.id,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
balance.value,
|
||||
balance.Account.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccountBalanceDto {
|
||||
@IsUUID()
|
||||
accountId: string;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
}
|
@ -1,13 +1,22 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import {
|
||||
AccountBalancesResponse,
|
||||
Accounts
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
AccountWithValue,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -19,6 +28,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
@ -29,29 +39,24 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountService } from './account.service';
|
||||
import { CreateAccountDto } from './create-account.dto';
|
||||
import { TransferBalanceDto } from './transfer-balance.dto';
|
||||
import { UpdateAccountDto } from './update-account.dto';
|
||||
|
||||
@Controller('account')
|
||||
export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteAccount)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const account = await this.accountService.accountWithOrders(
|
||||
{
|
||||
id_userId: {
|
||||
@ -62,54 +67,54 @@ export class AccountController {
|
||||
{ Order: true }
|
||||
);
|
||||
|
||||
if (account?.isDefault || account?.Order.length > 0) {
|
||||
if (!account || account?.Order.length > 0) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountService.deleteAccount(
|
||||
{
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
return this.accountService.deleteAccount({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string
|
||||
): Promise<Accounts> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByDataSource,
|
||||
filterBySymbol
|
||||
});
|
||||
|
||||
return this.portfolioService.getAccountsWithAggregations({
|
||||
filters,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Param('id') id: string
|
||||
): Promise<AccountWithValue> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
const accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
@ -121,20 +126,25 @@ export class AccountController {
|
||||
return accountsWithAggregations.accounts[0];
|
||||
}
|
||||
|
||||
@Get(':id/balances')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountBalancesById(
|
||||
@Param('id') id: string
|
||||
): Promise<AccountBalancesResponse> {
|
||||
return this.accountBalanceService.getAccountBalances({
|
||||
filters: [{ id, type: 'ACCOUNT' }],
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createAccount)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createAccount(
|
||||
@Body() data: CreateAccountDto
|
||||
): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
@ -160,18 +170,64 @@ export class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||
) {
|
||||
@HasPermission(permissions.updateAccount)
|
||||
@Post('transfer-balance')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async transferAccountBalance(
|
||||
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
||||
) {
|
||||
const accountsOfUser = await this.accountService.getAccounts(
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
const accountFrom = accountsOfUser.find(({ id }) => {
|
||||
return id === accountIdFrom;
|
||||
});
|
||||
|
||||
const accountTo = accountsOfUser.find(({ id }) => {
|
||||
return id === accountIdTo;
|
||||
});
|
||||
|
||||
if (!accountFrom || !accountTo) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
if (accountFrom.id === accountTo.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
if (accountFrom.balance < balance) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
await this.accountService.updateAccountBalance({
|
||||
accountId: accountFrom.id,
|
||||
amount: -balance,
|
||||
currency: accountFrom.currency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
await this.accountService.updateAccountBalance({
|
||||
accountId: accountTo.id,
|
||||
amount: balance,
|
||||
currency: accountFrom.currency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateAccount)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||
const originalAccount = await this.accountService.account({
|
||||
id_userId: {
|
||||
id,
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccountController } from './account.controller';
|
||||
@ -15,14 +16,14 @@ import { AccountService } from './account.service';
|
||||
controllers: [AccountController],
|
||||
exports: [AccountService],
|
||||
imports: [
|
||||
AccountBalanceModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
UserModule
|
||||
RedactValuesInResponseModule
|
||||
],
|
||||
providers: [AccountService]
|
||||
})
|
||||
|
@ -1,9 +1,15 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { Big } from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
@ -11,16 +17,20 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async account(
|
||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
||||
): Promise<Account | null> {
|
||||
return this.prismaService.account.findUnique({
|
||||
where: accountWhereUniqueInput
|
||||
public async account({
|
||||
id_userId
|
||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||
const [account] = await this.accounts({
|
||||
where: id_userId
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async accountWithOrders(
|
||||
@ -50,9 +60,11 @@ export class AccountService {
|
||||
Platform?: Platform;
|
||||
})[]
|
||||
> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.account.findMany({
|
||||
include.balances = { orderBy: { date: 'desc' }, take: 1 };
|
||||
|
||||
const accounts = await this.prismaService.account.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
@ -60,27 +72,59 @@ export class AccountService {
|
||||
take,
|
||||
where
|
||||
});
|
||||
|
||||
return accounts.map((account) => {
|
||||
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
||||
|
||||
delete account.balances;
|
||||
|
||||
return account;
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccount(
|
||||
data: Prisma.AccountCreateInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
return this.prismaService.account.create({
|
||||
const account = await this.prismaService.account.create({
|
||||
data
|
||||
});
|
||||
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: account.id,
|
||||
balance: data.balance,
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
userId: aUserId
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: account.userId
|
||||
})
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async deleteAccount(
|
||||
where: Prisma.AccountWhereUniqueInput,
|
||||
aUserId: string
|
||||
where: Prisma.AccountWhereUniqueInput
|
||||
): Promise<Account> {
|
||||
return this.prismaService.account.delete({
|
||||
const account = await this.prismaService.account.delete({
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: account.userId
|
||||
})
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async getAccounts(aUserId: string) {
|
||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||
const accounts = await this.accounts({
|
||||
include: { Order: true, Platform: true },
|
||||
orderBy: { name: 'asc' },
|
||||
@ -125,12 +169,8 @@ export class AccountService {
|
||||
where.isExcluded = false;
|
||||
}
|
||||
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
|
||||
return type;
|
||||
});
|
||||
|
||||
if (filtersByAccount?.length > 0) {
|
||||
@ -167,9 +207,64 @@ export class AccountService {
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
const { data, where } = params;
|
||||
return this.prismaService.account.update({
|
||||
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: data.id as string,
|
||||
balance: data.balance as number,
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
userId: aUserId
|
||||
});
|
||||
|
||||
const account = await this.prismaService.account.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: account.userId
|
||||
})
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async updateAccountBalance({
|
||||
accountId,
|
||||
amount,
|
||||
currency,
|
||||
date = new Date(),
|
||||
userId
|
||||
}: {
|
||||
accountId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
date?: Date;
|
||||
userId: string;
|
||||
}) {
|
||||
const { balance, currency: currencyOfAccount } = await this.account({
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
});
|
||||
|
||||
const amountInCurrencyOfAccount =
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
amount,
|
||||
currency,
|
||||
currencyOfAccount,
|
||||
date
|
||||
);
|
||||
|
||||
if (amountInCurrencyOfAccount) {
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId,
|
||||
userId,
|
||||
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
|
||||
date: date.toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,15 +8,20 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
@ -29,6 +36,6 @@ export class CreateAccountDto {
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
@ValidateIf((_object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
}
|
||||
|
13
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
13
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { IsNumber, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class TransferBalanceDto {
|
||||
@IsString()
|
||||
accountIdFrom: string;
|
||||
|
||||
@IsString()
|
||||
accountIdTo: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
balance: number;
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,15 +8,20 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
@ -28,6 +35,6 @@ export class UpdateAccountDto {
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
@ValidateIf((_object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
}
|
||||
|
@ -1,19 +1,31 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
Filter
|
||||
AdminUsers,
|
||||
EnhancedSymbolProfile
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
MarketDataPreset,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -21,199 +33,145 @@ import {
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { isDate } from 'date-fns';
|
||||
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { isDate, parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
public constructor(
|
||||
private readonly adminService: AdminService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly manualService: ManualService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getAdminData(): Promise<AdminData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.get();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gather7Days(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gather7Days();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/max')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherMax(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this.dataGatheringService.gatherMax();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileDataForSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
await this.dataGatheringService.addJobToQueue({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
public async gatherSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherSymbolForDate(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<MarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
const date = parseISO(dateString);
|
||||
|
||||
if (!isDate(date)) {
|
||||
throw new HttpException(
|
||||
@ -230,83 +188,116 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getMarketData(
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||
@Query('presetId') presetId?: MarketDataPreset,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('take') take?: number
|
||||
): Promise<AdminMarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAssetSubClasses,
|
||||
filterBySearchQuery
|
||||
});
|
||||
|
||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||
|
||||
const filters: Filter[] = [
|
||||
...assetSubClasses.map((assetSubClass) => {
|
||||
return <Filter>{
|
||||
id: assetSubClass,
|
||||
type: 'ASSET_SUB_CLASS'
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
return this.adminService.getMarketData(filters);
|
||||
return this.adminService.getMarketData({
|
||||
filters,
|
||||
presetId,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
skip: isNaN(skip) ? undefined : skip,
|
||||
take: isNaN(take) ? undefined : take
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('market-data/:dataSource/:symbol/test')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async testMarketData(
|
||||
@Body() data: { scraperConfiguration: string },
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<{ price: number }> {
|
||||
try {
|
||||
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
|
||||
const price = await this.manualService.test(scraperConfiguration);
|
||||
|
||||
if (price) {
|
||||
return { price };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not parse the current market price for ${symbol} (${dataSource})`
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'AdminController');
|
||||
|
||||
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateMarketData(
|
||||
@Body() data: UpdateBulkMarketDataDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||
({ date, marketPrice }) => ({
|
||||
dataSource,
|
||||
marketPrice,
|
||||
symbol,
|
||||
date: parseISO(date),
|
||||
state: 'CLOSE'
|
||||
})
|
||||
);
|
||||
|
||||
return this.marketDataService.updateMany({
|
||||
data: dataBulkUpdate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async update(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string,
|
||||
@Body() data: UpdateMarketDataDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
data: { ...data, dataSource },
|
||||
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||
where: {
|
||||
date_symbol: {
|
||||
dataSource_date_symbol: {
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}
|
||||
@ -314,46 +305,39 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async addProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<SymbolProfile | never> {
|
||||
return this.adminService.addAssetProfile({
|
||||
dataSource,
|
||||
symbol,
|
||||
currency: this.request.user.Settings.settings.baseCurrency
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Patch('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async patchAssetProfileData(
|
||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<EnhancedSymbolProfile> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.patchAssetProfileData({
|
||||
...assetProfileData,
|
||||
dataSource,
|
||||
@ -361,24 +345,26 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateProperty(
|
||||
@Param('key') key: string,
|
||||
@Body() data: PropertyDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
return this.adminService.putSetting(key, data.value);
|
||||
}
|
||||
|
||||
return await this.adminService.putSetting(key, data.value);
|
||||
@Get('user')
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getUsers(
|
||||
@Query('skip') skip?: number,
|
||||
@Query('take') take?: number
|
||||
): Promise<AdminUsers> {
|
||||
return this.adminService.getUsers({
|
||||
skip: isNaN(skip) ? undefined : skip,
|
||||
take: isNaN(take) ? undefined : take
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,17 @@
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
@ -15,16 +20,20 @@ import { QueueModule } from './queue/queue.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApiModule,
|
||||
BenchmarkModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
QueueModule,
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
|
@ -1,172 +1,445 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
getAssetProfileIdentifier,
|
||||
getCurrencyFromSymbol,
|
||||
isCurrency
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
AdminUsers,
|
||||
AssetProfileIdentifier,
|
||||
EnhancedSymbolProfile,
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
Property,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
) {}
|
||||
|
||||
public async addAssetProfile({
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
}: AssetProfileIdentifier & { currency?: string }): Promise<
|
||||
SymbolProfile | never
|
||||
> {
|
||||
try {
|
||||
if (dataSource === 'MANUAL') {
|
||||
return this.symbolProfileService.add({
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
|
||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfiles[symbol]?.currency) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile not found for ${symbol} (${dataSource})`
|
||||
);
|
||||
}
|
||||
|
||||
return this.symbolProfileService.add(
|
||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
public async deleteProfileData({
|
||||
dataSource,
|
||||
symbol
|
||||
}: AssetProfileIdentifier) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
|
||||
const currency = getCurrencyFromSymbol(symbol);
|
||||
const customCurrencies = (await this.propertyService.getByKey(
|
||||
PROPERTY_CURRENCIES
|
||||
)) as string[];
|
||||
|
||||
if (customCurrencies.includes(currency)) {
|
||||
const updatedCustomCurrencies = customCurrencies.filter(
|
||||
(customCurrency) => {
|
||||
return customCurrency !== currency;
|
||||
}
|
||||
);
|
||||
|
||||
await this.putSetting(
|
||||
PROPERTY_CURRENCIES,
|
||||
JSON.stringify(updatedCustomCurrencies)
|
||||
);
|
||||
} else {
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
}
|
||||
}
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
const exchangeRates = this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
return currency !== DEFAULT_CURRENCY;
|
||||
})
|
||||
.map((currency) => {
|
||||
const label1 = DEFAULT_CURRENCY;
|
||||
const label2 = currency;
|
||||
|
||||
return {
|
||||
label1,
|
||||
label2,
|
||||
dataSource:
|
||||
DataSource[
|
||||
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
||||
],
|
||||
symbol: `${label1}${label2}`,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
DEFAULT_CURRENCY,
|
||||
currency
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
const [settings, transactionCount, userCount] = await Promise.all([
|
||||
this.propertyService.get(),
|
||||
this.prismaService.order.count(),
|
||||
this.countUsersWithAnalytics()
|
||||
]);
|
||||
|
||||
return {
|
||||
exchangeRates: this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
return currency !== this.baseCurrency;
|
||||
})
|
||||
.map((currency) => {
|
||||
return {
|
||||
label1: this.baseCurrency,
|
||||
label2: currency,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
this.baseCurrency,
|
||||
currency
|
||||
)
|
||||
};
|
||||
}),
|
||||
settings: await this.propertyService.get(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
users: await this.getUsersWithAnalytics()
|
||||
exchangeRates,
|
||||
settings,
|
||||
transactionCount,
|
||||
userCount,
|
||||
version: environment.version
|
||||
};
|
||||
}
|
||||
|
||||
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
||||
public async getMarketData({
|
||||
filters,
|
||||
presetId,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
skip,
|
||||
take = Number.MAX_SAFE_INTEGER
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
presetId?: MarketDataPreset;
|
||||
skip?: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: Prisma.SortOrder;
|
||||
take?: number;
|
||||
}): Promise<AdminMarketData> {
|
||||
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
|
||||
[{ symbol: 'asc' }];
|
||||
const where: Prisma.SymbolProfileWhereInput = {};
|
||||
|
||||
if (presetId === 'BENCHMARKS') {
|
||||
const benchmarkAssetProfiles =
|
||||
await this.benchmarkService.getBenchmarkAssetProfiles();
|
||||
|
||||
where.id = {
|
||||
in: benchmarkAssetProfiles.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
};
|
||||
} else if (presetId === 'CURRENCIES') {
|
||||
return this.getMarketDataForCurrencies();
|
||||
} else if (
|
||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||
presetId === 'ETF_WITHOUT_SECTORS'
|
||||
) {
|
||||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||
}
|
||||
|
||||
const searchQuery = filters.find(({ type }) => {
|
||||
return type === 'SEARCH_QUERY';
|
||||
})?.id;
|
||||
|
||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||
filters,
|
||||
(filter) => {
|
||||
return filter.type;
|
||||
({ type }) => {
|
||||
return type;
|
||||
}
|
||||
);
|
||||
|
||||
const marketData = await this.prismaService.marketData.groupBy({
|
||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
|
||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||
|
||||
if (filtersByAssetSubClass) {
|
||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||
} else {
|
||||
currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
countriesCount: 0,
|
||||
sectorsCount: 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
where,
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
dataSource: true,
|
||||
if (searchQuery) {
|
||||
where.OR = [
|
||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||
];
|
||||
}
|
||||
|
||||
if (sortColumn) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
|
||||
if (sortColumn === 'activitiesCount') {
|
||||
orderBy = {
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
_count: sortDirection
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const extendedPrismaClient = this.getExtendedPrismaClient();
|
||||
|
||||
try {
|
||||
const symbolProfileResult = await Promise.all([
|
||||
extendedPrismaClient.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
isUsedByUsersWithSubscription: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
symbol: true,
|
||||
SymbolProfileOverrides: true
|
||||
}
|
||||
}),
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
const assetProfiles = symbolProfileResult[0];
|
||||
let count = symbolProfileResult[1];
|
||||
|
||||
const lastMarketPrices = await this.prismaService.marketData.findMany({
|
||||
distinct: ['dataSource', 'symbol'],
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
dataSource: true,
|
||||
marketPrice: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
dataSource: {
|
||||
in: assetProfiles.map(({ dataSource }) => {
|
||||
return dataSource;
|
||||
})
|
||||
},
|
||||
symbol: {
|
||||
in: assetProfiles.map(({ symbol }) => {
|
||||
return symbol;
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
const countriesCount = symbolProfile.countries
|
||||
? Object.keys(symbolProfile.countries).length
|
||||
: 0;
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||
marketDataItem.symbol === symbolProfile.symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = symbolProfile.sectors
|
||||
? Object.keys(symbolProfile.sectors).length
|
||||
: 0;
|
||||
});
|
||||
|
||||
const lastMarketPriceMap = new Map<string, number>();
|
||||
|
||||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
|
||||
lastMarketPriceMap.set(
|
||||
getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
marketPrice
|
||||
);
|
||||
}
|
||||
|
||||
let marketData: AdminMarketDataItem[] = await Promise.all(
|
||||
assetProfiles.map(
|
||||
async ({
|
||||
_count,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
isUsedByUsersWithSubscription,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
symbol,
|
||||
SymbolProfileOverrides
|
||||
}) => {
|
||||
let countriesCount = countries ? Object.keys(countries).length : 0;
|
||||
|
||||
const lastMarketPrice = lastMarketPriceMap.get(
|
||||
getAssetProfileIdentifier({ dataSource, symbol })
|
||||
);
|
||||
|
||||
const marketDataItemCount =
|
||||
marketDataItems.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
let sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||
|
||||
if (SymbolProfileOverrides) {
|
||||
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
|
||||
assetSubClass =
|
||||
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
|
||||
|
||||
if (
|
||||
(
|
||||
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
|
||||
)?.length > 0
|
||||
) {
|
||||
countriesCount = (
|
||||
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
|
||||
).length;
|
||||
}
|
||||
|
||||
name = SymbolProfileOverrides.name ?? name;
|
||||
|
||||
if (
|
||||
(SymbolProfileOverrides.sectors as unknown as Sector[])
|
||||
?.length > 0
|
||||
) {
|
||||
sectorsCount = (
|
||||
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
|
||||
).length;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
currency,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
id,
|
||||
lastMarketPrice,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: _count.Order,
|
||||
date: Order?.[0]?.date,
|
||||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (presetId) {
|
||||
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
||||
marketData = marketData.filter(({ countriesCount }) => {
|
||||
return countriesCount === 0;
|
||||
});
|
||||
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
||||
marketData = marketData.filter(({ sectorsCount }) => {
|
||||
return sectorsCount === 0;
|
||||
});
|
||||
}
|
||||
|
||||
count = marketData.length;
|
||||
}
|
||||
|
||||
return {
|
||||
countriesCount,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: symbolProfile._count.Order,
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
comment: symbolProfile.comment,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
count,
|
||||
marketData
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
await extendedPrismaClient.$disconnect();
|
||||
|
||||
return {
|
||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||
};
|
||||
Logger.debug('Disconnect extended prisma client', 'AdminService');
|
||||
}
|
||||
}
|
||||
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
|
||||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||
|
||||
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||
currency = getCurrencyFromSymbol(symbol);
|
||||
({ activitiesCount, dateOfFirstActivity } =
|
||||
await this.orderService.getStatisticsByCurrency(currency));
|
||||
}
|
||||
|
||||
const [[assetProfile], marketData] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
@ -185,24 +458,85 @@ export class AdminService {
|
||||
})
|
||||
]);
|
||||
|
||||
if (assetProfile) {
|
||||
assetProfile.dataProviderInfo = this.dataProviderService
|
||||
.getDataProvider(assetProfile.dataSource)
|
||||
.getDataProviderInfo();
|
||||
}
|
||||
|
||||
return {
|
||||
assetProfile,
|
||||
marketData
|
||||
marketData,
|
||||
assetProfile: assetProfile ?? {
|
||||
activitiesCount,
|
||||
currency,
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async getUsers({
|
||||
skip,
|
||||
take = Number.MAX_SAFE_INTEGER
|
||||
}: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
}): Promise<AdminUsers> {
|
||||
const [count, users] = await Promise.all([
|
||||
this.countUsersWithAnalytics(),
|
||||
this.getUsersWithAnalytics({ skip, take })
|
||||
]);
|
||||
|
||||
return { count, users };
|
||||
}
|
||||
|
||||
public async patchAssetProfileData({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
symbolMapping,
|
||||
url
|
||||
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
|
||||
const symbolProfileOverrides = {
|
||||
assetClass: assetClass as AssetClass,
|
||||
assetSubClass: assetSubClass as AssetSubClass,
|
||||
name: name as string,
|
||||
url: url as string
|
||||
};
|
||||
|
||||
const updatedSymbolProfile: AssetProfileIdentifier &
|
||||
Prisma.SymbolProfileUpdateInput = {
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping
|
||||
});
|
||||
symbolMapping,
|
||||
...(dataSource === 'MANUAL'
|
||||
? { assetClass, assetSubClass, name, url }
|
||||
: {
|
||||
SymbolProfileOverrides: {
|
||||
upsert: {
|
||||
create: symbolProfileOverrides,
|
||||
update: symbolProfileOverrides
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
@ -223,23 +557,179 @@ export class AdminService {
|
||||
response = await this.propertyService.delete({ key });
|
||||
}
|
||||
|
||||
if (key === PROPERTY_CURRENCIES) {
|
||||
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
|
||||
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
|
||||
} else if (key === PROPERTY_CURRENCIES) {
|
||||
await this.exchangeRateDataService.initialize();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||
let orderBy: any = {
|
||||
private async countUsersWithAnalytics() {
|
||||
let where: Prisma.UserWhereInput;
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
where = {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return this.prismaService.user.count({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
private getExtendedPrismaClient() {
|
||||
Logger.debug('Connect extended prisma client', 'AdminService');
|
||||
|
||||
const symbolProfileExtension = Prisma.defineExtension((client) => {
|
||||
return client.$extends({
|
||||
result: {
|
||||
symbolProfile: {
|
||||
isUsedByUsersWithSubscription: {
|
||||
compute: async ({ id }) => {
|
||||
const { _count } =
|
||||
await this.prismaService.symbolProfile.findUnique({
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
Order: {
|
||||
where: {
|
||||
User: {
|
||||
Subscription: {
|
||||
some: {
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
|
||||
return _count.Order > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return new PrismaClient().$extends(symbolProfileExtension);
|
||||
}
|
||||
|
||||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
|
||||
|
||||
const [lastMarketPrices, marketDataItems] = await Promise.all([
|
||||
this.prismaService.marketData.findMany({
|
||||
distinct: ['dataSource', 'symbol'],
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
dataSource: true,
|
||||
marketPrice: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
dataSource: {
|
||||
in: currencyPairs.map(({ dataSource }) => {
|
||||
return dataSource;
|
||||
})
|
||||
},
|
||||
symbol: {
|
||||
in: currencyPairs.map(({ symbol }) => {
|
||||
return symbol;
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
})
|
||||
]);
|
||||
|
||||
const lastMarketPriceMap = new Map<string, number>();
|
||||
|
||||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
|
||||
lastMarketPriceMap.set(
|
||||
getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
marketPrice
|
||||
);
|
||||
}
|
||||
|
||||
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
|
||||
async ({ dataSource, symbol }) => {
|
||||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||
|
||||
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||
currency = getCurrencyFromSymbol(symbol);
|
||||
({ activitiesCount, dateOfFirstActivity } =
|
||||
await this.orderService.getStatisticsByCurrency(currency));
|
||||
}
|
||||
|
||||
const lastMarketPrice = lastMarketPriceMap.get(
|
||||
getAssetProfileIdentifier({ dataSource, symbol })
|
||||
);
|
||||
|
||||
const marketDataItemCount =
|
||||
marketDataItems.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
activitiesCount,
|
||||
currency,
|
||||
dataSource,
|
||||
lastMarketPrice,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
assetClass: AssetClass.LIQUIDITY,
|
||||
assetSubClass: AssetSubClass.CASH,
|
||||
countriesCount: 0,
|
||||
date: dateOfFirstActivity,
|
||||
id: undefined,
|
||||
name: symbol,
|
||||
sectorsCount: 0
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const marketData = await Promise.all(marketDataPromise);
|
||||
return { marketData, count: marketData.length };
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics({
|
||||
skip,
|
||||
take
|
||||
}: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
}): Promise<AdminUsers['users']> {
|
||||
let orderBy: Prisma.UserOrderByWithRelationInput = {
|
||||
createdAt: 'desc'
|
||||
};
|
||||
let where;
|
||||
let where: Prisma.UserWhereInput;
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
orderBy = {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
lastRequestAt: 'desc'
|
||||
}
|
||||
};
|
||||
where = {
|
||||
@ -251,6 +741,8 @@ export class AdminService {
|
||||
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
@ -260,18 +752,19 @@ export class AdminService {
|
||||
select: {
|
||||
activityCount: true,
|
||||
country: true,
|
||||
dataProviderGhostfolioDailyRequests: true,
|
||||
updatedAt: true
|
||||
}
|
||||
},
|
||||
createdAt: true,
|
||||
id: true,
|
||||
role: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30
|
||||
}
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics
|
||||
@ -281,16 +774,21 @@ export class AdminService {
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.subscriptionService.getSubscription(Subscription)
|
||||
? this.subscriptionService.getSubscription({
|
||||
createdAt,
|
||||
subscriptions: Subscription
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
engagement,
|
||||
id,
|
||||
role,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
country: Analytics?.country,
|
||||
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
|
||||
lastActivity: Analytics?.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
};
|
||||
|
@ -1,87 +1,56 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { JobStatus } from 'bull';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { QueueService } from './queue.service';
|
||||
|
||||
@Controller('admin/queue')
|
||||
export class QueueController {
|
||||
public constructor(
|
||||
private readonly queueService: QueueService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly queueService: QueueService) {}
|
||||
|
||||
@Delete('job')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
|
||||
return this.queueService.deleteJobs({ status });
|
||||
}
|
||||
|
||||
@Get('job')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<AdminJobs> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
|
||||
return this.queueService.getJobs({ status });
|
||||
}
|
||||
|
||||
@Delete('job/:id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.queueService.deleteJob(id);
|
||||
}
|
||||
|
||||
@Get('job/:id/execute')
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async executeJob(@Param('id') id: string): Promise<void> {
|
||||
return this.queueService.executeJob(id);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { QueueController } from './queue.controller';
|
||||
@ -6,7 +8,7 @@ import { QueueService } from './queue.service';
|
||||
|
||||
@Module({
|
||||
controllers: [QueueController],
|
||||
imports: [DataGatheringModule],
|
||||
imports: [DataGatheringModule, PortfolioSnapshotQueueModule],
|
||||
providers: [QueueService]
|
||||
})
|
||||
export class QueueModule {}
|
||||
|
@ -1,21 +1,31 @@
|
||||
import {
|
||||
DATA_GATHERING_QUEUE,
|
||||
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
|
||||
QUEUE_JOB_STATUS_LIST
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JobStatus, Queue } from 'bull';
|
||||
|
||||
@Injectable()
|
||||
export class QueueService {
|
||||
public constructor(
|
||||
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||
private readonly dataGatheringQueue: Queue
|
||||
private readonly dataGatheringQueue: Queue,
|
||||
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
|
||||
private readonly portfolioSnapshotQueue: Queue
|
||||
) {}
|
||||
|
||||
public async deleteJob(aId: string) {
|
||||
return (await this.dataGatheringQueue.getJob(aId))?.remove();
|
||||
let job = await this.dataGatheringQueue.getJob(aId);
|
||||
|
||||
if (!job) {
|
||||
job = await this.portfolioSnapshotQueue.getJob(aId);
|
||||
}
|
||||
|
||||
return job?.remove();
|
||||
}
|
||||
|
||||
public async deleteJobs({
|
||||
@ -23,17 +33,24 @@ export class QueueService {
|
||||
}: {
|
||||
status?: JobStatus[];
|
||||
}) {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
for (const statusItem of status) {
|
||||
const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem;
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
Logger.warn(error, 'QueueService');
|
||||
}
|
||||
await this.dataGatheringQueue.clean(300, queueStatus);
|
||||
await this.portfolioSnapshotQueue.clean(300, queueStatus);
|
||||
}
|
||||
}
|
||||
|
||||
public async executeJob(aId: string) {
|
||||
let job = await this.dataGatheringQueue.getJob(aId);
|
||||
|
||||
if (!job) {
|
||||
job = await this.portfolioSnapshotQueue.getJob(aId);
|
||||
}
|
||||
|
||||
return job?.promote();
|
||||
}
|
||||
|
||||
public async getJobs({
|
||||
limit = 1000,
|
||||
status = QUEUE_JOB_STATUS_LIST
|
||||
@ -41,21 +58,30 @@ export class QueueService {
|
||||
limit?: number;
|
||||
status?: JobStatus[];
|
||||
}): Promise<AdminJobs> {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([
|
||||
this.dataGatheringQueue.getJobs(status),
|
||||
this.portfolioSnapshotQueue.getJobs(status)
|
||||
]);
|
||||
|
||||
const jobsWithState = await Promise.all(
|
||||
jobs.slice(0, limit).map(async (job) => {
|
||||
return {
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
data: job.data,
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
};
|
||||
})
|
||||
[...dataGatheringJobs, ...portfolioSnapshotJobs]
|
||||
.filter((job) => {
|
||||
return job;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map(async (job) => {
|
||||
return {
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
data: job.data,
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
opts: job.opts,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -1,13 +1,58 @@
|
||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateAssetProfileDto {
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
@IsOptional()
|
||||
assetClass?: AssetClass;
|
||||
|
||||
@IsEnum(AssetSubClass, { each: true })
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
countries?: Prisma.InputJsonArray;
|
||||
|
||||
@IsCurrencyCode()
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
scraperConfiguration?: Prisma.InputJsonObject;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
sectors?: Prisma.InputJsonArray;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
symbolMapping?: {
|
||||
[dataProvider: string]: string;
|
||||
};
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl({
|
||||
protocols: ['https'],
|
||||
require_protocol: true
|
||||
})
|
||||
url?: string;
|
||||
}
|
||||
|
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray } from 'class-validator';
|
||||
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
export class UpdateBulkMarketDataDto {
|
||||
@ArrayNotEmpty()
|
||||
@IsArray()
|
||||
@Type(() => UpdateMarketDataDto)
|
||||
marketData: UpdateMarketDataDto[];
|
||||
}
|
@ -1,6 +1,10 @@
|
||||
import { IsNumber } from 'class-validator';
|
||||
import { IsISO8601, IsNumber, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateMarketDataDto {
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
date?: string;
|
||||
|
||||
@IsNumber()
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
|
@ -1,49 +1,72 @@
|
||||
import { join } from 'path';
|
||||
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
SUPPORTED_LANGUAGE_CODES
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { join } from 'path';
|
||||
|
||||
import { ConfigurationModule } from '../services/configuration.module';
|
||||
import { CronService } from '../services/cron.service';
|
||||
import { DataGatheringModule } from '../services/data-gathering.module';
|
||||
import { DataProviderModule } from '../services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '../services/prisma.module';
|
||||
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
|
||||
import { AccessModule } from './access/access.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AssetModule } from './asset/asset.module';
|
||||
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { AiModule } from './endpoints/ai/ai.module';
|
||||
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
|
||||
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
|
||||
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
|
||||
import { MarketDataModule } from './endpoints/market-data/market-data.module';
|
||||
import { PublicModule } from './endpoints/public/public.module';
|
||||
import { TagsModule } from './endpoints/tags/tags.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { FrontendMiddleware } from './frontend.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { LogoModule } from './logo/logo.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SitemapModule } from './sitemap/sitemap.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@Module({
|
||||
controllers: [AppController],
|
||||
imports: [
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
AccountModule,
|
||||
AiModule,
|
||||
ApiKeysModule,
|
||||
AssetModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
BenchmarkModule,
|
||||
BenchmarksModule,
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD
|
||||
@ -54,43 +77,57 @@ import { UserModule } from './user/user.module';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
EventsModule,
|
||||
ExchangeRateModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
GhostfolioModule,
|
||||
HealthModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
LogoModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
PortfolioSnapshotQueueModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
PublicModule,
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
serveStaticOptions: {
|
||||
/*etag: false // Disable etag header to fix PWA
|
||||
setHeaders: (res, path) => {
|
||||
if (path.includes('ngsw.json')) {
|
||||
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
|
||||
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
|
||||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
}*/
|
||||
},
|
||||
exclude: ['/api*', '/sitemap.xml'],
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
exclude: ['/api*']
|
||||
serveStaticOptions: {
|
||||
setHeaders: (res) => {
|
||||
if (res.req?.path === '/') {
|
||||
let languageCode = DEFAULT_LANGUAGE_CODE;
|
||||
|
||||
try {
|
||||
const code = res.req.headers['accept-language']
|
||||
.split(',')[0]
|
||||
.split('-')[0];
|
||||
|
||||
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
|
||||
languageCode = code;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
res.set('Location', `/${languageCode}`);
|
||||
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
SitemapModule,
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
TagsModule,
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [CronService]
|
||||
})
|
||||
export class AppModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(FrontendMiddleware)
|
||||
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
||||
}
|
||||
}
|
||||
export class AppModule {}
|
||||
|
29
apps/api/src/app/asset/asset.controller.ts
Normal file
29
apps/api/src/app/asset/asset.controller.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { pick } from 'lodash';
|
||||
|
||||
@Controller('asset')
|
||||
export class AssetController {
|
||||
public constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAsset(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
const { assetProfile, marketData } =
|
||||
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||
|
||||
return {
|
||||
marketData,
|
||||
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
|
||||
};
|
||||
}
|
||||
}
|
17
apps/api/src/app/asset/asset.module.ts
Normal file
17
apps/api/src/app/asset/asset.module.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AssetController } from './asset.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AssetController],
|
||||
imports: [
|
||||
AdminModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
TransformDataSourceInResponseModule
|
||||
]
|
||||
})
|
||||
export class AssetModule {}
|
@ -1,40 +1,19 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('auth-device')
|
||||
export class AuthDeviceController {
|
||||
public constructor(
|
||||
private readonly authDeviceService: AuthDeviceService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly authDeviceService: AuthDeviceService) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteAuthDevice)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.deleteAuthDevice
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
await this.authDeviceService.deleteAuthDevice({ id });
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthDeviceController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
|
@ -1,14 +1,11 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthDevice, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthDeviceService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async authDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
|
76
apps/api/src/app/auth/api-key.strategy.ts
Normal file
76
apps/api/src/app/auth/api-key.strategy.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config';
|
||||
import { hasRole } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyStrategy extends PassportStrategy(
|
||||
HeaderAPIKeyStrategy,
|
||||
'api-key'
|
||||
) {
|
||||
public constructor(
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
super(
|
||||
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' },
|
||||
true,
|
||||
async (apiKey: string, done: (error: any, user?: any) => void) => {
|
||||
try {
|
||||
const user = await this.validateApiKey(apiKey);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (hasRole(user, 'INACTIVE')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { User: { connect: { id: user.id } } },
|
||||
update: {
|
||||
activityCount: { increment: 1 },
|
||||
lastRequestAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
}
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async validateApiKey(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||
StatusCodes.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await this.apiKeyService.getUserByApiKey(apiKey);
|
||||
|
||||
return this.userService.user({ id });
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||
StatusCodes.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -12,12 +14,12 @@ import {
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
VERSION_NEUTRAL,
|
||||
Version
|
||||
Version,
|
||||
VERSION_NEUTRAL
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
@ -33,13 +35,32 @@ export class AuthController {
|
||||
private readonly webAuthService: WebAuthService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('anonymous/:accessToken')
|
||||
public async accessTokenLogin(
|
||||
public async accessTokenLoginGet(
|
||||
@Param('accessToken') accessToken: string
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken =
|
||||
await this.authService.validateAnonymousLogin(accessToken);
|
||||
return { authToken };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('anonymous')
|
||||
public async accessTokenLogin(
|
||||
@Body() body: { accessToken: string }
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateAnonymousLogin(
|
||||
accessToken
|
||||
body.accessToken
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
@ -64,7 +85,7 @@ export class AuthController {
|
||||
@Res() response: Response
|
||||
) {
|
||||
// Handles the Google OAuth2 callback
|
||||
const jwt: string = (<any>request.user).jwt;
|
||||
const jwt: string = (request.user as any).jwt;
|
||||
|
||||
if (jwt) {
|
||||
response.redirect(
|
||||
@ -81,13 +102,13 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get('internet-identity/:principalId')
|
||||
@Post('internet-identity')
|
||||
public async internetIdentityLogin(
|
||||
@Param('principalId') principalId: string
|
||||
@Body() body: { principalId: string }
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||
principalId
|
||||
body.principalId
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
@ -99,20 +120,17 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Get('webauthn/generate-registration-options')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async generateRegistrationOptions() {
|
||||
return this.webAuthService.generateRegistrationOptions();
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-attestation')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async verifyAttestation(
|
||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||
) {
|
||||
return this.webAuthService.verifyAttestation(
|
||||
body.deviceName,
|
||||
body.credential
|
||||
);
|
||||
return this.webAuthService.verifyAttestation(body.credential);
|
||||
}
|
||||
|
||||
@Post('webauthn/generate-assertion-options')
|
||||
|
@ -2,12 +2,15 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { ApiKeyStrategy } from './api-key.strategy';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
@ -27,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
ApiKeyService,
|
||||
ApiKeyStrategy,
|
||||
AuthDeviceService,
|
||||
AuthService,
|
||||
GoogleStrategy,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Provider } from '@prisma/client';
|
||||
@ -55,7 +56,7 @@ export class AuthService {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (!isUserSignupEnabled) {
|
||||
if (!isUserSignupEnabled || true) {
|
||||
throw new Error('Sign up forbidden');
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { Strategy } from 'passport-google-oauth20';
|
||||
import { Profile, Strategy } from 'passport-google-oauth20';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@ -10,7 +11,7 @@ import { AuthService } from './auth.service';
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
public constructor(
|
||||
private readonly authService: AuthService,
|
||||
readonly configurationService: ConfigurationService
|
||||
configurationService: ConfigurationService
|
||||
) {
|
||||
super({
|
||||
callbackURL: `${configurationService.get(
|
||||
@ -19,28 +20,24 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
||||
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
||||
passReqToCallback: true,
|
||||
scope: ['email', 'profile']
|
||||
scope: ['profile']
|
||||
});
|
||||
}
|
||||
|
||||
public async validate(
|
||||
request: any,
|
||||
token: string,
|
||||
refreshToken: string,
|
||||
profile,
|
||||
done: Function,
|
||||
done2: Function
|
||||
_request: any,
|
||||
_token: string,
|
||||
_refreshToken: string,
|
||||
profile: Profile,
|
||||
done: Function
|
||||
) {
|
||||
try {
|
||||
const jwt: string = await this.authService.validateOAuthLogin({
|
||||
const jwt = await this.authService.validateOAuthLogin({
|
||||
provider: Provider.GOOGLE,
|
||||
thirdPartyId: profile.id
|
||||
});
|
||||
const user = {
|
||||
jwt
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
done(null, { jwt });
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GoogleStrategy');
|
||||
done(error, false);
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
|
||||
import { Provider } from '@prisma/client';
|
||||
|
||||
export interface AuthDeviceDialogParams {
|
||||
|
@ -198,12 +198,12 @@ export interface AuthenticatorAssertionResponseJSON
|
||||
/**
|
||||
* A WebAuthn-compatible device and the information needed to verify assertions by it
|
||||
*/
|
||||
export declare type AuthenticatorDevice = {
|
||||
export declare interface AuthenticatorDevice {
|
||||
credentialPublicKey: Buffer;
|
||||
credentialID: Buffer;
|
||||
counter: number;
|
||||
transports?: AuthenticatorTransport[];
|
||||
};
|
||||
}
|
||||
/**
|
||||
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
|
||||
*/
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { hasRole } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
@ -28,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
if (user) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (hasRole(user, 'INACTIVE')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
const country =
|
||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||
|
||||
@ -36,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
update: {
|
||||
country,
|
||||
activityCount: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
lastRequestAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
@ -44,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
return user;
|
||||
} else {
|
||||
throw '';
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||
StatusCodes.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new UnauthorizedException('unauthorized', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
@ -12,16 +13,16 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
generateRegistrationOptions,
|
||||
GenerateRegistrationOptionsOpts,
|
||||
VerifiedAuthenticationResponse,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse
|
||||
VerifyAuthenticationResponseOpts,
|
||||
verifyRegistrationResponse,
|
||||
VerifyRegistrationResponseOpts
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
import {
|
||||
@ -40,7 +41,7 @@ export class WebAuthService {
|
||||
) {}
|
||||
|
||||
get rpID() {
|
||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||
return new URL(this.configurationService.get('ROOT_URL')).hostname;
|
||||
}
|
||||
|
||||
get expectedOrigin() {
|
||||
@ -64,7 +65,7 @@ export class WebAuthService {
|
||||
}
|
||||
};
|
||||
|
||||
const options = generateRegistrationOptions(opts);
|
||||
const options = await generateRegistrationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
@ -79,7 +80,6 @@ export class WebAuthService {
|
||||
}
|
||||
|
||||
public async verifyAttestation(
|
||||
deviceName: string,
|
||||
credential: AttestationCredentialJSON
|
||||
): Promise<AuthDeviceDto> {
|
||||
const user = this.request.user;
|
||||
@ -88,10 +88,16 @@ export class WebAuthService {
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
const opts: VerifyRegistrationResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
expectedRPID: this.rpID,
|
||||
response: {
|
||||
clientExtensionResults: credential.clientExtensionResults,
|
||||
id: credential.id,
|
||||
rawId: credential.rawId,
|
||||
response: credential.response,
|
||||
type: 'public-key'
|
||||
}
|
||||
};
|
||||
verification = await verifyRegistrationResponse(opts);
|
||||
} catch (error) {
|
||||
@ -117,8 +123,8 @@ export class WebAuthService {
|
||||
*/
|
||||
existingDevice = await this.deviceService.createAuthDevice({
|
||||
counter,
|
||||
credentialPublicKey,
|
||||
credentialId: credentialID,
|
||||
credentialId: Buffer.from(credentialID),
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||
User: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
@ -152,7 +158,7 @@ export class WebAuthService {
|
||||
userVerification: 'preferred'
|
||||
};
|
||||
|
||||
const options = generateAuthenticationOptions(opts);
|
||||
const options = await generateAuthenticationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
@ -181,7 +187,6 @@ export class WebAuthService {
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
const opts: VerifyAuthenticationResponseOpts = {
|
||||
credential,
|
||||
authenticator: {
|
||||
credentialID: device.credentialId,
|
||||
credentialPublicKey: device.credentialPublicKey,
|
||||
@ -189,9 +194,16 @@ export class WebAuthService {
|
||||
},
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
expectedRPID: this.rpID,
|
||||
response: {
|
||||
clientExtensionResults: credential.clientExtensionResults,
|
||||
id: credential.id,
|
||||
rawId: credential.rawId,
|
||||
response: credential.response,
|
||||
type: 'public-key'
|
||||
}
|
||||
};
|
||||
verification = verifyAuthenticationResponse(opts);
|
||||
verification = await verifyAuthenticationResponse(opts);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'WebAuthService');
|
||||
throw new InternalServerErrorException({ error: error.message });
|
||||
|
@ -1,48 +0,0 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
|
||||
@Controller('benchmark')
|
||||
export class BenchmarkController {
|
||||
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||
return {
|
||||
benchmarks: await this.benchmarkService.getBenchmarks()
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:startDateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getBenchmarkMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('startDateString') startDateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<BenchmarkMarketDataDetails> {
|
||||
const startDate = new Date(startDateString);
|
||||
|
||||
return this.benchmarkService.getMarketDataBySymbol({
|
||||
dataSource,
|
||||
startDate,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
@ -1,210 +0,0 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
MAX_CHART_ITEMS,
|
||||
PROPERTY_BENCHMARKS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
export class BenchmarkService {
|
||||
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
||||
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly symbolProfileService: SymbolProfileService,
|
||||
private readonly symbolService: SymbolService
|
||||
) {}
|
||||
|
||||
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
||||
if (baseValue && currentValue) {
|
||||
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||
BenchmarkResponse['benchmarks']
|
||||
> {
|
||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||
|
||||
if (useCache) {
|
||||
try {
|
||||
benchmarks = JSON.parse(
|
||||
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||
);
|
||||
|
||||
if (benchmarks) {
|
||||
return benchmarks;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||
|
||||
const promises: Promise<number>[] = [];
|
||||
|
||||
const quotes = await this.dataProviderService.getQuotes(
|
||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||
}
|
||||
|
||||
const allTimeHighs = await Promise.all(promises);
|
||||
let storeInCache = true;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } =
|
||||
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||
|
||||
let performancePercentFromAllTimeHigh = 0;
|
||||
|
||||
if (allTimeHigh && marketPrice) {
|
||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||
allTimeHigh,
|
||||
marketPrice
|
||||
);
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
),
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent: performancePercentFromAllTimeHigh
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (storeInCache) {
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
const symbolProfileIds: string[] = (
|
||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
||||
symbolProfileId: string;
|
||||
}[]) ?? []
|
||||
).map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
|
||||
const assetProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||
|
||||
return assetProfiles
|
||||
.map(({ dataSource, id, name, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
symbol
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
startDate,
|
||||
symbol
|
||||
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||
this.symbolService.get({
|
||||
dataGatheringItem: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}),
|
||||
this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
where: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: {
|
||||
gte: startDate
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const step = Math.round(
|
||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||
);
|
||||
|
||||
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||
const response = {
|
||||
marketData: [
|
||||
...marketDataItems
|
||||
.filter((marketDataItem, index) => {
|
||||
return index % step === 0;
|
||||
})
|
||||
.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice
|
||||
) * 100
|
||||
};
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
if (currentSymbolItem?.marketPrice) {
|
||||
response.marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
currentSymbolItem.marketPrice
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: number) {
|
||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
}
|
||||
}
|
38
apps/api/src/app/cache/cache.controller.ts
vendored
38
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,39 +1,19 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('cache')
|
||||
export class CacheController {
|
||||
public constructor(
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly redisCacheService: RedisCacheService) {}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('flush')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async flushCache(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.redisCacheService.reset();
|
||||
await this.redisCacheService.reset();
|
||||
}
|
||||
}
|
||||
|
17
apps/api/src/app/cache/cache.module.ts
vendored
17
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,24 +1,11 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheController } from './cache.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [CacheController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
]
|
||||
imports: [RedisCacheModule]
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
42
apps/api/src/app/endpoints/ai/ai.controller.ts
Normal file
42
apps/api/src/app/endpoints/ai/ai.controller.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_LANGUAGE_CODE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Get, Inject, Param, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
public constructor(
|
||||
private readonly aiService: AiService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get('prompt/:mode')
|
||||
@HasPermission(permissions.readAiPrompt)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPrompt(
|
||||
@Param('mode') mode: AiPromptMode
|
||||
): Promise<AiPromptResponse> {
|
||||
const prompt = await this.aiService.getPrompt({
|
||||
mode,
|
||||
impersonationId: undefined,
|
||||
languageCode:
|
||||
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,
|
||||
userCurrency:
|
||||
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { prompt };
|
||||
}
|
||||
}
|
51
apps/api/src/app/endpoints/ai/ai.module.ts
Normal file
51
apps/api/src/app/endpoints/ai/ai.module.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AiController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PortfolioSnapshotQueueModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AccountBalanceService,
|
||||
AccountService,
|
||||
AiService,
|
||||
CurrentRateService,
|
||||
MarketDataService,
|
||||
PortfolioCalculatorFactory,
|
||||
PortfolioService,
|
||||
RulesService
|
||||
]
|
||||
})
|
||||
export class AiModule {}
|
67
apps/api/src/app/endpoints/ai/ai.service.ts
Normal file
67
apps/api/src/app/endpoints/ai/ai.service.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import type { AiPromptMode } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
public constructor(private readonly portfolioService: PortfolioService) {}
|
||||
|
||||
public async getPrompt({
|
||||
impersonationId,
|
||||
languageCode,
|
||||
mode,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
impersonationId: string;
|
||||
languageCode: string;
|
||||
mode: AiPromptMode;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
impersonationId,
|
||||
userId
|
||||
});
|
||||
|
||||
const holdingsTable = [
|
||||
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
|
||||
'| --- | --- | --- | --- | --- | --- |',
|
||||
...Object.values(holdings)
|
||||
.sort((a, b) => {
|
||||
return b.allocationInPercentage - a.allocationInPercentage;
|
||||
})
|
||||
.map(
|
||||
({
|
||||
allocationInPercentage,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency,
|
||||
name,
|
||||
symbol
|
||||
}) => {
|
||||
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`;
|
||||
}
|
||||
)
|
||||
];
|
||||
|
||||
if (mode === 'portfolio') {
|
||||
return holdingsTable.join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
|
||||
...holdingsTable,
|
||||
'Structure your answer with these sections:',
|
||||
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.',
|
||||
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.',
|
||||
'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.',
|
||||
'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.',
|
||||
'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).',
|
||||
'Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.',
|
||||
'Conclusion: Provide a concise summary highlighting key insights.',
|
||||
`Provide your answer in the following language: ${languageCode}.`
|
||||
].join('\n');
|
||||
}
|
||||
}
|
25
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
Normal file
25
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
||||
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Controller('api-keys')
|
||||
export class ApiKeysController {
|
||||
public constructor(
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.createApiKey)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createApiKey(): Promise<ApiKeyResponse> {
|
||||
return this.apiKeyService.create({ userId: this.request.user.id });
|
||||
}
|
||||
}
|
11
apps/api/src/app/endpoints/api-keys/api-keys.module.ts
Normal file
11
apps/api/src/app/endpoints/api-keys/api-keys.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [ApiKeysController],
|
||||
imports: [ApiKeyModule]
|
||||
})
|
||||
export class ApiKeysModule {}
|
156
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
Normal file
156
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import type {
|
||||
AssetProfileIdentifier,
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { BenchmarksService } from './benchmarks.service';
|
||||
|
||||
@Controller('benchmarks')
|
||||
export class BenchmarksController {
|
||||
public constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly benchmarksService: BenchmarksService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async addBenchmark(
|
||||
@Body() { dataSource, symbol }: AssetProfileIdentifier
|
||||
) {
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.addBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (!benchmark) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return benchmark;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':dataSource/:symbol')
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteBenchmark(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (!benchmark) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return benchmark;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||
return {
|
||||
benchmarks: await this.benchmarkService.getBenchmarks()
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:startDateString')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getBenchmarkMarketDataForUser(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('startDateString') startDateString: string,
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
|
||||
): Promise<BenchmarkMarketDataDetails> {
|
||||
const { endDate, startDate } = getIntervalFromDateRange(
|
||||
dateRange,
|
||||
new Date(startDateString)
|
||||
);
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
||||
|
||||
return this.benchmarksService.getMarketDataForUser({
|
||||
dataSource,
|
||||
dateRange,
|
||||
endDate,
|
||||
filters,
|
||||
impersonationId,
|
||||
startDate,
|
||||
symbol,
|
||||
withExcludedAccounts,
|
||||
user: this.request.user
|
||||
});
|
||||
}
|
||||
}
|
63
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
Normal file
63
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BenchmarksController } from './benchmarks.controller';
|
||||
import { BenchmarksService } from './benchmarks.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BenchmarksController],
|
||||
imports: [
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PortfolioSnapshotQueueModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
TransformDataSourceInResponseModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AccountBalanceService,
|
||||
AccountService,
|
||||
BenchmarkService,
|
||||
BenchmarksService,
|
||||
CurrentRateService,
|
||||
MarketDataService,
|
||||
PortfolioCalculatorFactory,
|
||||
PortfolioService,
|
||||
RulesService
|
||||
]
|
||||
})
|
||||
export class BenchmarksModule {}
|
163
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
Normal file
163
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
BenchmarkMarketDataDetails,
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { format, isSameDay } from 'date-fns';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
export class BenchmarksService {
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly symbolService: SymbolService
|
||||
) {}
|
||||
|
||||
public async getMarketDataForUser({
|
||||
dataSource,
|
||||
dateRange,
|
||||
endDate = new Date(),
|
||||
filters,
|
||||
impersonationId,
|
||||
startDate,
|
||||
symbol,
|
||||
user,
|
||||
withExcludedAccounts
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
endDate?: Date;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
startDate: Date;
|
||||
user: UserWithSettings;
|
||||
withExcludedAccounts?: boolean;
|
||||
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
|
||||
const marketData: { date: string; value: number }[] = [];
|
||||
const userCurrency = user.Settings.settings.baseCurrency;
|
||||
const userId = user.id;
|
||||
|
||||
const { chart } = await this.portfolioService.getPerformance({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||
this.symbolService.get({
|
||||
dataGatheringItem: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}),
|
||||
this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
where: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: {
|
||||
in: chart.map(({ date }) => {
|
||||
return resetHours(parseDate(date));
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const exchangeRates =
|
||||
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||
startDate,
|
||||
currencies: [currentSymbolItem.currency],
|
||||
targetCurrency: userCurrency
|
||||
});
|
||||
|
||||
const exchangeRateAtStartDate =
|
||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||
format(startDate, DATE_FORMAT)
|
||||
];
|
||||
|
||||
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
|
||||
return isSameDay(date, startDate);
|
||||
})?.marketPrice;
|
||||
|
||||
if (!marketPriceAtStartDate) {
|
||||
Logger.error(
|
||||
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
|
||||
startDate,
|
||||
DATE_FORMAT
|
||||
)}`,
|
||||
'BenchmarkService'
|
||||
);
|
||||
|
||||
return { marketData };
|
||||
}
|
||||
|
||||
for (const marketDataItem of marketDataItems) {
|
||||
const exchangeRate =
|
||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||
format(marketDataItem.date, DATE_FORMAT)
|
||||
];
|
||||
|
||||
const exchangeRateFactor =
|
||||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||
? exchangeRate / exchangeRateAtStartDate
|
||||
: 1;
|
||||
|
||||
marketData.push({
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.benchmarkService.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice * exchangeRateFactor
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
const includesEndDate = isSameDay(
|
||||
parseDate(marketData.at(-1).date),
|
||||
endDate
|
||||
);
|
||||
|
||||
if (currentSymbolItem?.marketPrice && !includesEndDate) {
|
||||
const exchangeRate =
|
||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||
format(endDate, DATE_FORMAT)
|
||||
];
|
||||
|
||||
const exchangeRateFactor =
|
||||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||
? exchangeRate / exchangeRateAtStartDate
|
||||
: 1;
|
||||
|
||||
marketData.push({
|
||||
date: format(endDate, DATE_FORMAT),
|
||||
value:
|
||||
this.benchmarkService.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
currentSymbolItem.marketPrice * exchangeRateFactor
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
marketData
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
|
||||
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
export class GetDividendsDto {
|
||||
@IsISO8601()
|
||||
from: string;
|
||||
|
||||
@IsIn(['day', 'month'] as Granularity[])
|
||||
@IsOptional()
|
||||
granularity: Granularity;
|
||||
|
||||
@IsISO8601()
|
||||
to: string;
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
|
||||
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
export class GetHistoricalDto {
|
||||
@IsISO8601()
|
||||
from: string;
|
||||
|
||||
@IsIn(['day', 'month'] as Granularity[])
|
||||
@IsOptional()
|
||||
granularity: Granularity;
|
||||
|
||||
@IsISO8601()
|
||||
to: string;
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class GetQuotesDto {
|
||||
@IsString({ each: true })
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.split(',') : value
|
||||
)
|
||||
symbols: string[];
|
||||
}
|
@ -0,0 +1,375 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderGhostfolioStatusResponse,
|
||||
DividendsResponse,
|
||||
HistoricalResponse,
|
||||
LookupResponse,
|
||||
QuotesResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
||||
|
||||
import { GetDividendsDto } from './get-dividends.dto';
|
||||
import { GetHistoricalDto } from './get-historical.dto';
|
||||
import { GetQuotesDto } from './get-quotes.dto';
|
||||
import { GhostfolioService } from './ghostfolio.service';
|
||||
|
||||
@Controller('data-providers/ghostfolio')
|
||||
export class GhostfolioController {
|
||||
public constructor(
|
||||
private readonly ghostfolioService: GhostfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('dividends/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getDividendsV1(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetDividendsDto
|
||||
): Promise<DividendsResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dividends = await this.ghostfolioService.getDividends({
|
||||
symbol,
|
||||
from: parseDate(query.from),
|
||||
granularity: query.granularity,
|
||||
to: parseDate(query.to)
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return dividends;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('dividends/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async getDividends(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetDividendsDto
|
||||
): Promise<DividendsResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dividends = await this.ghostfolioService.getDividends({
|
||||
symbol,
|
||||
from: parseDate(query.from),
|
||||
granularity: query.granularity,
|
||||
to: parseDate(query.to)
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return dividends;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('historical/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getHistoricalV1(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetHistoricalDto
|
||||
): Promise<HistoricalResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const historicalData = await this.ghostfolioService.getHistorical({
|
||||
symbol,
|
||||
from: parseDate(query.from),
|
||||
granularity: query.granularity,
|
||||
to: parseDate(query.to)
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return historicalData;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('historical/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async getHistorical(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetHistoricalDto
|
||||
): Promise<HistoricalResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const historicalData = await this.ghostfolioService.getHistorical({
|
||||
symbol,
|
||||
from: parseDate(query.from),
|
||||
granularity: query.granularity,
|
||||
to: parseDate(query.to)
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return historicalData;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('lookup')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async lookupSymbolV1(
|
||||
@Query('includeIndices') includeIndicesParam = 'false',
|
||||
@Query('query') query = ''
|
||||
): Promise<LookupResponse> {
|
||||
const includeIndices = includeIndicesParam === 'true';
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.ghostfolioService.lookup({
|
||||
includeIndices,
|
||||
query: query.toLowerCase()
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('lookup')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async lookupSymbol(
|
||||
@Query('includeIndices') includeIndicesParam = 'false',
|
||||
@Query('query') query = ''
|
||||
): Promise<LookupResponse> {
|
||||
const includeIndices = includeIndicesParam === 'true';
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.ghostfolioService.lookup({
|
||||
includeIndices,
|
||||
query: query.toLowerCase()
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('quotes')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getQuotesV1(
|
||||
@Query() query: GetQuotesDto
|
||||
): Promise<QuotesResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const quotes = await this.ghostfolioService.getQuotes({
|
||||
symbols: query.symbols
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return quotes;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('quotes')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async getQuotes(
|
||||
@Query() query: GetQuotesDto
|
||||
): Promise<QuotesResponse> {
|
||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||
|
||||
if (
|
||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const quotes = await this.ghostfolioService.getQuotes({
|
||||
symbols: query.symbols
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return quotes;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('status')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
|
||||
return this.ghostfolioService.getStatus({ user: this.request.user });
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
@Version('2')
|
||||
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
|
||||
return this.ghostfolioService.getStatus({ user: this.request.user });
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
|
||||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
||||
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
|
||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { GhostfolioController } from './ghostfolio.controller';
|
||||
import { GhostfolioService } from './ghostfolio.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GhostfolioController],
|
||||
imports: [
|
||||
CryptocurrencyModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CoinGeckoService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
EodHistoricalDataService,
|
||||
FinancialModelingPrepService,
|
||||
GhostfolioService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RapidApiService,
|
||||
YahooFinanceService,
|
||||
YahooFinanceDataEnhancerService,
|
||||
{
|
||||
inject: [
|
||||
AlphaVantageService,
|
||||
CoinGeckoService,
|
||||
EodHistoricalDataService,
|
||||
FinancialModelingPrepService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RapidApiService,
|
||||
YahooFinanceService
|
||||
],
|
||||
provide: 'DataProviderInterfaces',
|
||||
useFactory: (
|
||||
alphaVantageService,
|
||||
coinGeckoService,
|
||||
eodHistoricalDataService,
|
||||
financialModelingPrepService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
coinGeckoService,
|
||||
eodHistoricalDataService,
|
||||
financialModelingPrepService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
export class GhostfolioModule {}
|
@ -0,0 +1,303 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import {
|
||||
GetDividendsParams,
|
||||
GetHistoricalParams,
|
||||
GetQuotesParams,
|
||||
GetSearchParams
|
||||
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DERIVED_CURRENCIES
|
||||
} from '@ghostfolio/common/config';
|
||||
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
DividendsResponse,
|
||||
HistoricalResponse,
|
||||
LookupItem,
|
||||
LookupResponse,
|
||||
QuotesResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
|
||||
@Injectable()
|
||||
export class GhostfolioService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity,
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbol,
|
||||
to
|
||||
}: GetDividendsParams) {
|
||||
const result: DividendsResponse = { dividends: {} };
|
||||
|
||||
try {
|
||||
const promises: Promise<{
|
||||
[date: string]: IDataProviderHistoricalResponse;
|
||||
}>[] = [];
|
||||
|
||||
for (const dataProviderService of this.getDataProviderServices()) {
|
||||
promises.push(
|
||||
dataProviderService
|
||||
.getDividends({
|
||||
from,
|
||||
granularity,
|
||||
requestTimeout,
|
||||
symbol,
|
||||
to
|
||||
})
|
||||
.then((dividends) => {
|
||||
result.dividends = dividends;
|
||||
|
||||
return dividends;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GhostfolioService');
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getHistorical({
|
||||
from,
|
||||
granularity,
|
||||
requestTimeout,
|
||||
to,
|
||||
symbol
|
||||
}: GetHistoricalParams) {
|
||||
const result: HistoricalResponse = { historicalData: {} };
|
||||
|
||||
try {
|
||||
const promises: Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}>[] = [];
|
||||
|
||||
for (const dataProviderService of this.getDataProviderServices()) {
|
||||
promises.push(
|
||||
dataProviderService
|
||||
.getHistorical({
|
||||
from,
|
||||
granularity,
|
||||
requestTimeout,
|
||||
symbol,
|
||||
to
|
||||
})
|
||||
.then((historicalData) => {
|
||||
result.historicalData = historicalData[symbol];
|
||||
|
||||
return historicalData;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GhostfolioService');
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getMaxDailyRequests() {
|
||||
return parseInt(
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
|
||||
)) as string) || '0',
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) {
|
||||
const results: QuotesResponse = { quotes: {} };
|
||||
|
||||
try {
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
for (const dataProvider of this.getDataProviderServices()) {
|
||||
const maximumNumberOfSymbolsPerRequest =
|
||||
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
||||
Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < symbols.length;
|
||||
i += maximumNumberOfSymbolsPerRequest
|
||||
) {
|
||||
const symbolsChunk = symbols.slice(
|
||||
i,
|
||||
i + maximumNumberOfSymbolsPerRequest
|
||||
);
|
||||
|
||||
const promise = Promise.resolve(
|
||||
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
|
||||
);
|
||||
|
||||
promises.push(
|
||||
promise.then(async (result) => {
|
||||
for (const [symbol, dataProviderResponse] of Object.entries(
|
||||
result
|
||||
)) {
|
||||
dataProviderResponse.dataSource = 'GHOSTFOLIO';
|
||||
|
||||
if (
|
||||
[
|
||||
...DERIVED_CURRENCIES.map(({ currency }) => {
|
||||
return `${DEFAULT_CURRENCY}${currency}`;
|
||||
}),
|
||||
`${DEFAULT_CURRENCY}USX`
|
||||
].includes(symbol)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.quotes[symbol] = dataProviderResponse;
|
||||
|
||||
for (const {
|
||||
currency,
|
||||
factor,
|
||||
rootCurrency
|
||||
} of DERIVED_CURRENCIES) {
|
||||
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) {
|
||||
results.quotes[`${DEFAULT_CURRENCY}${currency}`] = {
|
||||
...dataProviderResponse,
|
||||
currency,
|
||||
marketPrice: new Big(
|
||||
result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice
|
||||
)
|
||||
.mul(factor)
|
||||
.toNumber(),
|
||||
marketState: 'open'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GhostfolioService');
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getStatus({ user }: { user: UserWithSettings }) {
|
||||
return {
|
||||
dailyRequests: user.dataProviderGhostfolioDailyRequests,
|
||||
dailyRequestsMax: await this.getMaxDailyRequests(),
|
||||
subscription: user.subscription
|
||||
};
|
||||
}
|
||||
|
||||
public async incrementDailyRequests({ userId }: { userId: string }) {
|
||||
await this.prismaService.analytics.update({
|
||||
data: {
|
||||
dataProviderGhostfolioDailyRequests: { increment: 1 }
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
}
|
||||
|
||||
public async lookup({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: GetSearchParams): Promise<LookupResponse> {
|
||||
const results: LookupResponse = { items: [] };
|
||||
|
||||
if (!query) {
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
let lookupItems: LookupItem[] = [];
|
||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||
|
||||
if (query?.length < 2) {
|
||||
return { items: lookupItems };
|
||||
}
|
||||
|
||||
for (const dataProviderService of this.getDataProviderServices()) {
|
||||
promises.push(
|
||||
dataProviderService.search({
|
||||
includeIndices,
|
||||
query
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const searchResults = await Promise.all(promises);
|
||||
|
||||
for (const { items } of searchResults) {
|
||||
if (items?.length > 0) {
|
||||
lookupItems = lookupItems.concat(items);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = lookupItems
|
||||
.filter(({ currency }) => {
|
||||
// Only allow symbols with supported currency
|
||||
return currency ? true : false;
|
||||
})
|
||||
.sort(({ name: name1 }, { name: name2 }) => {
|
||||
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
||||
})
|
||||
.map((lookupItem) => {
|
||||
lookupItem.dataProviderInfo = this.getDataProviderInfo();
|
||||
lookupItem.dataSource = 'GHOSTFOLIO';
|
||||
|
||||
return lookupItem;
|
||||
});
|
||||
|
||||
results.items = filteredItems;
|
||||
return results;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GhostfolioService');
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false,
|
||||
name: 'Ghostfolio Premium',
|
||||
url: 'https://ghostfol.io'
|
||||
};
|
||||
}
|
||||
|
||||
private getDataProviderServices() {
|
||||
return this.configurationService
|
||||
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
|
||||
.map((dataSource) => {
|
||||
return this.dataProviderService.getDataProvider(DataSource[dataSource]);
|
||||
});
|
||||
}
|
||||
}
|
137
apps/api/src/app/endpoints/market-data/market-data.controller.ts
Normal file
137
apps/api/src/app/endpoints/market-data/market-data.controller.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, Prisma } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
||||
|
||||
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||
|
||||
@Controller('market-data')
|
||||
export class MarketDataController {
|
||||
public constructor(
|
||||
private readonly adminService: AdminService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<MarketDataDetailsResponse> {
|
||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const canReadAllAssetProfiles = hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.readMarketData
|
||||
);
|
||||
|
||||
const canReadOwnAssetProfile =
|
||||
assetProfile?.userId === this.request.user.id &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.readMarketDataOfOwnAssetProfile
|
||||
);
|
||||
|
||||
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
|
||||
throw new HttpException(
|
||||
assetProfile.userId
|
||||
? getReasonPhrase(StatusCodes.NOT_FOUND)
|
||||
: getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Post(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateMarketData(
|
||||
@Body() data: UpdateBulkMarketDataDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const canUpsertAllAssetProfiles =
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createMarketData
|
||||
) &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.updateMarketData
|
||||
);
|
||||
|
||||
const canUpsertOwnAssetProfile =
|
||||
assetProfile.userId === this.request.user.id &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createMarketDataOfOwnAssetProfile
|
||||
) &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.updateMarketDataOfOwnAssetProfile
|
||||
);
|
||||
|
||||
if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||
({ date, marketPrice }) => ({
|
||||
dataSource,
|
||||
marketPrice,
|
||||
symbol,
|
||||
date: parseISO(date),
|
||||
state: 'CLOSE'
|
||||
})
|
||||
);
|
||||
|
||||
return this.marketDataService.updateMany({
|
||||
data: dataBulkUpdate
|
||||
});
|
||||
}
|
||||
}
|
13
apps/api/src/app/endpoints/market-data/market-data.module.ts
Normal file
13
apps/api/src/app/endpoints/market-data/market-data.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
||||
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MarketDataController } from './market-data.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [MarketDataController],
|
||||
imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule]
|
||||
})
|
||||
export class MarketDataModule {}
|
@ -0,0 +1,24 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateBulkMarketDataDto {
|
||||
@ArrayNotEmpty()
|
||||
@IsArray()
|
||||
@Type(() => UpdateMarketDataDto)
|
||||
marketData: UpdateMarketDataDto[];
|
||||
}
|
||||
|
||||
class UpdateMarketDataDto {
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
date?: string;
|
||||
|
||||
@IsNumber()
|
||||
marketPrice: number;
|
||||
}
|
140
apps/api/src/app/endpoints/public/public.controller.ts
Normal file
140
apps/api/src/app/endpoints/public/public.controller.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
import { getSum } from '@ghostfolio/common/helper';
|
||||
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { Big } from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('public')
|
||||
export class PublicController {
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Get(':accessId/portfolio')
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPublicPortfolio(
|
||||
@Param('accessId') accessId
|
||||
): Promise<PublicPortfolioResponse> {
|
||||
const access = await this.accessService.access({ id: accessId });
|
||||
|
||||
if (!access) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
|
||||
const user = await this.userService.user({
|
||||
id: access.userId
|
||||
});
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const [
|
||||
{ createdAt, holdings, markets },
|
||||
{ performance: performance1d },
|
||||
{ performance: performanceMax },
|
||||
{ performance: performanceYtd }
|
||||
] = await Promise.all([
|
||||
this.portfolioService.getDetails({
|
||||
impersonationId: access.userId,
|
||||
userId: user.id,
|
||||
withMarkets: true
|
||||
}),
|
||||
...['1d', 'max', 'ytd'].map((dateRange) => {
|
||||
return this.portfolioService.getPerformance({
|
||||
dateRange,
|
||||
impersonationId: undefined,
|
||||
userId: user.id
|
||||
});
|
||||
})
|
||||
]);
|
||||
|
||||
Object.values(markets ?? {}).forEach((market) => {
|
||||
delete market.valueInBaseCurrency;
|
||||
});
|
||||
|
||||
const publicPortfolioResponse: PublicPortfolioResponse = {
|
||||
createdAt,
|
||||
hasDetails,
|
||||
markets,
|
||||
alias: access.alias,
|
||||
holdings: {},
|
||||
performance: {
|
||||
'1d': {
|
||||
relativeChange:
|
||||
performance1d.netPerformancePercentageWithCurrencyEffect
|
||||
},
|
||||
max: {
|
||||
relativeChange:
|
||||
performanceMax.netPerformancePercentageWithCurrencyEffect
|
||||
},
|
||||
ytd: {
|
||||
relativeChange:
|
||||
performanceYtd.netPerformancePercentageWithCurrencyEffect
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const totalValue = getSum(
|
||||
Object.values(holdings).map(({ currency, marketPrice, quantity }) => {
|
||||
return new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
quantity * marketPrice,
|
||||
currency,
|
||||
this.request.user?.Settings?.settings.baseCurrency ??
|
||||
DEFAULT_CURRENCY
|
||||
)
|
||||
);
|
||||
})
|
||||
).toNumber();
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
publicPortfolioResponse.holdings[symbol] = {
|
||||
allocationInPercentage:
|
||||
portfolioPosition.valueInBaseCurrency / totalValue,
|
||||
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
dataSource: portfolioPosition.dataSource,
|
||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
name: portfolioPosition.name,
|
||||
netPerformancePercentWithCurrencyEffect:
|
||||
portfolioPosition.netPerformancePercentWithCurrencyEffect,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
symbol: portfolioPosition.symbol,
|
||||
url: portfolioPosition.url,
|
||||
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
||||
};
|
||||
}
|
||||
|
||||
return publicPortfolioResponse;
|
||||
}
|
||||
}
|
49
apps/api/src/app/endpoints/public/public.module.ts
Normal file
49
apps/api/src/app/endpoints/public/public.module.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PublicController } from './public.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [PublicController],
|
||||
imports: [
|
||||
AccessModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PortfolioSnapshotQueueModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AccountBalanceService,
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
PortfolioCalculatorFactory,
|
||||
PortfolioService,
|
||||
RulesService
|
||||
]
|
||||
})
|
||||
export class PublicModule {}
|
10
apps/api/src/app/endpoints/tags/create-tag.dto.ts
Normal file
10
apps/api/src/app/endpoints/tags/create-tag.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateTagDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
}
|
115
apps/api/src/app/endpoints/tags/tags.controller.ts
Normal file
115
apps/api/src/app/endpoints/tags/tags.controller.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Tag } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateTagDto } from './create-tag.dto';
|
||||
import { UpdateTagDto } from './update-tag.dto';
|
||||
|
||||
@Controller('tags')
|
||||
export class TagsController {
|
||||
public constructor(
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly tagService: TagService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
|
||||
const canCreateOwnTag = hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createOwnTag
|
||||
);
|
||||
|
||||
const canCreateTag = hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createTag
|
||||
);
|
||||
|
||||
if (!canCreateOwnTag && !canCreateTag) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
if (canCreateOwnTag && !canCreateTag) {
|
||||
if (data.userId !== this.request.user.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.tagService.createTag(data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HasPermission(permissions.deleteTag)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteTag(@Param('id') id: string) {
|
||||
const originalTag = await this.tagService.getTag({
|
||||
id
|
||||
});
|
||||
|
||||
if (!originalTag) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.tagService.deleteTag({ id });
|
||||
}
|
||||
|
||||
@Get()
|
||||
@HasPermission(permissions.readTags)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getTags() {
|
||||
return this.tagService.getTagsWithActivityCount();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateTag)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
|
||||
const originalTag = await this.tagService.getTag({
|
||||
id
|
||||
});
|
||||
|
||||
if (!originalTag) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.tagService.updateTag({
|
||||
data: {
|
||||
...data
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
12
apps/api/src/app/endpoints/tags/tags.module.ts
Normal file
12
apps/api/src/app/endpoints/tags/tags.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TagsController } from './tags.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
imports: [PrismaModule, TagModule]
|
||||
})
|
||||
export class TagsModule {}
|
13
apps/api/src/app/endpoints/tags/update-tag.dto.ts
Normal file
13
apps/api/src/app/endpoints/tags/update-tag.dto.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateTagDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -7,6 +9,7 @@ import {
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ExchangeRateService } from './exchange-rate.service';
|
||||
@ -18,12 +21,12 @@ export class ExchangeRateController {
|
||||
) {}
|
||||
|
||||
@Get(':symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getExchangeRate(
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<IDataProviderHistoricalResponse> {
|
||||
const date = new Date(dateString);
|
||||
const date = parseISO(dateString);
|
||||
|
||||
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
||||
date,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExchangeRateController } from './exchange-rate.controller';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -9,17 +12,29 @@ import { ExportService } from './export.service';
|
||||
@Controller('export')
|
||||
export class ExportController {
|
||||
public constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly exportService: ExportService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async export(
|
||||
@Query('activityIds') activityIds?: string[]
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('activityIds') activityIds?: string[],
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Export> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
return this.exportService.export({
|
||||
activityIds,
|
||||
filters,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
@ -1,21 +1,15 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
imports: [AccountModule, ApiModule, OrderModule, TagModule],
|
||||
controllers: [ExportController],
|
||||
providers: [ExportService]
|
||||
})
|
||||
|
@ -1,49 +1,78 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import { Filter, Export } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Platform } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly tagService: TagService
|
||||
) {}
|
||||
|
||||
public async export({
|
||||
activityIds,
|
||||
filters,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activityIds?: string[];
|
||||
filters?: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
const accounts = await this.prismaService.account.findMany({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
select: {
|
||||
accountType: true,
|
||||
balance: true,
|
||||
currency: true,
|
||||
id: true,
|
||||
isExcluded: true,
|
||||
name: true,
|
||||
platformId: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
const platformsMap: { [platformId: string]: Platform } = {};
|
||||
|
||||
let activities = await this.prismaService.order.findMany({
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
accountId: true,
|
||||
comment: true,
|
||||
date: true,
|
||||
fee: true,
|
||||
id: true,
|
||||
quantity: true,
|
||||
SymbolProfile: true,
|
||||
type: true,
|
||||
unitPrice: true
|
||||
},
|
||||
where: { userId }
|
||||
const accounts = (
|
||||
await this.accountService.accounts({
|
||||
include: {
|
||||
Platform: true
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
where: { userId }
|
||||
})
|
||||
).map(
|
||||
({
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
Platform: platform,
|
||||
platformId
|
||||
}) => {
|
||||
if (platformId) {
|
||||
platformsMap[platformId] = platform;
|
||||
}
|
||||
|
||||
return {
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
let { activities } = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
includeDrafts: true,
|
||||
sortColumn: 'date',
|
||||
sortDirection: 'asc',
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
@ -52,9 +81,22 @@ export class ExportService {
|
||||
});
|
||||
}
|
||||
|
||||
const tags = (await this.tagService.getTagsForUser(userId))
|
||||
.filter(({ isUsed }) => {
|
||||
return isUsed;
|
||||
})
|
||||
.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
meta: { date: new Date().toISOString(), version: environment.version },
|
||||
accounts,
|
||||
platforms: Object.values(platformsMap),
|
||||
tags,
|
||||
activities: activities.map(
|
||||
({
|
||||
accountId,
|
||||
@ -64,6 +106,7 @@ export class ExportService {
|
||||
id,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
tags: currentTags,
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
@ -78,10 +121,18 @@ export class ExportService {
|
||||
currency: SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
date: date.toISOString(),
|
||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
||||
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)
|
||||
? SymbolProfile.name
|
||||
: SymbolProfile.symbol,
|
||||
tags: currentTags.map(({ id: tagId }) => {
|
||||
return tagId;
|
||||
})
|
||||
};
|
||||
}
|
||||
)
|
||||
),
|
||||
user: {
|
||||
settings: { currency: userCurrency }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,205 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlDe = '';
|
||||
public indexHtmlEn = '';
|
||||
public indexHtmlEs = '';
|
||||
public indexHtmlFr = '';
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public indexHtmlPt = '';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
try {
|
||||
this.indexHtmlDe = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('de'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEn = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEs = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('es'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlFr = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('fr'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlIt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('it'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlNl = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('nl'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlPt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('pt'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public use(request: Request, response: Response, next: NextFunction) {
|
||||
const currentDate = format(new Date(), DATE_FORMAT);
|
||||
let featureGraphicPath = 'assets/cover.png';
|
||||
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||
|
||||
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||
title = `500 Stars - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||
title = `Hacktoberfest 2022 - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
|
||||
title = `Black Friday 2022 - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
||||
title = `The importance of tracking your personal finances - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
||||
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
||||
title = `Ghostfolio meets Umbrel - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
|
||||
title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`;
|
||||
}
|
||||
|
||||
if (
|
||||
request.path.startsWith('/api/') ||
|
||||
this.isFileRequest(request.url) ||
|
||||
!environment.production
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlDe, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'de',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEs, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'es',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlFr, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'fr',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlIt, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'it',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlNl, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'nl',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlPt, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'pt',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEn, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getPathOfIndexHtmlFile(aLocale: string) {
|
||||
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
|
||||
}
|
||||
|
||||
private interpolate(template: string, context: any) {
|
||||
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
|
||||
const properties = objectPath.split('.');
|
||||
return properties.reduce(
|
||||
(previous, current) => previous?.[current],
|
||||
context
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private isFileRequest(filename: string) {
|
||||
if (filename === '/assets/LICENSE') {
|
||||
return true;
|
||||
} else if (filename.includes('auth/ey')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filename.split('.').pop() !== filename;
|
||||
}
|
||||
}
|
74
apps/api/src/app/health/health.controller.ts
Normal file
74
apps/api/src/app/health/health.controller.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Res,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
public constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
public async getHealth(@Res() response: Response) {
|
||||
const databaseServiceHealthy = await this.healthService.isDatabaseHealthy();
|
||||
const redisCacheServiceHealthy =
|
||||
await this.healthService.isRedisCacheHealthy();
|
||||
|
||||
if (databaseServiceHealthy && redisCacheServiceHealthy) {
|
||||
return response
|
||||
.status(HttpStatus.OK)
|
||||
.json({ status: getReasonPhrase(StatusCodes.OK) });
|
||||
} else {
|
||||
return response
|
||||
.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
|
||||
}
|
||||
}
|
||||
|
||||
@Get('data-enhancer/:name')
|
||||
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataEnhancer(name);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('data-provider/:dataSource')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getHealthOfDataProvider(
|
||||
@Param('dataSource') dataSource: DataSource
|
||||
) {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataProvider(dataSource);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
23
apps/api/src/app/health/health.module.ts
Normal file
23
apps/api/src/app/health/health.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
imports: [
|
||||
DataEnhancerModule,
|
||||
DataProviderModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
providers: [HealthService]
|
||||
})
|
||||
export class HealthModule {}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user