Compare commits
1160 Commits
Author | SHA1 | Date | |
---|---|---|---|
325556188f | |||
8b9b1c7f11 | |||
2f9023dadd | |||
a3c4f84822 | |||
f501c7aeae | |||
aca2fe1654 | |||
dcf40367c1 | |||
dcedaa4d59 | |||
28b11b979d | |||
7a97ec75f4 | |||
91a6d14e24 | |||
d9fb159c6a | |||
7b88f7978f | |||
bd43ea9b6c | |||
655601c6a3 | |||
e28ab59673 | |||
c8291578b8 | |||
57abe53daf | |||
0b26f2a624 | |||
e8c1185812 | |||
c197622880 | |||
3dc287cfd7 | |||
9b4730b731 | |||
c437bc2534 | |||
433f1686e3 | |||
842a564bb5 | |||
559cac31bd | |||
77d3121f0d | |||
f936c69a7f | |||
3fcd611e29 | |||
4983b7ca12 | |||
a45ad41459 | |||
854a08c7a5 | |||
1ec81d352b | |||
5dfe9b54fe | |||
b34ef31812 | |||
bd04b4a60a | |||
3de2c9a81c | |||
351fd8a1d4 | |||
a919f3bb3b | |||
36741dedf0 | |||
f0eb0dc7fc | |||
ded0385da7 | |||
0fca29199d | |||
2fa16480a5 | |||
c40993ce33 | |||
a7a33eeb56 | |||
a0c5958f81 | |||
d609b75254 | |||
197aea1bec | |||
bad3f87957 | |||
84c4d79a74 | |||
960b871aa5 | |||
24f1aeb4c6 | |||
0334eabdad | |||
d1351b4665 | |||
99cab25fe0 | |||
738f7490f6 | |||
b6854f30e1 | |||
be3a9b8e83 | |||
f33d902560 | |||
7619772bf6 | |||
9bd7f795ac | |||
4964a8675b | |||
39fbb3a27c | |||
3c427a0005 | |||
f5aae11a4c | |||
69371bd42f | |||
38ae224ee1 | |||
ee7bb911d7 | |||
dca821e75c | |||
56128d8fe8 | |||
e45b1e9b1f | |||
c8f46f64fb | |||
1f50a9bf7b | |||
314b121584 | |||
ff447b6de6 | |||
29ecfc1137 | |||
029504d843 | |||
d5d74eb4db | |||
14b213c571 | |||
463dc3b295 | |||
28d9bb81be | |||
26cb9ca11a | |||
3aae0aa40c | |||
f541832666 | |||
d858aa53ec | |||
f351212600 | |||
7a251ef749 | |||
0b143088a2 | |||
6e3cf1460e | |||
46aa2ebc7a | |||
d8626ffab7 | |||
a31f71d7ee | |||
b92eff5b72 | |||
6f4e0f11cf | |||
a56016ee03 | |||
33ef272243 | |||
78d8a1c8d4 | |||
b0e1065f79 | |||
153b162927 | |||
32919f96a9 | |||
5fde161eed | |||
18f94a1ba1 | |||
a7319844fd | |||
8ae91961d9 | |||
0cdad51226 | |||
b63a57cf9e | |||
ec5b2693c0 | |||
da1a6445b1 | |||
ed649fe37e | |||
3ea72af5a0 | |||
ab00ebee1e | |||
ca8525a90c | |||
ffd6f51289 | |||
4b556dfdc4 | |||
c4cde4eef6 | |||
072d700b82 | |||
908a731989 | |||
60c9b5300d | |||
74b51fe1dd | |||
1400660664 | |||
38756b853d | |||
853a3b12ad | |||
c67e351ccc | |||
1674944596 | |||
c453a8cd07 | |||
2f7425d0a2 | |||
8d4a21c096 | |||
fb3f7517e2 | |||
279757fab3 | |||
b0cf5f7f80 | |||
be3be20604 | |||
5af53eb97e | |||
8cedd91561 | |||
9c4fe5fa6b | |||
9c27c1772f | |||
d84e28e058 | |||
54249c708b | |||
66ef2c2fec | |||
72a3b8709b | |||
3b54fcac60 | |||
ac4536abb5 | |||
1a72ef40c2 | |||
b80c186c97 | |||
20070d76bc | |||
17ef147da7 | |||
076ac1a32f | |||
4e985e4796 | |||
c6f25e0fb6 | |||
f3a598a58e | |||
8d3e12c646 | |||
a541ccee1b | |||
78e81ec36a | |||
ef64292cd6 | |||
0f537adf3e | |||
8bc6b51405 | |||
8e3480c0ce | |||
aba42307da | |||
4993d81c80 | |||
ced7f1f206 | |||
d44eba9617 | |||
f9aea95a73 | |||
6056cf9a6a | |||
d77295f64c | |||
376b1416bb | |||
2e377044ed | |||
0d3a99dd39 | |||
c604268507 | |||
3af558c580 | |||
85aa323e84 | |||
c42ffcb2a1 | |||
fec223070f | |||
766d792b10 | |||
a40c726843 | |||
ffce1c7208 | |||
7a0b82b5f0 | |||
fa99876dca | |||
25a755d1bf | |||
71d56ad789 | |||
edba53be29 | |||
9b233da4bf | |||
3bcb19af14 | |||
21baabc7f2 | |||
66e430ab9a | |||
042112faa6 | |||
b3ec353074 | |||
19b8300a4d | |||
1d29005736 | |||
e38ce2852c | |||
31f5c0de88 | |||
840805f9df | |||
e8f185e484 | |||
1dd3857618 | |||
c318e0019e | |||
b5abdaae07 | |||
f87dc437fd | |||
e2ec635bbf | |||
1f01668a52 | |||
7d2bb2116f | |||
fde8ff4bb6 | |||
8cee03f16a | |||
5fdd27eca0 | |||
85af3db0d8 | |||
ccee5b1337 | |||
9366aea9d5 | |||
5c617f761a | |||
de56a35197 | |||
3efe09d3eb | |||
a11fef5e23 | |||
15deed3032 | |||
8b8d194383 | |||
c59d2701f0 | |||
90f2e8101c | |||
7342e7e5ec | |||
ef8bbf1ae8 | |||
9757df859f | |||
32baf1946c | |||
b66e60e9f4 | |||
cb7326edb3 | |||
cb7434a8b2 | |||
cf76f54c1c | |||
fda9cc71f7 | |||
c553fdf6d4 | |||
03b04ac7f0 | |||
c6e38cd4ac | |||
daedfd6ad6 | |||
3bfbb31191 | |||
824985b464 | |||
62e304490d | |||
5fa97a9a40 | |||
5db5fd903e | |||
9e74eec04d | |||
9f3079716b | |||
bd2a8e2444 | |||
a8e48be9da | |||
bbb55dbec9 | |||
0ab7b98077 | |||
316d118111 | |||
6c1273dc8a | |||
a594b58d61 | |||
59abe473f0 | |||
8c2f8033d7 | |||
7e1f4fd11f | |||
9122b674ca | |||
f20791d5ad | |||
502f1c9244 | |||
1f2d2f8d8a | |||
ffe1021d69 | |||
afb35f7036 | |||
13b544c67d | |||
b8d356a949 | |||
692147f629 | |||
9a75e7d257 | |||
d96a346b70 | |||
1a247d6e97 | |||
90385157d7 | |||
a8a31c141d | |||
a1786c95a1 | |||
6bdf7b185f | |||
b5bd2bd997 | |||
71b8121b48 | |||
df11615b2f | |||
7c378d88af | |||
4bffb3107d | |||
8f3fecc5aa | |||
ba6503636e | |||
6e67520b68 | |||
0ea588315a | |||
c33aa82bd7 | |||
3d94b1a873 | |||
11ca51024a | |||
bd2d05b143 | |||
d4666f778d | |||
243ef2206c | |||
1cfc691a3e | |||
fe5d6f702b | |||
24160366b9 | |||
eed9d157f0 | |||
a06872b657 | |||
f4ef91e3be | |||
f361ecc732 | |||
294a1834b6 | |||
f63ede46b0 | |||
3da83fc42b | |||
70a4697f54 | |||
d395b195ff | |||
c2c628e77c | |||
a0f377e8eb | |||
85d07c27b6 | |||
57c43e5815 | |||
273ec92316 | |||
dcc016633e | |||
af79888cd6 | |||
2a934a75ec | |||
698d71fb3a | |||
e7bfcabac2 | |||
ccbf958aa6 | |||
8abc039fac | |||
1697b7e1e0 | |||
8358928aaf | |||
895a6214c7 | |||
e97757631b | |||
722da69987 | |||
a8937fbf04 | |||
e045811232 | |||
365318e6e0 | |||
66eb7adbdb | |||
6c322522d9 | |||
0b7fc7a3b2 | |||
4adc9dc9b1 | |||
7aab1c08b0 | |||
7bf87352c9 | |||
2932d57c1e | |||
3fe5a762eb | |||
12ebce03af | |||
755d85a54b | |||
d99217a434 | |||
480709c32a | |||
b6e8431c53 | |||
11629ffd26 | |||
037d3b1a60 | |||
7af153a161 | |||
72ccf47526 | |||
c81d3ced75 | |||
1215803a40 | |||
92fbf33032 | |||
aadd9f56a0 | |||
4237ded93c | |||
8e76bd82eb | |||
c393aeeb9a | |||
828bd5f172 | |||
edbcd0e225 | |||
03e27dd233 | |||
096699d83b | |||
19eff6e814 | |||
67db1b0de4 | |||
c38dab5ab0 | |||
40d3eaa023 | |||
3ec2460bfe | |||
aaf507b276 | |||
307621e103 | |||
4594f7339b | |||
6e9622a2cf | |||
28d2fd3877 | |||
5b6447b60d | |||
d661cdc78f | |||
ecffb53f07 | |||
b93671c740 | |||
620ae023d9 | |||
308dfaa58d | |||
e4073608e5 | |||
3646fb7f77 | |||
1bced96460 | |||
3e963228d6 | |||
c3887e2f8e | |||
72188374ca | |||
f70d71d5bd | |||
770b322137 | |||
6bb85c4fb8 | |||
e314efb2e1 | |||
575615b972 | |||
2306bc597b | |||
ca992db14e | |||
8fbdcac66c | |||
8df9667979 | |||
73f009e43b | |||
d9d8eadbd3 | |||
afac9daeab | |||
d919622932 | |||
a5fe259761 | |||
db7d45ecb9 | |||
82cf4afd7d | |||
b90bfc3d6e | |||
1b5a65d391 | |||
34f191ef7a | |||
fe1df8095a | |||
7d0af34034 | |||
398833a0e3 | |||
c671ea4022 | |||
909d56ab10 | |||
456327d199 | |||
f209519d95 | |||
c34996fdd6 | |||
a5e13d30ae | |||
b2dcb1e7ac | |||
2774dd7b2e | |||
862de91e7b | |||
07fa345457 | |||
68fb5c8b66 | |||
d2452791cc | |||
e86801dfe9 | |||
7fb0f9b6e8 | |||
28dd26be97 | |||
3b59d7989a | |||
3023d79311 | |||
447fe1806f | |||
9f5cc6a4cb | |||
4c63e08e3c | |||
8dcf04019d | |||
ac37974fd6 | |||
50e7e3d3c7 | |||
10580e22d1 | |||
53a81b3c2b | |||
56fcafaa12 | |||
dfa940c1b4 | |||
d9acd3ace9 | |||
e67ccea673 | |||
fa2739bbb4 | |||
416fa44cf0 | |||
b77afed38c | |||
ad29221e79 | |||
71fc3906c7 | |||
26b705cfea | |||
3091c3f080 | |||
d6e0b499d9 | |||
1ae5ba7f8a | |||
e8e499be26 | |||
de6c416639 | |||
a41032d0af | |||
b287257a29 | |||
6b2966b7dc | |||
c16352b76c | |||
255c3ec5f4 | |||
e498746c4c | |||
8163966212 | |||
1b45ce8619 | |||
f3022ca1f4 | |||
b6f87e46a7 | |||
f29f201a4f | |||
7cebfbc9c2 | |||
db38806812 | |||
13643f578a | |||
edf03d1cd6 | |||
3361666f63 | |||
53122b09ab | |||
073e60055d | |||
07ad7f2817 | |||
5e64de6650 | |||
d23dfadbd0 | |||
c6083ec7c9 | |||
535e9b654d | |||
94e53c7d4a | |||
5072ba09aa | |||
6122da3f14 | |||
cca1637aec | |||
f34fba97ba | |||
55791303a0 | |||
c86033a0b5 | |||
48a0a28d23 | |||
42734dff75 | |||
95325aad14 | |||
b2634db99f | |||
1856d40ad0 | |||
267dfc572a | |||
d1a4cb5037 | |||
e6b7073195 | |||
bb4e034300 | |||
6defc02c28 | |||
38e8284aef | |||
0f557f431e | |||
587c735288 | |||
2c07b7f058 | |||
2deca58928 | |||
ed07610602 | |||
ad47bedcb5 | |||
830720620f | |||
3ec0fe81b4 | |||
f7f057f2e8 | |||
57748a18ef | |||
d3ecbc0a96 | |||
b6fc5566d4 | |||
5754f01819 | |||
6ae22d13ec | |||
f1d292330a | |||
2894274b92 | |||
29739b27ee | |||
a216ba98b6 | |||
4df575e34f | |||
9c7778983d | |||
476b287ef3 | |||
c58b4c1cda | |||
1202d15522 | |||
8cd7679760 | |||
91394160b9 | |||
64cbd276ce | |||
b720a8dd96 | |||
ff563ddfea | |||
e46c25aaeb | |||
70d56c6c6c | |||
a09d3e5beb | |||
2f8767578c | |||
a13d6140cf | |||
31be526c11 | |||
f28c452be0 | |||
f08ed2dc68 | |||
c9e8eb401a | |||
f22c5ee16b | |||
44f6efd427 | |||
8920b60a72 | |||
7183db17b0 | |||
c467c946d4 | |||
a6952a0e37 | |||
5247cc3c97 | |||
4842c347a9 | |||
6c624fefc9 | |||
5ecdb5fb7a | |||
a9c3224856 | |||
b5db651ec8 | |||
198f73db00 | |||
536b000ff9 | |||
150d97bd42 | |||
d8b305a343 | |||
1aa1960d45 | |||
22cde840ea | |||
795e4582a8 | |||
ddc7989280 | |||
82fe2590bf | |||
ee361bf669 | |||
efc0b1bf5a | |||
235db72ade | |||
170b10dbde | |||
4db8c007f0 | |||
51d55f74e9 | |||
6036547cf5 | |||
f17a95eb48 | |||
6cd79fab31 | |||
5783497a66 | |||
29b8d63951 | |||
3efe06409e | |||
0cc3674780 | |||
755ab15755 | |||
b90e3076d0 | |||
6319661b0c | |||
2f868b8902 | |||
1917c17cf9 | |||
9e44023f86 | |||
d8dc02fc4a | |||
a4c78739bb | |||
7535f3f2b4 | |||
f528c45c68 | |||
9a35f1a611 | |||
97037e9481 | |||
1eaea2618a | |||
a137bbbdca | |||
1cb33a407d | |||
9abc4af203 | |||
70cd1a89b5 | |||
27f5536f23 | |||
feea2ced82 | |||
4368beebff | |||
7ca0720244 | |||
6bdfd8984f | |||
52081d6741 | |||
cb1f488eb4 | |||
25d0c1c8a0 | |||
443ff55295 | |||
d346f6b9fa | |||
bdde9dd314 | |||
f7185676b0 | |||
9438931849 | |||
cd2b26c1c2 | |||
ff73e7e0ee | |||
9278adc6ba | |||
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 |
1
.env.dev
1
.env.dev
@ -22,4 +22,3 @@ JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
# 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
|
||||
|
||||
# CACHE
|
||||
REDIS_HOST=localhost
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||
|
||||
@ -12,5 +12,5 @@ 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
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
|
124
.eslintrc.json
124
.eslintrc.json
@ -1,124 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": ["**/*"],
|
||||
"plugins": ["@nx"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {
|
||||
"@nx/enforce-module-boundaries": [
|
||||
"error",
|
||||
{
|
||||
"enforceBuildableLibDependency": true,
|
||||
"allow": [],
|
||||
"depConstraints": [
|
||||
{
|
||||
"sourceTag": "*",
|
||||
"onlyDependOnLibsWithTags": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"extends": ["plugin:@nx/typescript"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-extra-semi": "error",
|
||||
"no-extra-semi": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"extends": ["plugin:@nx/javascript"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-extra-semi": "error",
|
||||
"no-extra-semi": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"extends": [null, "plugin:storybook/recommended"]
|
||||
}
|
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -26,7 +26,7 @@ Thank you for your understanding and cooperation!
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected behavior**
|
||||
**Expected Behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
@ -48,6 +48,6 @@ Thank you for your understanding and cooperation!
|
||||
- Browser
|
||||
- OS
|
||||
|
||||
**Additional context**
|
||||
**Additional Context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
||||
|
5
.github/workflows/build-code.yml
vendored
5
.github/workflows/build-code.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 20
|
||||
- 22
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@ -29,6 +29,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check code style
|
||||
run: npm run lint
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
|
||||
|
48
.github/workflows/docker-image.yml
vendored
48
.github/workflows/docker-image.yml
vendored
@ -4,9 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_and_push:
|
||||
@ -15,14 +18,11 @@ jobs:
|
||||
- name: Checkout code
|
||||
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={{major}}
|
||||
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
|
||||
@ -35,16 +35,36 @@ 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
|
||||
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,5 +1,7 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
scripts/*
|
||||
|
||||
# compiled output
|
||||
/out-tsc
|
||||
/tmp
|
||||
@ -25,8 +27,10 @@ npm-debug.log
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.env
|
||||
.env.prod
|
||||
.github/instructions/nx.instructions.md
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
/.sass-cache
|
||||
|
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
|
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>",
|
||||
}
|
||||
|
1338
CHANGELOG.md
1338
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,59 @@
|
||||
# Ghostfolio Development Guide
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 22+)
|
||||
- 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
|
||||
|
||||
#### English (Default)
|
||||
|
||||
Run `npm run start:client` and open https://localhost:4200/en in your browser.
|
||||
|
||||
#### Other Languages
|
||||
|
||||
To start the client in a different language, such as German (`de`), adapt the `start:client` script in the `package.json` file by changing `--configuration=development-en` to `--configuration=development-de`. Then, run `npm run start:client` and open https://localhost:4200/de 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.
|
||||
@ -12,6 +66,10 @@ Remove permission in `UserService` using `without()`
|
||||
|
||||
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||
|
||||
## Component Library (_Storybook_)
|
||||
|
||||
https://ghostfol.io/development/storybook
|
||||
|
||||
## Git
|
||||
|
||||
### Rebase
|
||||
@ -53,3 +111,12 @@ https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||
Run `npm run prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||
|
||||
## SSL
|
||||
|
||||
Generate `localhost.cert` and `localhost.pem` files.
|
||||
|
||||
```
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -keyout apps/client/localhost.pem -out apps/client/localhost.cert -days 365 \
|
||||
-subj "/C=CH/ST=State/L=City/O=Organization/OU=Unit/CN=localhost"
|
||||
```
|
||||
|
41
Dockerfile
41
Dockerfile
@ -1,8 +1,16 @@
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-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
|
||||
@ -11,45 +19,39 @@ COPY ./package.json package.json
|
||||
COPY ./package-lock.json package-lock.json
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
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
|
||||
|
||||
ENV NX_DAEMON=false
|
||||
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
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||
|
||||
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
|
||||
# all the scripts
|
||||
COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN npm run database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:20-slim
|
||||
FROM node:22-slim
|
||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@ -58,9 +60,8 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||
RUN chown -R node:node /ghostfolio
|
||||
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}
|
||||
USER node
|
||||
|
170
README.md
170
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) | [**X**](https://x.com/ghostfolio_)
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**LinkedIn**](https://www.linkedin.com/company/ghostfolio) | [**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>
|
||||
|
||||
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
@ -85,23 +85,24 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| 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) |
|
||||
| `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 |
|
||||
| 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
|
||||
|
||||
@ -117,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
|
||||
@ -125,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
|
||||
@ -136,9 +137,19 @@ 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
|
||||
|
||||
- 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
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
@ -146,52 +157,7 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
||||
|
||||
## Development
|
||||
|
||||
### 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 --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 `npm run database:setup` to initialize the database schema
|
||||
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
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`
|
||||
For detailed information on the environment setup and development process, please refer to [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||
|
||||
## Public API
|
||||
|
||||
@ -207,8 +173,32 @@ You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anony
|
||||
|
||||
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`
|
||||
@ -264,6 +254,38 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||
@ -284,6 +306,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
|
||||
|
||||
## License
|
||||
|
||||
© 2021 - 2024 [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: {}
|
||||
}
|
||||
];
|
@ -37,20 +37,20 @@ export class AccessController {
|
||||
public async getAllAccesses(): Promise<Access[]> {
|
||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
granteeUser: true
|
||||
},
|
||||
orderBy: { granteeUserId: 'asc' },
|
||||
where: { userId: this.request.user.id }
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map(
|
||||
({ alias, GranteeUser, id, permissions }) => {
|
||||
if (GranteeUser) {
|
||||
({ alias, granteeUser, id, permissions }) => {
|
||||
if (granteeUser) {
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: GranteeUser?.id,
|
||||
grantee: granteeUser?.id,
|
||||
type: 'PRIVATE'
|
||||
};
|
||||
}
|
||||
@ -85,11 +85,11 @@ export class AccessController {
|
||||
try {
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
granteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
permissions: data.permissions,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
|
@ -13,7 +13,7 @@ export class AccessService {
|
||||
): Promise<AccessWithGranteeUser | null> {
|
||||
return this.prismaService.access.findFirst({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
granteeUser: true
|
||||
},
|
||||
where: accessWhereInput
|
||||
});
|
||||
|
@ -1,14 +1,19 @@
|
||||
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 { resetHours } from '@ghostfolio/common/helper';
|
||||
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
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 { parseISO } from 'date-fns';
|
||||
import { Big } from 'big.js';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||
|
||||
@ -25,7 +30,7 @@ export class AccountBalanceService {
|
||||
): Promise<AccountBalance | null> {
|
||||
return this.prismaService.accountBalance.findFirst({
|
||||
include: {
|
||||
Account: true
|
||||
account: true
|
||||
},
|
||||
where: accountBalanceWhereInput
|
||||
});
|
||||
@ -41,7 +46,7 @@ export class AccountBalanceService {
|
||||
}): Promise<AccountBalance> {
|
||||
const accountBalance = await this.prismaService.accountBalance.upsert({
|
||||
create: {
|
||||
Account: {
|
||||
account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
@ -83,23 +88,62 @@ export class AccountBalanceService {
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: <string>where.userId
|
||||
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,
|
||||
user,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
user: UserWithSettings;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<AccountBalancesResponse> {
|
||||
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
|
||||
const where: Prisma.AccountBalanceWhereInput = { userId };
|
||||
|
||||
const accountFilter = filters?.find(({ type }) => {
|
||||
return type === 'ACCOUNT';
|
||||
@ -110,7 +154,7 @@ export class AccountBalanceService {
|
||||
}
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.Account = { isExcluded: false };
|
||||
where.account = { isExcluded: false };
|
||||
}
|
||||
|
||||
const balances = await this.prismaService.accountBalance.findMany({
|
||||
@ -119,7 +163,7 @@ export class AccountBalanceService {
|
||||
date: 'asc'
|
||||
},
|
||||
select: {
|
||||
Account: true,
|
||||
account: true,
|
||||
date: true,
|
||||
id: true,
|
||||
value: true
|
||||
@ -130,10 +174,11 @@ export class AccountBalanceService {
|
||||
balances: balances.map((balance) => {
|
||||
return {
|
||||
...balance,
|
||||
accountId: balance.account.id,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
balance.value,
|
||||
balance.Account.currency,
|
||||
user.Settings.settings.baseCurrency
|
||||
balance.account.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
})
|
||||
|
@ -3,11 +3,13 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic
|
||||
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 {
|
||||
AccountBalancesResponse,
|
||||
Accounts
|
||||
AccountsResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -26,6 +28,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
@ -44,6 +47,7 @@ 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
|
||||
@ -53,44 +57,50 @@ export class AccountController {
|
||||
@HasPermission(permissions.deleteAccount)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||
const account = await this.accountService.accountWithOrders(
|
||||
const account = await this.accountService.accountWithActivities(
|
||||
{
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
},
|
||||
{ Order: true }
|
||||
{ activities: true }
|
||||
);
|
||||
|
||||
if (!account || account?.Order.length > 0) {
|
||||
if (!account || account?.activities.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'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||
): Promise<Accounts> {
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string
|
||||
): Promise<AccountsResponse> {
|
||||
const impersonationUserId =
|
||||
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
|
||||
});
|
||||
@ -100,7 +110,7 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('id') id: string
|
||||
): Promise<AccountWithValue> {
|
||||
const impersonationUserId =
|
||||
@ -124,7 +134,8 @@ export class AccountController {
|
||||
): Promise<AccountBalancesResponse> {
|
||||
return this.accountBalanceService.getAccountBalances({
|
||||
filters: [{ id, type: 'ACCOUNT' }],
|
||||
user: this.request.user
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@ -141,8 +152,8 @@ export class AccountController {
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
platform: { connect: { id: platformId } },
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
@ -152,7 +163,7 @@ export class AccountController {
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
@ -239,8 +250,8 @@ export class AccountController {
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
platform: { connect: { id: platformId } },
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
@ -259,10 +270,10 @@ export class AccountController {
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: originalAccount.platformId
|
||||
platform: originalAccount.platformId
|
||||
? { disconnect: true }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.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';
|
||||
@ -16,6 +17,7 @@ import { AccountService } from './account.service';
|
||||
exports: [AccountService],
|
||||
imports: [
|
||||
AccountBalanceModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
|
@ -7,7 +7,13 @@ 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 {
|
||||
Account,
|
||||
AccountBalance,
|
||||
Order,
|
||||
Platform,
|
||||
Prisma
|
||||
} from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
@ -33,12 +39,12 @@ export class AccountService {
|
||||
return account;
|
||||
}
|
||||
|
||||
public async accountWithOrders(
|
||||
public async accountWithActivities(
|
||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
|
||||
accountInclude: Prisma.AccountInclude
|
||||
): Promise<
|
||||
Account & {
|
||||
Order?: Order[];
|
||||
activities?: Order[];
|
||||
}
|
||||
> {
|
||||
return this.prismaService.account.findUnique({
|
||||
@ -56,13 +62,19 @@ export class AccountService {
|
||||
orderBy?: Prisma.AccountOrderByWithRelationInput;
|
||||
}): Promise<
|
||||
(Account & {
|
||||
Order?: Order[];
|
||||
Platform?: Platform;
|
||||
activities?: Order[];
|
||||
balances?: AccountBalance[];
|
||||
platform?: Platform;
|
||||
})[]
|
||||
> {
|
||||
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
include.balances = { orderBy: { date: 'desc' }, take: 1 };
|
||||
const isBalancesIncluded = !!include.balances;
|
||||
|
||||
include.balances = {
|
||||
orderBy: { date: 'desc' },
|
||||
...(isBalancesIncluded ? {} : { take: 1 })
|
||||
};
|
||||
|
||||
const accounts = await this.prismaService.account.findMany({
|
||||
cursor,
|
||||
@ -76,7 +88,9 @@ export class AccountService {
|
||||
return accounts.map((account) => {
|
||||
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
||||
|
||||
delete account.balances;
|
||||
if (!isBalancesIncluded) {
|
||||
delete account.balances;
|
||||
}
|
||||
|
||||
return account;
|
||||
});
|
||||
@ -108,8 +122,7 @@ export class AccountService {
|
||||
}
|
||||
|
||||
public async deleteAccount(
|
||||
where: Prisma.AccountWhereUniqueInput,
|
||||
aUserId: string
|
||||
where: Prisma.AccountWhereUniqueInput
|
||||
): Promise<Account> {
|
||||
const account = await this.prismaService.account.delete({
|
||||
where
|
||||
@ -127,7 +140,10 @@ export class AccountService {
|
||||
|
||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||
const accounts = await this.accounts({
|
||||
include: { Order: true, Platform: true },
|
||||
include: {
|
||||
activities: true,
|
||||
platform: true
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
});
|
||||
@ -135,15 +151,15 @@ export class AccountService {
|
||||
return accounts.map((account) => {
|
||||
let transactionCount = 0;
|
||||
|
||||
for (const order of account.Order) {
|
||||
if (!order.isDraft) {
|
||||
for (const { isDraft } of account.activities) {
|
||||
if (!isDraft) {
|
||||
transactionCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result = { ...account, transactionCount };
|
||||
|
||||
delete result.Order;
|
||||
delete result.activities;
|
||||
|
||||
return result;
|
||||
});
|
||||
@ -170,11 +186,7 @@ export class AccountService {
|
||||
where.isExcluded = false;
|
||||
}
|
||||
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, ({ type }) => {
|
||||
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
|
||||
return type;
|
||||
});
|
||||
|
||||
@ -214,8 +226,8 @@ export class AccountService {
|
||||
const { data, where } = params;
|
||||
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: <string>data.id,
|
||||
balance: <number>data.balance,
|
||||
accountId: data.id as string,
|
||||
balance: data.balance as number,
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
userId: aUserId
|
||||
});
|
||||
|
@ -36,6 +36,6 @@ export class CreateAccountDto {
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
@ValidateIf((_object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
}
|
||||
|
@ -35,6 +35,6 @@ export class UpdateAccountDto {
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
@ValidateIf((_object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
}
|
||||
|
@ -2,22 +2,23 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
|
||||
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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { DemoService } from '@ghostfolio/api/services/demo/demo.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
|
||||
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile
|
||||
AdminUsers,
|
||||
EnhancedSymbolProfile,
|
||||
ScraperConfiguration
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -49,8 +50,6 @@ 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 {
|
||||
@ -58,8 +57,8 @@ export class AdminController {
|
||||
private readonly adminService: AdminService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly demoService: DemoService,
|
||||
private readonly manualService: ManualService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@ -67,7 +66,14 @@ export class AdminController {
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getAdminData(): Promise<AdminData> {
|
||||
return this.adminService.get();
|
||||
return this.adminService.get({ user: this.request.user });
|
||||
}
|
||||
|
||||
@Get('demo-user/sync')
|
||||
@HasPermission(permissions.syncDemoUserAccount)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> {
|
||||
return this.demoService.syncDemoUserAccount();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@ -82,7 +88,7 @@ export class AdminController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherMax(): Promise<void> {
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
@ -91,9 +97,9 @@ export class AdminController {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
}
|
||||
@ -109,7 +115,7 @@ export class AdminController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
@ -118,9 +124,9 @@ export class AdminController {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
}
|
||||
@ -141,9 +147,9 @@ export class AdminController {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
}
|
||||
@ -213,89 +219,31 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
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 },
|
||||
@Body() data: { scraperConfiguration: ScraperConfiguration },
|
||||
@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);
|
||||
const price = await this.manualService.test(data.scraperConfiguration);
|
||||
|
||||
if (price) {
|
||||
return { price };
|
||||
}
|
||||
|
||||
throw new Error('Could not parse the current market price');
|
||||
throw new Error(
|
||||
`Could not parse the current market price for ${symbol} (${dataSource})`
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
Logger.error(error, 'AdminController');
|
||||
|
||||
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@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'), HasPermissionGuard)
|
||||
public async update(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string,
|
||||
@Body() data: UpdateMarketDataDto
|
||||
) {
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||
where: {
|
||||
dataSource_date_symbol: {
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@ -325,15 +273,14 @@ export class AdminController {
|
||||
@Patch('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async patchAssetProfileData(
|
||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||
@Body() assetProfile: UpdateAssetProfileDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<EnhancedSymbolProfile> {
|
||||
return this.adminService.patchAssetProfileData({
|
||||
...assetProfileData,
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
return this.adminService.patchAssetProfileData(
|
||||
{ dataSource, symbol },
|
||||
assetProfile
|
||||
);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@ -345,4 +292,17 @@ export class AdminController {
|
||||
) {
|
||||
return 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,15 +1,15 @@
|
||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.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 { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { DemoModule } from '@ghostfolio/api/services/demo/demo.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 { 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';
|
||||
@ -25,13 +25,13 @@ import { QueueModule } from './queue/queue.module';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
DemoModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
QueueModule,
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.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';
|
||||
@ -10,24 +9,34 @@ 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/symbol-profile.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||
} from '@ghostfolio/common/config';
|
||||
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
getAssetProfileIdentifier,
|
||||
getCurrencyFromSymbol,
|
||||
isCurrency
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
AdminUsers,
|
||||
AssetProfileIdentifier,
|
||||
EnhancedSymbolProfile,
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { MarketDataPreset, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
Logger
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
@ -38,6 +47,7 @@ import {
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
@ -51,7 +61,6 @@ export class AdminService {
|
||||
private readonly orderService: OrderService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
@ -103,39 +112,63 @@ export class AdminService {
|
||||
symbol
|
||||
}: AssetProfileIdentifier) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
|
||||
const currency = getCurrencyFromSymbol(symbol);
|
||||
const customCurrencies =
|
||||
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES);
|
||||
|
||||
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> {
|
||||
return {
|
||||
exchangeRates: this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
return currency !== DEFAULT_CURRENCY;
|
||||
})
|
||||
.map((currency) => {
|
||||
const label1 = DEFAULT_CURRENCY;
|
||||
const label2 = currency;
|
||||
public async get({ user }: { user: UserWithSettings }): Promise<AdminData> {
|
||||
const dataSources = await this.dataProviderService.getDataSources({
|
||||
user,
|
||||
includeGhostfolio: true
|
||||
});
|
||||
|
||||
return {
|
||||
label1,
|
||||
label2,
|
||||
dataSource:
|
||||
DataSource[
|
||||
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
||||
],
|
||||
symbol: `${label1}${label2}`,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
DEFAULT_CURRENCY,
|
||||
currency
|
||||
)
|
||||
};
|
||||
}),
|
||||
settings: await this.propertyService.get(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
users: await this.getUsersWithAnalytics(),
|
||||
const [settings, transactionCount, userCount] = await Promise.all([
|
||||
this.propertyService.get(),
|
||||
this.prismaService.order.count(),
|
||||
this.countUsersWithAnalytics()
|
||||
]);
|
||||
|
||||
const dataProviders = await Promise.all(
|
||||
dataSources.map(async (dataSource) => {
|
||||
const dataProviderInfo = this.dataProviderService
|
||||
.getDataProvider(dataSource)
|
||||
.getDataProviderInfo();
|
||||
|
||||
const assetProfileCount = await this.prismaService.symbolProfile.count({
|
||||
where: {
|
||||
dataSource
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...dataProviderInfo,
|
||||
assetProfileCount
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
dataProviders,
|
||||
settings,
|
||||
transactionCount,
|
||||
userCount,
|
||||
version: environment.version
|
||||
};
|
||||
}
|
||||
@ -211,7 +244,7 @@ export class AdminService {
|
||||
|
||||
if (sortColumn === 'activitiesCount') {
|
||||
orderBy = {
|
||||
Order: {
|
||||
activities: {
|
||||
_count: sortDirection
|
||||
}
|
||||
};
|
||||
@ -221,7 +254,7 @@ export class AdminService {
|
||||
const extendedPrismaClient = this.getExtendedPrismaClient();
|
||||
|
||||
try {
|
||||
let [assetProfiles, count] = await Promise.all([
|
||||
const symbolProfileResult = await Promise.all([
|
||||
extendedPrismaClient.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
@ -229,7 +262,15 @@ export class AdminService {
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
select: {
|
||||
activities: true,
|
||||
watchedBy: true
|
||||
}
|
||||
},
|
||||
activities: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
@ -238,25 +279,56 @@ export class AdminService {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
isActive: true,
|
||||
isUsedByUsersWithSubscription: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
symbol: 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;
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
activities,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
@ -264,15 +336,19 @@ export class AdminService {
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
isActive,
|
||||
isUsedByUsersWithSubscription,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
symbol
|
||||
symbol,
|
||||
SymbolProfileOverrides
|
||||
}) => {
|
||||
const countriesCount = countries
|
||||
? Object.keys(countries).length
|
||||
: 0;
|
||||
let countriesCount = countries ? Object.keys(countries).length : 0;
|
||||
|
||||
const lastMarketPrice = lastMarketPriceMap.get(
|
||||
getAssetProfileIdentifier({ dataSource, symbol })
|
||||
);
|
||||
|
||||
const marketDataItemCount =
|
||||
marketDataItems.find((marketDataItem) => {
|
||||
return (
|
||||
@ -280,7 +356,35 @@ export class AdminService {
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = sectors ? Object.keys(sectors).length : 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,
|
||||
@ -290,13 +394,17 @@ export class AdminService {
|
||||
countriesCount,
|
||||
dataSource,
|
||||
id,
|
||||
isActive,
|
||||
lastMarketPrice,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: _count.Order,
|
||||
date: Order?.[0]?.date,
|
||||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
||||
activitiesCount: _count.activities,
|
||||
date: activities?.[0]?.date,
|
||||
isUsedByUsersWithSubscription:
|
||||
await isUsedByUsersWithSubscription,
|
||||
watchedByCount: _count.watchedBy
|
||||
};
|
||||
}
|
||||
)
|
||||
@ -372,66 +480,147 @@ export class AdminService {
|
||||
currency,
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol
|
||||
symbol,
|
||||
isActive: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async patchAssetProfileData({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping,
|
||||
url
|
||||
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
|
||||
const symbolProfileOverrides = {
|
||||
assetClass: assetClass as AssetClass,
|
||||
assetSubClass: assetSubClass as AssetSubClass,
|
||||
name: name as string,
|
||||
url: url as string
|
||||
};
|
||||
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 })
|
||||
]);
|
||||
|
||||
const updatedSymbolProfile: AssetProfileIdentifier &
|
||||
Prisma.SymbolProfileUpdateInput = {
|
||||
return { count, users };
|
||||
}
|
||||
|
||||
public async patchAssetProfileData(
|
||||
{ dataSource, symbol }: AssetProfileIdentifier,
|
||||
{
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
dataSource: newDataSource,
|
||||
holdings,
|
||||
isActive,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbol: newSymbol,
|
||||
symbolMapping,
|
||||
...(dataSource === 'MANUAL'
|
||||
? { assetClass, assetSubClass, name, url }
|
||||
: {
|
||||
SymbolProfileOverrides: {
|
||||
upsert: {
|
||||
create: symbolProfileOverrides,
|
||||
update: symbolProfileOverrides
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
url
|
||||
}: Prisma.SymbolProfileUpdateInput
|
||||
) {
|
||||
if (
|
||||
newSymbol &&
|
||||
newDataSource &&
|
||||
(newSymbol !== symbol || newDataSource !== dataSource)
|
||||
) {
|
||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource: DataSource[newDataSource.toString()],
|
||||
symbol: newSymbol as string
|
||||
}
|
||||
]);
|
||||
|
||||
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
if (assetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.CONFLICT),
|
||||
StatusCodes.CONFLICT
|
||||
);
|
||||
}
|
||||
]);
|
||||
|
||||
return symbolProfile;
|
||||
try {
|
||||
Promise.all([
|
||||
await this.symbolProfileService.updateAssetProfileIdentifier(
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
{
|
||||
dataSource: DataSource[newDataSource.toString()],
|
||||
symbol: newSymbol as string
|
||||
}
|
||||
),
|
||||
await this.marketDataService.updateAssetProfileIdentifier(
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
{
|
||||
dataSource: DataSource[newDataSource.toString()],
|
||||
symbol: newSymbol as string
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
return this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource: DataSource[newDataSource.toString()],
|
||||
symbol: newSymbol as string
|
||||
}
|
||||
])?.[0];
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const symbolProfileOverrides = {
|
||||
assetClass: assetClass as AssetClass,
|
||||
assetSubClass: assetSubClass as AssetSubClass,
|
||||
name: name as string,
|
||||
url: url as string
|
||||
};
|
||||
|
||||
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
isActive,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping,
|
||||
...(dataSource === 'MANUAL'
|
||||
? { assetClass, assetSubClass, name, url }
|
||||
: {
|
||||
SymbolProfileOverrides: {
|
||||
upsert: {
|
||||
create: symbolProfileOverrides,
|
||||
update: symbolProfileOverrides
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
await this.symbolProfileService.updateSymbolProfile(
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
updatedSymbolProfile
|
||||
);
|
||||
|
||||
return this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource: dataSource as DataSource,
|
||||
symbol: symbol as string
|
||||
}
|
||||
])?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
public async putSetting(key: string, value: string) {
|
||||
@ -452,6 +641,22 @@ export class AdminService {
|
||||
return response;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@ -466,10 +671,10 @@ export class AdminService {
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
Order: {
|
||||
activities: {
|
||||
where: {
|
||||
User: {
|
||||
Subscription: {
|
||||
user: {
|
||||
subscriptions: {
|
||||
some: {
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
@ -487,7 +692,7 @@ export class AdminService {
|
||||
}
|
||||
});
|
||||
|
||||
return _count.Order > 0;
|
||||
return _count.activities > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -499,110 +704,165 @@ export class AdminService {
|
||||
}
|
||||
|
||||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
|
||||
|
||||
const marketDataPromise: Promise<AdminMarketDataItem>[] =
|
||||
this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.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 [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 marketDataItemCount =
|
||||
marketDataItems.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const lastMarketPriceMap = new Map<string, number>();
|
||||
|
||||
return {
|
||||
activitiesCount,
|
||||
currency,
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
assetClass: AssetClass.LIQUIDITY,
|
||||
assetSubClass: AssetSubClass.CASH,
|
||||
countriesCount: 0,
|
||||
date: dateOfFirstActivity,
|
||||
id: undefined,
|
||||
name: symbol,
|
||||
sectorsCount: 0
|
||||
};
|
||||
});
|
||||
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,
|
||||
isActive: true,
|
||||
name: symbol,
|
||||
sectorsCount: 0,
|
||||
watchedByCount: 0
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const marketData = await Promise.all(marketDataPromise);
|
||||
return { marketData, count: marketData.length };
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||
let orderBy: any = {
|
||||
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'
|
||||
analytics: {
|
||||
lastRequestAt: 'desc'
|
||||
}
|
||||
};
|
||||
where = {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Account: true, Order: true }
|
||||
select: { accounts: true, activities: true }
|
||||
},
|
||||
Analytics: {
|
||||
analytics: {
|
||||
select: {
|
||||
activityCount: true,
|
||||
country: true,
|
||||
dataProviderGhostfolioDailyRequests: true,
|
||||
updatedAt: true
|
||||
}
|
||||
},
|
||||
createdAt: true,
|
||||
id: true,
|
||||
role: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30
|
||||
subscriptions: {
|
||||
orderBy: {
|
||||
expiresAt: 'desc'
|
||||
},
|
||||
take: 1,
|
||||
where: {
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
||||
({ _count, analytics, createdAt, id, role, subscriptions }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics
|
||||
? Analytics.activityCount / daysSinceRegistration
|
||||
const engagement = analytics
|
||||
? analytics.activityCount / daysSinceRegistration
|
||||
: undefined;
|
||||
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.subscriptionService.getSubscription({
|
||||
createdAt,
|
||||
subscriptions: Subscription
|
||||
})
|
||||
: undefined;
|
||||
const subscription =
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
subscriptions?.length > 0
|
||||
? subscriptions[0]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
@ -610,10 +870,11 @@ export class AdminService {
|
||||
id,
|
||||
role,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
country: Analytics?.country,
|
||||
lastActivity: Analytics?.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
accountCount: _count.accounts || 0,
|
||||
activityCount: _count.activities || 0,
|
||||
country: analytics?.country,
|
||||
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
|
||||
lastActivity: analytics?.updatedAt
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -26,7 +26,7 @@ export class QueueController {
|
||||
public async deleteJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<void> {
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
|
||||
return this.queueService.deleteJobs({ status });
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ export class QueueController {
|
||||
public async getJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<AdminJobs> {
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
|
||||
return this.queueService.getJobs({ status });
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/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';
|
||||
|
||||
@ -7,7 +8,7 @@ import { QueueService } from './queue.service';
|
||||
|
||||
@Module({
|
||||
controllers: [QueueController],
|
||||
imports: [DataGatheringModule],
|
||||
imports: [DataGatheringModule, PortfolioSnapshotQueueModule],
|
||||
providers: [QueueService]
|
||||
})
|
||||
export class QueueModule {}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {
|
||||
DATA_GATHERING_QUEUE,
|
||||
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
|
||||
QUEUE_JOB_STATUS_LIST
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
@ -12,11 +13,19 @@ import { JobStatus, Queue } from 'bull';
|
||||
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({
|
||||
@ -25,15 +34,21 @@ export class QueueService {
|
||||
status?: JobStatus[];
|
||||
}) {
|
||||
for (const statusItem of status) {
|
||||
await this.dataGatheringQueue.clean(
|
||||
300,
|
||||
statusItem === 'waiting' ? 'wait' : statusItem
|
||||
);
|
||||
const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem;
|
||||
|
||||
await this.dataGatheringQueue.clean(300, queueStatus);
|
||||
await this.portfolioSnapshotQueue.clean(300, queueStatus);
|
||||
}
|
||||
}
|
||||
|
||||
public async executeJob(aId: string) {
|
||||
return (await this.dataGatheringQueue.getJob(aId))?.promote();
|
||||
let job = await this.dataGatheringQueue.getJob(aId);
|
||||
|
||||
if (!job) {
|
||||
job = await this.portfolioSnapshotQueue.getJob(aId);
|
||||
}
|
||||
|
||||
return job?.promote();
|
||||
}
|
||||
|
||||
public async getJobs({
|
||||
@ -43,10 +58,13 @@ 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
|
||||
[...dataGatheringJobs, ...portfolioSnapshotJobs]
|
||||
.filter((job) => {
|
||||
return job;
|
||||
})
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
@ -19,8 +20,8 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
comment?: string;
|
||||
|
||||
@IsArray()
|
||||
@ -31,8 +32,16 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@IsString()
|
||||
@IsEnum(DataSource)
|
||||
@IsOptional()
|
||||
dataSource?: DataSource;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsObject()
|
||||
@ -43,6 +52,10 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
sectors?: Prisma.InputJsonArray;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
symbol?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
symbolMapping?: {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray } from 'class-validator';
|
||||
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
|
@ -1,19 +1,21 @@
|
||||
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
||||
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { CronModule } from '@ghostfolio/api/services/cron/cron.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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.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 {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
SUPPORTED_LANGUAGE_CODES
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@ -28,8 +30,17 @@ 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 { AssetsModule } from './endpoints/assets/assets.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 { SitemapModule } from './endpoints/sitemap/sitemap.module';
|
||||
import { TagsModule } from './endpoints/tags/tags.module';
|
||||
import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
@ -40,10 +51,8 @@ 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 { TagModule } from './tag/tag.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@Module({
|
||||
@ -52,10 +61,13 @@ import { UserModule } from './user/user.module';
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
AccountModule,
|
||||
AiModule,
|
||||
ApiKeysModule,
|
||||
AssetModule,
|
||||
AssetsModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
BenchmarkModule,
|
||||
BenchmarksModule,
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
||||
@ -67,6 +79,7 @@ import { UserModule } from './user/user.module';
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
ConfigurationModule,
|
||||
CronModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
@ -74,19 +87,23 @@ import { UserModule } from './user/user.module';
|
||||
ExchangeRateModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
GhostfolioModule,
|
||||
HealthModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
LogoModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
PortfolioSnapshotQueueModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
PublicModule,
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
exclude: ['/api*', '/sitemap.xml'],
|
||||
exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
serveStaticOptions: {
|
||||
setHeaders: (res) => {
|
||||
@ -109,13 +126,21 @@ import { UserModule } from './user/user.module';
|
||||
}
|
||||
}
|
||||
}),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..', 'client', '.well-known'),
|
||||
serveRoot: '/.well-known'
|
||||
}),
|
||||
SitemapModule,
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
TagModule,
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
TagsModule,
|
||||
UserModule,
|
||||
WatchlistModule
|
||||
],
|
||||
providers: [CronService]
|
||||
providers: [I18nService]
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
public configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard');
|
||||
}
|
||||
}
|
||||
|
70
apps/api/src/app/auth/api-key.strategy.ts
Normal file
70
apps/api/src/app/auth/api-key.strategy.ts
Normal file
@ -0,0 +1,70 @@
|
||||
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 ' }, false);
|
||||
}
|
||||
|
||||
public async validate(apiKey: string) {
|
||||
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 }
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -14,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 {
|
||||
@ -85,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(
|
||||
@ -130,10 +130,7 @@ export class AuthController {
|
||||
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,6 +2,7 @@ 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 { 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';
|
||||
@ -9,6 +10,7 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
|
||||
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';
|
||||
@ -28,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
ApiKeyService,
|
||||
ApiKeyStrategy,
|
||||
AuthDeviceService,
|
||||
AuthService,
|
||||
GoogleStrategy,
|
||||
|
@ -20,10 +20,10 @@ export class AuthService {
|
||||
public async validateAnonymousLogin(accessToken: string): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const hashedAccessToken = this.userService.createAccessToken(
|
||||
accessToken,
|
||||
this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
);
|
||||
const hashedAccessToken = this.userService.createAccessToken({
|
||||
password: accessToken,
|
||||
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
});
|
||||
|
||||
const [user] = await this.userService.users({
|
||||
where: { accessToken: hashedAccessToken }
|
||||
|
@ -11,7 +11,7 @@ import { AuthService } from './auth.service';
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
public constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
configurationService: ConfigurationService
|
||||
) {
|
||||
super({
|
||||
callbackURL: `${configurationService.get(
|
||||
@ -25,12 +25,11 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
}
|
||||
|
||||
public async validate(
|
||||
request: any,
|
||||
token: string,
|
||||
refreshToken: string,
|
||||
_request: any,
|
||||
_token: string,
|
||||
_refreshToken: string,
|
||||
profile: Profile,
|
||||
done: Function,
|
||||
done2: Function
|
||||
done: Function
|
||||
) {
|
||||
try {
|
||||
const jwt = await this.authService.validateOAuthLogin({
|
||||
|
@ -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,7 +1,11 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.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 {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
HEADER_KEY_TIMEZONE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { hasRole } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
@ -42,16 +46,24 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { country, User: { connect: { id: user.id } } },
|
||||
create: { country, user: { connect: { id: user.id } } },
|
||||
update: {
|
||||
country,
|
||||
activityCount: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
lastRequestAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.Settings.settings.baseCurrency) {
|
||||
user.Settings.settings.baseCurrency = DEFAULT_CURRENCY;
|
||||
}
|
||||
|
||||
if (!user.Settings.settings.language) {
|
||||
user.Settings.settings.language = DEFAULT_LANGUAGE_CODE;
|
||||
}
|
||||
|
||||
return user;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
@ -60,7 +72,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
|
@ -13,17 +13,18 @@ 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 { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
|
||||
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
@ -54,10 +55,9 @@ export class WebAuthService {
|
||||
const opts: GenerateRegistrationOptionsOpts = {
|
||||
rpName: 'Ghostfolio',
|
||||
rpID: this.rpID,
|
||||
userID: user.id,
|
||||
userID: isoUint8Array.fromUTF8String(user.id),
|
||||
userName: '',
|
||||
timeout: 60000,
|
||||
attestationType: 'indirect',
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: 'platform',
|
||||
requireResidentKey: false,
|
||||
@ -80,7 +80,6 @@ export class WebAuthService {
|
||||
}
|
||||
|
||||
public async verifyAttestation(
|
||||
deviceName: string,
|
||||
credential: AttestationCredentialJSON
|
||||
): Promise<AuthDeviceDto> {
|
||||
const user = this.request.user;
|
||||
@ -112,11 +111,17 @@ export class WebAuthService {
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (registrationInfo && verified) {
|
||||
const { counter, credentialID, credentialPublicKey } = registrationInfo;
|
||||
const {
|
||||
credential: {
|
||||
counter,
|
||||
id: credentialId,
|
||||
publicKey: credentialPublicKey
|
||||
}
|
||||
} = registrationInfo;
|
||||
|
||||
let existingDevice = devices.find(
|
||||
(device) => device.credentialId === credentialID
|
||||
);
|
||||
let existingDevice = devices.find((device) => {
|
||||
return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
|
||||
});
|
||||
|
||||
if (!existingDevice) {
|
||||
/**
|
||||
@ -124,9 +129,9 @@ export class WebAuthService {
|
||||
*/
|
||||
existingDevice = await this.deviceService.createAuthDevice({
|
||||
counter,
|
||||
credentialId: Buffer.from(credentialID),
|
||||
credentialId: Buffer.from(credentialId),
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||
User: { connect: { id: user.id } }
|
||||
user: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
|
||||
@ -149,9 +154,8 @@ export class WebAuthService {
|
||||
const opts: GenerateAuthenticationOptionsOpts = {
|
||||
allowCredentials: [
|
||||
{
|
||||
id: device.credentialId,
|
||||
transports: ['internal'],
|
||||
type: 'public-key'
|
||||
id: isoBase64URL.fromBuffer(device.credentialId),
|
||||
transports: ['internal']
|
||||
}
|
||||
],
|
||||
rpID: this.rpID,
|
||||
@ -188,10 +192,10 @@ export class WebAuthService {
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
const opts: VerifyAuthenticationResponseOpts = {
|
||||
authenticator: {
|
||||
credentialID: device.credentialId,
|
||||
credentialPublicKey: device.credentialPublicKey,
|
||||
counter: device.counter
|
||||
credential: {
|
||||
counter: device.counter,
|
||||
id: isoBase64URL.fromBuffer(device.credentialId),
|
||||
publicKey: device.credentialPublicKey
|
||||
},
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
|
59
apps/api/src/app/endpoints/ai/ai.controller.ts
Normal file
59
apps/api/src/app/endpoints/ai/ai.controller.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
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,
|
||||
Query,
|
||||
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,
|
||||
private readonly apiService: ApiService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get('prompt/:mode')
|
||||
@HasPermission(permissions.readAiPrompt)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPrompt(
|
||||
@Param('mode') mode: AiPromptMode,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<AiPromptResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const prompt = await this.aiService.getPrompt({
|
||||
filters,
|
||||
mode,
|
||||
impersonationId: undefined,
|
||||
languageCode: this.request.user.Settings.settings.language,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { prompt };
|
||||
}
|
||||
}
|
59
apps/api/src/app/endpoints/ai/ai.module.ts
Normal file
59
apps/api/src/app/endpoints/ai/ai.module.ts
Normal file
@ -0,0 +1,59 @@
|
||||
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 { 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/exchange-rate-data.module';
|
||||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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 { AiController } from './ai.controller';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AiController],
|
||||
imports: [
|
||||
ApiModule,
|
||||
BenchmarkModule,
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PortfolioSnapshotQueueModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AccountBalanceService,
|
||||
AccountService,
|
||||
AiService,
|
||||
CurrentRateService,
|
||||
MarketDataService,
|
||||
PortfolioCalculatorFactory,
|
||||
PortfolioService,
|
||||
RulesService
|
||||
]
|
||||
})
|
||||
export class AiModule {}
|
100
apps/api/src/app/endpoints/ai/ai.service.ts
Normal file
100
apps/api/src/app/endpoints/ai/ai.service.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_API_KEY_OPENROUTER,
|
||||
PROPERTY_OPENROUTER_MODEL
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import type { AiPromptMode } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { generateText } from 'ai';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
public constructor(
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
|
||||
public async generateText({ prompt }: { prompt: string }) {
|
||||
const openRouterApiKey = await this.propertyService.getByKey<string>(
|
||||
PROPERTY_API_KEY_OPENROUTER
|
||||
);
|
||||
|
||||
const openRouterModel = await this.propertyService.getByKey<string>(
|
||||
PROPERTY_OPENROUTER_MODEL
|
||||
);
|
||||
|
||||
const openRouterService = createOpenRouter({
|
||||
apiKey: openRouterApiKey
|
||||
});
|
||||
|
||||
return generateText({
|
||||
prompt,
|
||||
model: openRouterService.chat(openRouterModel)
|
||||
});
|
||||
}
|
||||
|
||||
public async getPrompt({
|
||||
filters,
|
||||
impersonationId,
|
||||
languageCode,
|
||||
mode,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
languageCode: string;
|
||||
mode: AiPromptMode;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
filters,
|
||||
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 {}
|
46
apps/api/src/app/endpoints/assets/assets.controller.ts
Normal file
46
apps/api/src/app/endpoints/assets/assets.controller.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { interpolate } from '@ghostfolio/common/helper';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Res,
|
||||
Version,
|
||||
VERSION_NEUTRAL
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
@Controller('assets')
|
||||
export class AssetsController {
|
||||
private webManifest = '';
|
||||
|
||||
public constructor(
|
||||
public readonly configurationService: ConfigurationService
|
||||
) {
|
||||
try {
|
||||
this.webManifest = readFileSync(
|
||||
join(__dirname, 'assets', 'site.webmanifest'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@Get('/:languageCode/site.webmanifest')
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public getWebManifest(
|
||||
@Param('languageCode') languageCode: string,
|
||||
@Res() response: Response
|
||||
): void {
|
||||
const rootUrl = this.configurationService.get('ROOT_URL');
|
||||
const webManifest = interpolate(this.webManifest, {
|
||||
languageCode,
|
||||
rootUrl
|
||||
});
|
||||
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.send(webManifest);
|
||||
}
|
||||
}
|
11
apps/api/src/app/endpoints/assets/assets.module.ts
Normal file
11
apps/api/src/app/endpoints/assets/assets.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AssetsController } from './assets.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AssetsController],
|
||||
providers: [ConfigurationService]
|
||||
})
|
||||
export class AssetsModule {}
|
@ -1,8 +1,11 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
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,
|
||||
@ -16,6 +19,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
@ -29,12 +33,14 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
import { BenchmarksService } from './benchmarks.service';
|
||||
|
||||
@Controller('benchmark')
|
||||
export class BenchmarkController {
|
||||
@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
|
||||
) {}
|
||||
|
||||
@ -108,23 +114,43 @@ export class BenchmarkController {
|
||||
@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('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 } = getInterval(
|
||||
const { endDate, startDate } = getIntervalFromDateRange(
|
||||
dateRange,
|
||||
new Date(startDateString)
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
return this.benchmarkService.getMarketDataForUser({
|
||||
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,
|
||||
userCurrency
|
||||
withExcludedAccounts,
|
||||
user: this.request.user
|
||||
});
|
||||
}
|
||||
}
|
65
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
Normal file
65
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
Normal file
@ -0,0 +1,65 @@
|
||||
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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,
|
||||
I18nModule,
|
||||
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,241 @@
|
||||
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 {
|
||||
DataProviderGhostfolioAssetProfileResponse,
|
||||
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 { isISIN } from 'class-validator';
|
||||
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
|
||||
) {}
|
||||
|
||||
@Get('asset-profile/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||
public async getAssetProfile(
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<DataProviderGhostfolioAssetProfileResponse> {
|
||||
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 assetProfile = await this.ghostfolioService.getAssetProfile({
|
||||
symbol
|
||||
});
|
||||
|
||||
await this.ghostfolioService.incrementDailyRequests({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return assetProfile;
|
||||
} 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@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: isISIN(query.toUpperCase())
|
||||
? query.toUpperCase()
|
||||
: 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('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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@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,350 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
|
||||
import {
|
||||
GetAssetProfileParams,
|
||||
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 {
|
||||
DataProviderGhostfolioAssetProfileResponse,
|
||||
DataProviderInfo,
|
||||
DividendsResponse,
|
||||
HistoricalResponse,
|
||||
LookupItem,
|
||||
LookupResponse,
|
||||
QuotesResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } 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 getAssetProfile({
|
||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
||||
symbol
|
||||
}: GetAssetProfileParams) {
|
||||
let result: DataProviderGhostfolioAssetProfileResponse = {};
|
||||
|
||||
try {
|
||||
const promises: Promise<Partial<SymbolProfile>>[] = [];
|
||||
|
||||
for (const dataProviderService of this.getDataProviderServices()) {
|
||||
promises.push(
|
||||
dataProviderService
|
||||
.getAssetProfile({
|
||||
requestTimeout,
|
||||
symbol
|
||||
})
|
||||
.then((assetProfile) => {
|
||||
result = {
|
||||
...result,
|
||||
...assetProfile,
|
||||
dataSource: DataSource.GHOSTFOLIO
|
||||
};
|
||||
|
||||
return assetProfile;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GhostfolioService');
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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<string>(
|
||||
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
|
||||
)) || '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 {
|
||||
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
|
||||
this.configurationService,
|
||||
this.propertyService
|
||||
);
|
||||
|
||||
return {
|
||||
...ghostfolioDataProviderService.getDataProviderInfo(),
|
||||
isPremium: false,
|
||||
name: 'Ghostfolio Premium'
|
||||
};
|
||||
}
|
||||
|
||||
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 && !isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||
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;
|
||||
}
|
||||
}
|
53
apps/api/src/app/endpoints/public/public.module.ts
Normal file
53
apps/api/src/app/endpoints/public/public.module.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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,
|
||||
BenchmarkModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PortfolioSnapshotQueueModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AccountBalanceService,
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
PortfolioCalculatorFactory,
|
||||
PortfolioService,
|
||||
RulesService
|
||||
]
|
||||
})
|
||||
export class PublicModule {}
|
51
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
Normal file
51
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getYesterday,
|
||||
interpolate
|
||||
} from '@ghostfolio/common/helper';
|
||||
|
||||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { Response } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Controller('sitemap.xml')
|
||||
export class SitemapController {
|
||||
public sitemapXml = '';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly sitemapService: SitemapService
|
||||
) {
|
||||
try {
|
||||
this.sitemapXml = readFileSync(
|
||||
join(__dirname, 'assets', 'sitemap.xml'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public getSitemapXml(@Res() response: Response) {
|
||||
const currentDate = format(getYesterday(), DATE_FORMAT);
|
||||
|
||||
response.setHeader('content-type', 'application/xml');
|
||||
response.send(
|
||||
interpolate(this.sitemapXml, {
|
||||
personalFinanceTools: this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.sitemapService.getPersonalFinanceTools({ currentDate })
|
||||
: '',
|
||||
publicRoutes: this.sitemapService.getPublicRoutes({
|
||||
currentDate
|
||||
})
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SitemapController } from './sitemap.controller';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SitemapController],
|
||||
imports: [ConfigurationModule]
|
||||
imports: [ConfigurationModule, I18nModule],
|
||||
providers: [SitemapService]
|
||||
})
|
||||
export class SitemapModule {}
|
116
apps/api/src/app/endpoints/sitemap/sitemap.service.ts
Normal file
116
apps/api/src/app/endpoints/sitemap/sitemap.service.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config';
|
||||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface';
|
||||
import { publicRoutes } from '@ghostfolio/common/routes/routes';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX =
|
||||
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly i18nService: I18nService
|
||||
) {}
|
||||
|
||||
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) {
|
||||
const rootUrl = this.configurationService.get('ROOT_URL');
|
||||
|
||||
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
|
||||
return personalFinanceTools.map(({ alias, key }) => {
|
||||
const route =
|
||||
publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes
|
||||
.product;
|
||||
const params = {
|
||||
currentDate,
|
||||
languageCode,
|
||||
rootUrl,
|
||||
urlPostfix: alias ?? key
|
||||
};
|
||||
|
||||
return this.createRouteSitemapUrl({ ...params, route });
|
||||
});
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
public getPublicRoutes({ currentDate }: { currentDate: string }) {
|
||||
const rootUrl = this.configurationService.get('ROOT_URL');
|
||||
|
||||
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
|
||||
const params = {
|
||||
currentDate,
|
||||
languageCode,
|
||||
rootUrl
|
||||
};
|
||||
|
||||
return [
|
||||
this.createRouteSitemapUrl(params),
|
||||
...this.createSitemapUrls(params, publicRoutes)
|
||||
];
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
private createRouteSitemapUrl({
|
||||
currentDate,
|
||||
languageCode,
|
||||
rootUrl,
|
||||
route,
|
||||
urlPostfix
|
||||
}: {
|
||||
currentDate: string;
|
||||
languageCode: string;
|
||||
rootUrl: string;
|
||||
route?: PublicRoute;
|
||||
urlPostfix?: string;
|
||||
}): string {
|
||||
const segments =
|
||||
route?.routerLink.map((link) => {
|
||||
const match = link.match(
|
||||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
|
||||
);
|
||||
|
||||
const segment = match
|
||||
? (this.i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: match.groups.id
|
||||
}) ?? match.groups.message)
|
||||
: link;
|
||||
|
||||
return segment.replace(/^\/+|\/+$/, '');
|
||||
}) ?? [];
|
||||
|
||||
const location =
|
||||
[rootUrl, languageCode, ...segments].join('/') +
|
||||
(urlPostfix ? `-${urlPostfix}` : '');
|
||||
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${location}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
' </url>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private createSitemapUrls(
|
||||
params: { currentDate: string; languageCode: string; rootUrl: string },
|
||||
routes: Record<string, PublicRoute>
|
||||
): string[] {
|
||||
return Object.values(routes).flatMap((route) => {
|
||||
if (route.excludeFromSitemap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const urls = [this.createRouteSitemapUrl({ ...params, route })];
|
||||
|
||||
if (route.subRoutes) {
|
||||
urls.push(...this.createSitemapUrls(params, route.subRoutes));
|
||||
}
|
||||
|
||||
return urls;
|
||||
});
|
||||
}
|
||||
}
|
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;
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
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 { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
@ -8,36 +10,84 @@ import {
|
||||
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 { TagService } from './tag.service';
|
||||
import { UpdateTagDto } from './update-tag.dto';
|
||||
|
||||
@Controller('tag')
|
||||
export class TagController {
|
||||
public constructor(private readonly tagService: TagService) {}
|
||||
@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();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HasPermission(permissions.createTag)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
|
||||
return this.tagService.createTag(data);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateTag)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@ -62,22 +112,4 @@ export class TagController {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@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 });
|
||||
}
|
||||
}
|
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;
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { IsEnum, IsString } from 'class-validator';
|
||||
|
||||
export class CreateWatchlistItemDto {
|
||||
@IsEnum(DataSource)
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsString()
|
||||
symbol: string;
|
||||
}
|
100
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
Normal file
100
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
Normal file
@ -0,0 +1,100 @@
|
||||
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
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 { CreateWatchlistItemDto } from './create-watchlist-item.dto';
|
||||
import { WatchlistService } from './watchlist.service';
|
||||
|
||||
@Controller('watchlist')
|
||||
export class WatchlistController {
|
||||
public constructor(
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly watchlistService: WatchlistService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@HasPermission(permissions.createWatchlistItem)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) {
|
||||
return this.watchlistService.createWatchlistItem({
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':dataSource/:symbol')
|
||||
@HasPermission(permissions.deleteWatchlistItem)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async deleteWatchlistItem(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
const watchlistItems = await this.watchlistService.getWatchlistItems(
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
const watchlistItem = watchlistItems.find((item) => {
|
||||
return item.dataSource === dataSource && item.symbol === symbol;
|
||||
});
|
||||
|
||||
if (!watchlistItem) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return this.watchlistService.deleteWatchlistItem({
|
||||
dataSource,
|
||||
symbol,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@HasPermission(permissions.readWatchlist)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getWatchlistItems(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||
): Promise<WatchlistResponse> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
const watchlist = await this.watchlistService.getWatchlistItems(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
return {
|
||||
watchlist
|
||||
};
|
||||
}
|
||||
}
|
@ -1,34 +1,31 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { PropertyModule } from '@ghostfolio/api/services/property/property.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 { BenchmarkController } from './benchmark.controller';
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
import { WatchlistController } from './watchlist.controller';
|
||||
import { WatchlistService } from './watchlist.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BenchmarkController],
|
||||
exports: [BenchmarkService],
|
||||
controllers: [WatchlistController],
|
||||
imports: [
|
||||
BenchmarkModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
TransformDataSourceInResponseModule
|
||||
],
|
||||
providers: [BenchmarkService]
|
||||
providers: [WatchlistService]
|
||||
})
|
||||
export class BenchmarkModule {}
|
||||
export class WatchlistModule {}
|
150
apps/api/src/app/endpoints/watchlist/watchlist.service.ts
Normal file
150
apps/api/src/app/endpoints/watchlist/watchlist.service.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class WatchlistService {
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async createWatchlistItem({
|
||||
dataSource,
|
||||
symbol,
|
||||
userId
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
const symbolProfile = await this.prismaService.symbolProfile.findUnique({
|
||||
where: {
|
||||
dataSource_symbol: { dataSource, symbol }
|
||||
}
|
||||
});
|
||||
|
||||
if (!symbolProfile) {
|
||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfiles[symbol]?.currency) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile not found for ${symbol} (${dataSource})`
|
||||
);
|
||||
}
|
||||
|
||||
await this.symbolProfileService.add(
|
||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.gatherSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
await this.prismaService.user.update({
|
||||
data: {
|
||||
watchlist: {
|
||||
connect: {
|
||||
dataSource_symbol: { dataSource, symbol }
|
||||
}
|
||||
}
|
||||
},
|
||||
where: { id: userId }
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteWatchlistItem({
|
||||
dataSource,
|
||||
symbol,
|
||||
userId
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
userId: string;
|
||||
}) {
|
||||
await this.prismaService.user.update({
|
||||
data: {
|
||||
watchlist: {
|
||||
disconnect: {
|
||||
dataSource_symbol: { dataSource, symbol }
|
||||
}
|
||||
}
|
||||
},
|
||||
where: { id: userId }
|
||||
});
|
||||
}
|
||||
|
||||
public async getWatchlistItems(
|
||||
userId: string
|
||||
): Promise<WatchlistResponse['watchlist']> {
|
||||
const user = await this.prismaService.user.findUnique({
|
||||
select: {
|
||||
watchlist: {
|
||||
select: { dataSource: true, symbol: true }
|
||||
}
|
||||
},
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
const [assetProfiles, quotes] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles(user.watchlist),
|
||||
this.dataProviderService.getQuotes({
|
||||
items: user.watchlist.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
const watchlist = await Promise.all(
|
||||
user.watchlist.map(async ({ dataSource, symbol }) => {
|
||||
const assetProfile = assetProfiles.find((profile) => {
|
||||
return profile.dataSource === dataSource && profile.symbol === symbol;
|
||||
});
|
||||
|
||||
const allTimeHigh = await this.marketDataService.getMax({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
const performancePercent =
|
||||
this.benchmarkService.calculateChangeInPercentage(
|
||||
allTimeHigh?.marketPrice,
|
||||
quotes[symbol]?.marketPrice
|
||||
);
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
marketCondition:
|
||||
this.benchmarkService.getMarketCondition(performancePercent),
|
||||
name: assetProfile?.name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent,
|
||||
date: allTimeHigh?.date
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return watchlist.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,9 +1,17 @@
|
||||
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 { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@ -19,15 +27,21 @@ export class ExportController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async export(
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('activityIds') activityIds?: string[],
|
||||
@Query('activityIds') filterByActivityIds?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Export> {
|
||||
const activityIds = filterByActivityIds?.split(',') ?? [];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.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 { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@ -8,8 +10,14 @@ import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [AccountModule, ApiModule, OrderModule],
|
||||
controllers: [ExportController],
|
||||
imports: [
|
||||
AccountModule,
|
||||
ApiModule,
|
||||
OrderModule,
|
||||
TagModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
providers: [ExportService]
|
||||
})
|
||||
export class ExportModule {}
|
||||
|
@ -1,15 +1,18 @@
|
||||
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 { 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 accountService: AccountService,
|
||||
private readonly orderService: OrderService
|
||||
private readonly orderService: OrderService,
|
||||
private readonly tagService: TagService
|
||||
) {}
|
||||
|
||||
public async export({
|
||||
@ -23,26 +26,7 @@ export class ExportService {
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
const accounts = (
|
||||
await this.accountService.accounts({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
where: { userId }
|
||||
})
|
||||
).map(
|
||||
({ balance, comment, currency, id, isExcluded, name, platformId }) => {
|
||||
return {
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
};
|
||||
}
|
||||
);
|
||||
const platformsMap: { [platformId: string]: Platform } = {};
|
||||
|
||||
let { activities } = await this.orderService.getOrders({
|
||||
filters,
|
||||
@ -54,24 +38,95 @@ export class ExportService {
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
activities = activities.filter((activity) => {
|
||||
return activityIds.includes(activity.id);
|
||||
if (activityIds?.length > 0) {
|
||||
activities = activities.filter(({ id }) => {
|
||||
return activityIds.includes(id);
|
||||
});
|
||||
}
|
||||
|
||||
const accounts = (
|
||||
await this.accountService.accounts({
|
||||
include: {
|
||||
balances: true,
|
||||
platform: true
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
where: { userId }
|
||||
})
|
||||
)
|
||||
.filter(({ id }) => {
|
||||
return activities.length > 0
|
||||
? activities.some(({ accountId }) => {
|
||||
return accountId === id;
|
||||
})
|
||||
: true;
|
||||
})
|
||||
.map(
|
||||
({
|
||||
balance,
|
||||
balances,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platform,
|
||||
platformId
|
||||
}) => {
|
||||
if (platformId) {
|
||||
platformsMap[platformId] = platform;
|
||||
}
|
||||
|
||||
return {
|
||||
balance,
|
||||
balances: balances.map(({ date, value }) => {
|
||||
return { date: date.toISOString(), value };
|
||||
}),
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const tags = (await this.tagService.getTagsForUser(userId))
|
||||
.filter(
|
||||
({ id, isUsed }) =>
|
||||
isUsed &&
|
||||
activities.some((activity) => {
|
||||
return activity.tags.some(({ id: tagId }) => {
|
||||
return tagId === id;
|
||||
});
|
||||
})
|
||||
)
|
||||
.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,
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
tags: currentTags,
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
@ -83,16 +138,15 @@ export class ExportService {
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
currency: SymbolProfile.currency,
|
||||
currency: currency ?? SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
date: date.toISOString(),
|
||||
symbol:
|
||||
type === 'FEE' ||
|
||||
type === 'INTEREST' ||
|
||||
type === 'ITEM' ||
|
||||
type === 'LIABILITY'
|
||||
? SymbolProfile.name
|
||||
: SymbolProfile.symbol
|
||||
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)
|
||||
? SymbolProfile.name
|
||||
: SymbolProfile.symbol,
|
||||
tags: currentTags.map(({ id: tagId }) => {
|
||||
return tagId;
|
||||
})
|
||||
};
|
||||
}
|
||||
),
|
||||
|
@ -1,13 +1,20 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import {
|
||||
DataEnhancerHealthResponse,
|
||||
DataProviderHealthResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
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';
|
||||
@ -17,26 +24,47 @@ export class HealthController {
|
||||
public constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
public async getHealth() {}
|
||||
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) {
|
||||
public async getHealthOfDataEnhancer(
|
||||
@Param('name') name: string,
|
||||
@Res() response: Response
|
||||
): Promise<Response<DataEnhancerHealthResponse>> {
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataEnhancer(name);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
if (hasResponse) {
|
||||
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-provider/:dataSource')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getHealthOfDataProvider(
|
||||
@Param('dataSource') dataSource: DataSource
|
||||
) {
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Res() response: Response
|
||||
): Promise<Response<DataProviderHealthResponse>> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
@ -47,11 +75,14 @@ export class HealthController {
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataProvider(dataSource);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
if (hasResponse) {
|
||||
return response
|
||||
.status(HttpStatus.OK)
|
||||
.json({ status: getReasonPhrase(StatusCodes.OK) });
|
||||
} else {
|
||||
return response
|
||||
.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
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';
|
||||
|
||||
@ -12,6 +14,8 @@ import { HealthService } from './health.service';
|
||||
imports: [
|
||||
DataEnhancerModule,
|
||||
DataProviderModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
providers: [HealthService]
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -8,7 +11,9 @@ import { DataSource } from '@prisma/client';
|
||||
export class HealthService {
|
||||
public constructor(
|
||||
private readonly dataEnhancerService: DataEnhancerService,
|
||||
private readonly dataProviderService: DataProviderService
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {}
|
||||
|
||||
public async hasResponseFromDataEnhancer(aName: string) {
|
||||
@ -18,4 +23,24 @@ export class HealthService {
|
||||
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||
return this.dataProviderService.checkQuote(aDataSource);
|
||||
}
|
||||
|
||||
public async isDatabaseHealthy() {
|
||||
try {
|
||||
await this.propertyService.getByKey(PROPERTY_CURRENCIES);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async isRedisCacheHealthy() {
|
||||
try {
|
||||
const isHealthy = await this.redisCacheService.isHealthy();
|
||||
|
||||
return isHealthy;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
10
apps/api/src/app/import/create-account-with-balances.dto.ts
Normal file
10
apps/api/src/app/import/create-account-with-balances.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { AccountBalance } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { IsArray, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateAccountWithBalancesDto extends CreateAccountDto {
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
balances?: AccountBalance[];
|
||||
}
|
@ -1,15 +1,16 @@
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||
|
||||
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => CreateAccountDto)
|
||||
@Type(() => CreateAccountWithBalancesDto)
|
||||
@ValidateNested({ each: true })
|
||||
accounts: CreateAccountDto[];
|
||||
accounts: CreateAccountWithBalancesDto[];
|
||||
|
||||
@IsArray()
|
||||
@Type(() => CreateOrderDto)
|
||||
|
@ -71,7 +71,7 @@ export class ImportController {
|
||||
const activities = await this.importService.import({
|
||||
isDryRun,
|
||||
maxActivitiesToImport,
|
||||
accountsDto: importData.accounts ?? [],
|
||||
accountsWithBalancesDto: importData.accounts ?? [],
|
||||
activitiesDto: importData.activities,
|
||||
user: this.request.user
|
||||
});
|
||||
@ -98,12 +98,10 @@ export class ImportController {
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<ImportResponse> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const activities = await this.importService.getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { activities };
|
||||
|
@ -7,10 +7,10 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
|
||||
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.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';
|
||||
|
@ -9,13 +9,11 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getAssetProfileIdentifier,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
@ -29,10 +27,12 @@ import {
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||
import { omit, uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
@ -40,7 +40,6 @@ export class ImportService {
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@ -50,11 +49,16 @@ export class ImportService {
|
||||
public async getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
|
||||
userId
|
||||
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
|
||||
try {
|
||||
const { firstBuyDate, historicalData, orders } =
|
||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||
const { activities, firstBuyDate, historicalData } =
|
||||
await this.portfolioService.getHolding({
|
||||
dataSource,
|
||||
symbol,
|
||||
userId,
|
||||
impersonationId: undefined
|
||||
});
|
||||
|
||||
const [[assetProfile], dividends] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles([
|
||||
@ -72,84 +76,84 @@ export class ImportService {
|
||||
})
|
||||
]);
|
||||
|
||||
const accounts = orders
|
||||
.filter(({ Account }) => {
|
||||
return !!Account;
|
||||
const accounts = activities
|
||||
.filter(({ account }) => {
|
||||
return !!account;
|
||||
})
|
||||
.map(({ Account }) => {
|
||||
return Account;
|
||||
.map(({ account }) => {
|
||||
return account;
|
||||
});
|
||||
|
||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
|
||||
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||
const quantity =
|
||||
historicalData.find((historicalDataItem) => {
|
||||
return historicalDataItem.date === dateString;
|
||||
})?.quantity ?? 0;
|
||||
return await Promise.all(
|
||||
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
|
||||
const quantity =
|
||||
historicalData.find((historicalDataItem) => {
|
||||
return historicalDataItem.date === dateString;
|
||||
})?.quantity ?? 0;
|
||||
|
||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||
|
||||
const date = parseDate(dateString);
|
||||
const isDuplicate = orders.some((activity) => {
|
||||
return (
|
||||
activity.accountId === Account?.id &&
|
||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||
activity.type === 'DIVIDEND' &&
|
||||
activity.unitPrice === marketPrice
|
||||
);
|
||||
});
|
||||
const date = parseDate(dateString);
|
||||
const isDuplicate = activities.some((activity) => {
|
||||
return (
|
||||
activity.accountId === account?.id &&
|
||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||
activity.type === 'DIVIDEND' &&
|
||||
activity.unitPrice === marketPrice
|
||||
);
|
||||
});
|
||||
|
||||
const error: ActivityError = isDuplicate
|
||||
? { code: 'IS_DUPLICATE' }
|
||||
: undefined;
|
||||
const error: ActivityError = isDuplicate
|
||||
? { code: 'IS_DUPLICATE' }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
Account,
|
||||
date,
|
||||
error,
|
||||
quantity,
|
||||
value,
|
||||
accountId: Account?.id,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
currency: undefined,
|
||||
createdAt: undefined,
|
||||
fee: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
isDraft: false,
|
||||
SymbolProfile: assetProfile,
|
||||
symbolProfileId: assetProfile.id,
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: marketPrice,
|
||||
updatedAt: undefined,
|
||||
userId: Account?.userId,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
return {
|
||||
account,
|
||||
date,
|
||||
error,
|
||||
quantity,
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
accountId: account?.id,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
currency: undefined,
|
||||
createdAt: undefined,
|
||||
fee: 0,
|
||||
feeInAssetProfileCurrency: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
isDraft: false,
|
||||
SymbolProfile: assetProfile,
|
||||
symbolProfileId: assetProfile.id,
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: marketPrice,
|
||||
unitPriceInAssetProfileCurrency: marketPrice,
|
||||
updatedAt: undefined,
|
||||
userId: account?.userId,
|
||||
valueInBaseCurrency: value
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async import({
|
||||
accountsDto,
|
||||
accountsWithBalancesDto,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
user
|
||||
}: {
|
||||
accountsDto: Partial<CreateAccountDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
accountsWithBalancesDto: ImportDataDto['accounts'];
|
||||
activitiesDto: ImportDataDto['activities'];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
user: UserWithSettings;
|
||||
@ -157,12 +161,12 @@ export class ImportService {
|
||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||
const userCurrency = user.Settings.settings.baseCurrency;
|
||||
|
||||
if (!isDryRun && accountsDto?.length) {
|
||||
if (!isDryRun && accountsWithBalancesDto?.length) {
|
||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||
this.accountService.accounts({
|
||||
where: {
|
||||
id: {
|
||||
in: accountsDto.map(({ id }) => {
|
||||
in: accountsWithBalancesDto.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
}
|
||||
@ -171,14 +175,19 @@ export class ImportService {
|
||||
this.platformService.getPlatforms()
|
||||
]);
|
||||
|
||||
for (const account of accountsDto) {
|
||||
for (const accountWithBalances of accountsWithBalancesDto) {
|
||||
// Check if there is any existing account with the same ID
|
||||
const accountWithSameId = existingAccounts.find(
|
||||
(existingAccount) => existingAccount.id === account.id
|
||||
);
|
||||
const accountWithSameId = existingAccounts.find((existingAccount) => {
|
||||
return existingAccount.id === accountWithBalances.id;
|
||||
});
|
||||
|
||||
// If there is no account or if the account belongs to a different user then create a new account
|
||||
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
|
||||
const account: CreateAccountDto = omit(
|
||||
accountWithBalances,
|
||||
'balances'
|
||||
);
|
||||
|
||||
let oldAccountId: string;
|
||||
const platformId = account.platformId;
|
||||
|
||||
@ -191,7 +200,10 @@ export class ImportService {
|
||||
|
||||
let accountObject: Prisma.AccountCreateInput = {
|
||||
...account,
|
||||
User: { connect: { id: user.id } }
|
||||
balances: {
|
||||
create: accountWithBalances.balances ?? []
|
||||
},
|
||||
user: { connect: { id: user.id } }
|
||||
};
|
||||
|
||||
if (
|
||||
@ -201,7 +213,7 @@ export class ImportService {
|
||||
) {
|
||||
accountObject = {
|
||||
...accountObject,
|
||||
Platform: { connect: { id: platformId } }
|
||||
platform: { connect: { id: platformId } }
|
||||
};
|
||||
}
|
||||
|
||||
@ -220,7 +232,7 @@ export class ImportService {
|
||||
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(activity.type)) {
|
||||
activity.dataSource = DataSource.MANUAL;
|
||||
} else {
|
||||
activity.dataSource =
|
||||
@ -255,35 +267,31 @@ export class ImportService {
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
accountsDto.forEach(({ id, name }) => {
|
||||
accountsWithBalancesDto.forEach(({ id, name }) => {
|
||||
accounts.push({ id, name });
|
||||
});
|
||||
}
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (let [
|
||||
index,
|
||||
{
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
error,
|
||||
fee,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
}
|
||||
] of activitiesExtendedWithErrors.entries()) {
|
||||
for (const activity of activitiesExtendedWithErrors) {
|
||||
const accountId = activity.accountId;
|
||||
const comment = activity.comment;
|
||||
const currency = activity.currency;
|
||||
const date = activity.date;
|
||||
const error = activity.error;
|
||||
const fee = activity.fee;
|
||||
const quantity = activity.quantity;
|
||||
const SymbolProfile = activity.SymbolProfile;
|
||||
const type = activity.type;
|
||||
const unitPrice = activity.unitPrice;
|
||||
|
||||
const assetProfile = assetProfiles[
|
||||
getAssetProfileIdentifier({
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
})
|
||||
] ?? {
|
||||
currency: SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
};
|
||||
@ -292,12 +300,14 @@ export class ImportService {
|
||||
assetSubClass,
|
||||
countries,
|
||||
createdAt,
|
||||
cusip,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
holdings,
|
||||
id,
|
||||
isActive,
|
||||
isin,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
@ -317,35 +327,6 @@ export class ImportService {
|
||||
Account?: { id: string; name: string };
|
||||
});
|
||||
|
||||
if (SymbolProfile.currency !== assetProfile.currency) {
|
||||
// Convert the unit price and fee to the asset currency if the imported
|
||||
// activity is in a different currency
|
||||
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
unitPrice,
|
||||
SymbolProfile.currency,
|
||||
assetProfile.currency,
|
||||
date
|
||||
);
|
||||
|
||||
if (!unitPrice) {
|
||||
throw new Error(
|
||||
`activities.${index} historical exchange rate at ${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)} is not available from "${SymbolProfile.currency}" to "${
|
||||
assetProfile.currency
|
||||
}"`
|
||||
);
|
||||
}
|
||||
|
||||
fee = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
fee,
|
||||
SymbolProfile.currency,
|
||||
assetProfile.currency,
|
||||
date
|
||||
);
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
comment,
|
||||
@ -355,6 +336,7 @@ export class ImportService {
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
Account: validatedAccount,
|
||||
accountId: validatedAccount?.id,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
@ -365,12 +347,14 @@ export class ImportService {
|
||||
assetSubClass,
|
||||
countries,
|
||||
createdAt,
|
||||
cusip,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
holdings,
|
||||
id,
|
||||
isActive,
|
||||
isin,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
@ -379,10 +363,10 @@ export class ImportService {
|
||||
symbolMapping,
|
||||
updatedAt,
|
||||
url,
|
||||
comment: assetProfile.comment,
|
||||
currency: assetProfile.currency,
|
||||
comment: assetProfile.comment
|
||||
userId: dataSource === 'MANUAL' ? user.id : undefined
|
||||
},
|
||||
Account: validatedAccount,
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date(),
|
||||
userId: user.id
|
||||
@ -394,6 +378,7 @@ export class ImportService {
|
||||
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
@ -405,7 +390,8 @@ export class ImportService {
|
||||
create: {
|
||||
dataSource,
|
||||
symbol,
|
||||
currency: assetProfile.currency
|
||||
currency: assetProfile.currency,
|
||||
userId: dataSource === 'MANUAL' ? user.id : undefined
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
@ -416,7 +402,7 @@ export class ImportService {
|
||||
}
|
||||
},
|
||||
updateAccountBalance: false,
|
||||
User: { connect: { id: user.id } },
|
||||
user: { connect: { id: user.id } },
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
@ -432,18 +418,8 @@ export class ImportService {
|
||||
...order,
|
||||
error,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
// @ts-ignore
|
||||
SymbolProfile: assetProfile,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
SymbolProfile: assetProfile
|
||||
});
|
||||
}
|
||||
|
||||
@ -484,12 +460,13 @@ export class ImportService {
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Partial<Activity>[]> {
|
||||
let { activities: existingActivities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
includeDrafts: true,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
const { activities: existingActivities } =
|
||||
await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
includeDrafts: true,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
return activitiesDto.map(
|
||||
({
|
||||
@ -508,7 +485,9 @@ export class ImportService {
|
||||
const isDuplicate = existingActivities.some((activity) => {
|
||||
return (
|
||||
activity.accountId === accountId &&
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.comment === comment &&
|
||||
(activity.currency === currency ||
|
||||
activity.SymbolProfile.currency === currency) &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
activity.fee === fee &&
|
||||
@ -526,6 +505,7 @@ export class ImportService {
|
||||
return {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
date,
|
||||
error,
|
||||
fee,
|
||||
@ -533,7 +513,6 @@ export class ImportService {
|
||||
type,
|
||||
unitPrice,
|
||||
SymbolProfile: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol,
|
||||
activitiesCount: undefined,
|
||||
@ -541,8 +520,10 @@ export class ImportService {
|
||||
assetSubClass: undefined,
|
||||
countries: undefined,
|
||||
createdAt: undefined,
|
||||
currency: undefined,
|
||||
holdings: undefined,
|
||||
id: undefined,
|
||||
isActive: true,
|
||||
sectors: undefined,
|
||||
updatedAt: undefined
|
||||
}
|
||||
@ -577,12 +558,13 @@ export class ImportService {
|
||||
const assetProfiles: {
|
||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
const dataSources = await this.dataProviderService.getDataSources({ user });
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, symbol, type }
|
||||
] of activitiesDto.entries()) {
|
||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||
if (!dataSources.includes(dataSource)) {
|
||||
throw new Error(
|
||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||
);
|
||||
@ -619,12 +601,6 @@ export class ImportService {
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (assetProfile.currency !== currency) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
|
@ -1,15 +1,15 @@
|
||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { PropertyModule } from '@ghostfolio/api/services/property/property.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 { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@ -32,8 +32,8 @@ import { InfoService } from './info.service';
|
||||
PlatformModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule,
|
||||
TagModule,
|
||||
TransformDataSourceInResponseModule,
|
||||
UserModule
|
||||
],
|
||||
|
@ -1,19 +1,19 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
HEADER_KEY_TOKEN,
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||
PROPERTY_DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
@ -21,19 +21,13 @@ import {
|
||||
encodeDataSource,
|
||||
extractNumberFromString
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
InfoItem,
|
||||
Statistics,
|
||||
Subscription
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import got from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
@ -47,16 +41,13 @@ export class InfoService {
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly tagService: TagService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<InfoItem> {
|
||||
const info: Partial<InfoItem> = {};
|
||||
let isReadOnlyMode: boolean;
|
||||
const platforms = await this.platformService.getPlatforms({
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
const globalPermissions: string[] = [];
|
||||
|
||||
@ -73,9 +64,9 @@ export class InfoService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
isReadOnlyMode = await this.propertyService.getByKey<boolean>(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||
@ -90,9 +81,9 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.enableSubscription);
|
||||
|
||||
info.countriesOfSubscribers =
|
||||
((await this.propertyService.getByKey(
|
||||
(await this.propertyService.getByKey<string[]>(
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||
)) as string[]) ?? [];
|
||||
)) ?? [];
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
@ -100,22 +91,28 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.enableSystemMessage);
|
||||
}
|
||||
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
const [
|
||||
benchmarks,
|
||||
demoAuthToken,
|
||||
isUserSignupEnabled,
|
||||
platforms,
|
||||
statistics,
|
||||
subscriptionOffer
|
||||
] = await Promise.all([
|
||||
this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
this.getDemoAuthToken(),
|
||||
this.propertyService.isUserSignupEnabled(),
|
||||
this.platformService.getPlatforms({
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
this.getStatistics(),
|
||||
this.subscriptionService.getSubscriptionOffer({ key: 'default' })
|
||||
]);
|
||||
|
||||
if (isUserSignupEnabled) {
|
||||
globalPermissions.push(permissions.createUserAccount);
|
||||
}
|
||||
|
||||
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
|
||||
await Promise.all([
|
||||
this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
this.getDemoAuthToken(),
|
||||
this.getStatistics(),
|
||||
this.getSubscriptions(),
|
||||
this.tagService.get()
|
||||
]);
|
||||
|
||||
return {
|
||||
...info,
|
||||
benchmarks,
|
||||
@ -124,8 +121,7 @@ export class InfoService {
|
||||
isReadOnlyMode,
|
||||
platforms,
|
||||
statistics,
|
||||
subscriptions,
|
||||
tags,
|
||||
subscriptionOffer,
|
||||
baseCurrency: DEFAULT_CURRENCY,
|
||||
currencies: this.exchangeRateDataService.getCurrencies()
|
||||
};
|
||||
@ -137,12 +133,12 @@ export class InfoService {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
Analytics: {
|
||||
updatedAt: {
|
||||
analytics: {
|
||||
lastRequestAt: {
|
||||
gt: subDays(new Date(), aDays)
|
||||
}
|
||||
}
|
||||
@ -154,20 +150,15 @@ export class InfoService {
|
||||
|
||||
private async countDockerHubPulls(): Promise<number> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { pull_count } = await got(
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
const { pull_count } = (await fetch(
|
||||
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
|
||||
{
|
||||
headers: { 'User-Agent': 'request' },
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json())) as { pull_count: number };
|
||||
|
||||
return pull_count;
|
||||
} catch (error) {
|
||||
@ -179,22 +170,17 @@ export class InfoService {
|
||||
|
||||
private async countGitHubContributors(): Promise<number> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
});
|
||||
const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}).then((res) => res.text());
|
||||
|
||||
const $ = cheerio.load(body);
|
||||
|
||||
return extractNumberFromString({
|
||||
value: $(
|
||||
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||
'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
|
||||
).text()
|
||||
});
|
||||
} catch (error) {
|
||||
@ -206,20 +192,15 @@ export class InfoService {
|
||||
|
||||
private async countGitHubStargazers(): Promise<number> {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { stargazers_count } = await got(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||
const { stargazers_count } = (await fetch(
|
||||
'https://api.github.com/repos/ghostfolio/ghostfolio',
|
||||
{
|
||||
headers: { 'User-Agent': 'request' },
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json())) as { stargazers_count: number };
|
||||
|
||||
return stargazers_count;
|
||||
} catch (error) {
|
||||
@ -235,7 +216,7 @@ export class InfoService {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -249,15 +230,15 @@ export class InfoService {
|
||||
}
|
||||
|
||||
private async countSlackCommunityUsers() {
|
||||
return (await this.propertyService.getByKey(
|
||||
return await this.propertyService.getByKey<string>(
|
||||
PROPERTY_SLACK_COMMUNITY_USERS
|
||||
)) as string;
|
||||
);
|
||||
}
|
||||
|
||||
private async getDemoAuthToken() {
|
||||
const demoUserId = (await this.propertyService.getByKey(
|
||||
const demoUserId = await this.propertyService.getByKey<string>(
|
||||
PROPERTY_DEMO_USER_ID
|
||||
)) as string;
|
||||
);
|
||||
|
||||
if (demoUserId) {
|
||||
return this.jwtService.sign({
|
||||
@ -314,47 +295,29 @@ export class InfoService {
|
||||
return statistics;
|
||||
}
|
||||
|
||||
private async getSubscriptions(): Promise<{
|
||||
[offer in SubscriptionOffer]: Subscription;
|
||||
}> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
private async getUptime(): Promise<number> {
|
||||
{
|
||||
try {
|
||||
const monitorId = (await this.propertyService.getByKey(
|
||||
const monitorId = await this.propertyService.getByKey<string>(
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||
)) as string;
|
||||
);
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { data } = await got(
|
||||
const { data } = await fetch(
|
||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||
subDays(new Date(), 90),
|
||||
DATE_FORMAT
|
||||
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.configurationService.get(
|
||||
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
|
||||
'API_KEY_BETTER_UPTIME'
|
||||
)}`
|
||||
},
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).json<any>();
|
||||
).then((res) => res.json());
|
||||
|
||||
return data.attributes.availability / 100;
|
||||
} catch (error) {
|
||||
|
@ -26,12 +26,13 @@ export class LogoController {
|
||||
@Res() response: Response
|
||||
) {
|
||||
try {
|
||||
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
const { buffer, type } =
|
||||
await this.logoService.getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
response.contentType('image/png');
|
||||
response.contentType(type);
|
||||
response.send(buffer);
|
||||
} catch {
|
||||
response.status(HttpStatus.NOT_FOUND).send();
|
||||
@ -44,9 +45,9 @@ export class LogoController {
|
||||
@Res() response: Response
|
||||
) {
|
||||
try {
|
||||
const buffer = await this.logoService.getLogoByUrl(url);
|
||||
const { buffer, type } = await this.logoService.getLogoByUrl(url);
|
||||
|
||||
response.contentType('image/png');
|
||||
response.contentType(type);
|
||||
response.send(buffer);
|
||||
} catch {
|
||||
response.status(HttpStatus.NOT_FOUND).send();
|
||||
|
@ -4,7 +4,6 @@ import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import got from 'got';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Injectable()
|
||||
@ -29,7 +28,7 @@ export class LogoService {
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile) {
|
||||
if (!assetProfile?.url) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
@ -39,24 +38,26 @@ export class LogoService {
|
||||
return this.getBuffer(assetProfile.url);
|
||||
}
|
||||
|
||||
public async getLogoByUrl(aUrl: string) {
|
||||
public getLogoByUrl(aUrl: string) {
|
||||
return this.getBuffer(aUrl);
|
||||
}
|
||||
|
||||
private getBuffer(aUrl: string) {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
private async getBuffer(aUrl: string) {
|
||||
const blob = await fetch(
|
||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||
{
|
||||
headers: { 'User-Agent': 'request' },
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
signal: AbortSignal.timeout(
|
||||
this.configurationService.get('REQUEST_TIMEOUT')
|
||||
)
|
||||
}
|
||||
).buffer();
|
||||
).then((res) => res.blob());
|
||||
|
||||
return {
|
||||
buffer: await blob.arrayBuffer().then((arrayBuffer) => {
|
||||
return Buffer.from(arrayBuffer);
|
||||
}),
|
||||
type: blob.type
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -27,12 +27,12 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
accountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
@IsOptional()
|
||||
assetClass?: AssetClass;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetSubClass, { each: true })
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsOptional()
|
||||
@ -49,8 +49,8 @@ export class CreateOrderDto {
|
||||
@IsOptional()
|
||||
customCurrency?: string;
|
||||
|
||||
@IsEnum(DataSource)
|
||||
@IsOptional()
|
||||
@IsEnum(DataSource, { each: true })
|
||||
dataSource?: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
|
@ -9,11 +9,13 @@ export interface Activities {
|
||||
}
|
||||
|
||||
export interface Activity extends Order {
|
||||
Account?: AccountWithPlatform;
|
||||
account?: AccountWithPlatform;
|
||||
error?: ActivityError;
|
||||
feeInAssetProfileCurrency: number;
|
||||
feeInBaseCurrency: number;
|
||||
SymbolProfile?: EnhancedSymbolProfile;
|
||||
tags?: Tag[];
|
||||
unitPriceInAssetProfileCurrency: number;
|
||||
updateAccountBalance?: boolean;
|
||||
value: number;
|
||||
valueInBaseCurrency: number;
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
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 { 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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
HEADER_KEY_IMPERSONATION
|
||||
@ -53,14 +53,19 @@ export class OrderController {
|
||||
@Delete()
|
||||
@HasPermission(permissions.deleteOrder)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async deleteOrders(
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<number> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -94,15 +99,18 @@ export class OrderController {
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('range') dateRange?: DateRange,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('take') take?: number
|
||||
): Promise<Activities> {
|
||||
@ -110,12 +118,14 @@ export class OrderController {
|
||||
let startDate: Date;
|
||||
|
||||
if (dateRange) {
|
||||
({ endDate, startDate } = getInterval(dateRange));
|
||||
({ endDate, startDate } = getIntervalFromDateRange(dateRange));
|
||||
}
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -145,7 +155,7 @@ export class OrderController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getOrderById(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('id') id: string
|
||||
): Promise<Activity> {
|
||||
const impersonationUserId =
|
||||
@ -204,7 +214,7 @@ export class OrderController {
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } },
|
||||
user: { connect: { id: this.request.user.id } },
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -259,7 +269,7 @@ export class OrderController {
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
@ -277,7 +287,7 @@ export class OrderController {
|
||||
name: data.symbol
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id
|
||||
|
@ -6,11 +6,11 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac
|
||||
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 { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.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';
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
@ -62,14 +63,14 @@ export class OrderService {
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
await Promise.all(
|
||||
orders.map(({ id }) =>
|
||||
this.prismaService.order.update({
|
||||
data: {
|
||||
tags: {
|
||||
// The set operation replaces all existing connections with the provided ones
|
||||
set: tags.map(({ id }) => {
|
||||
return { id };
|
||||
set: tags.map((tag) => {
|
||||
return { id: tag.id };
|
||||
})
|
||||
}
|
||||
},
|
||||
@ -77,6 +78,13 @@ export class OrderService {
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async createOrder(
|
||||
@ -92,10 +100,10 @@ export class OrderService {
|
||||
userId: string;
|
||||
}
|
||||
): Promise<Order> {
|
||||
let Account;
|
||||
let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
|
||||
|
||||
if (data.accountId) {
|
||||
Account = {
|
||||
account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
@ -123,6 +131,7 @@ export class OrderService {
|
||||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||
data.SymbolProfile.connectOrCreate.create.userId = userId;
|
||||
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
|
||||
dataSource,
|
||||
symbol: id
|
||||
@ -135,9 +144,9 @@ export class OrderService {
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
@ -170,7 +179,7 @@ export class OrderService {
|
||||
const order = await this.prismaService.order.create({
|
||||
data: {
|
||||
...orderData,
|
||||
Account,
|
||||
account,
|
||||
isDraft,
|
||||
tags: {
|
||||
connect: tags.map(({ id }) => {
|
||||
@ -222,10 +231,7 @@ export class OrderService {
|
||||
order.symbolProfileId
|
||||
]);
|
||||
|
||||
if (
|
||||
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
|
||||
symbolProfile.activitiesCount === 0
|
||||
) {
|
||||
if (symbolProfile.activitiesCount === 0) {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
@ -349,6 +355,14 @@ export class OrderService {
|
||||
return type;
|
||||
});
|
||||
|
||||
const filterByDataSource = filters?.find(({ type }) => {
|
||||
return type === 'DATA_SOURCE';
|
||||
})?.id;
|
||||
|
||||
const filterBySymbol = filters?.find(({ type }) => {
|
||||
return type === 'SYMBOL';
|
||||
})?.id;
|
||||
|
||||
const searchQuery = filters?.find(({ type }) => {
|
||||
return type === 'SEARCH_QUERY';
|
||||
})?.id;
|
||||
@ -394,6 +408,29 @@ export class OrderService {
|
||||
};
|
||||
}
|
||||
|
||||
if (filterByDataSource && filterBySymbol) {
|
||||
if (where.SymbolProfile) {
|
||||
where.SymbolProfile = {
|
||||
AND: [
|
||||
where.SymbolProfile,
|
||||
{
|
||||
AND: [
|
||||
{ dataSource: filterByDataSource as DataSource },
|
||||
{ symbol: filterBySymbol }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
} else {
|
||||
where.SymbolProfile = {
|
||||
AND: [
|
||||
{ dataSource: filterByDataSource as DataSource },
|
||||
{ symbol: filterBySymbol }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
|
||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
@ -438,8 +475,8 @@ export class OrderService {
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.OR = [
|
||||
{ Account: null },
|
||||
{ Account: { NOT: { isExcluded: true } } }
|
||||
{ account: null },
|
||||
{ account: { NOT: { isExcluded: true } } }
|
||||
];
|
||||
}
|
||||
|
||||
@ -450,10 +487,9 @@ export class OrderService {
|
||||
take,
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: {
|
||||
account: {
|
||||
include: {
|
||||
Platform: true
|
||||
platform: true
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@ -483,38 +519,82 @@ export class OrderService {
|
||||
assetProfileIdentifiers
|
||||
);
|
||||
|
||||
const activities = orders.map((order) => {
|
||||
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
|
||||
return (
|
||||
dataSource === order.SymbolProfile.dataSource &&
|
||||
symbol === order.SymbolProfile.symbol
|
||||
);
|
||||
});
|
||||
const activities = await Promise.all(
|
||||
orders.map(async (order) => {
|
||||
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
|
||||
return (
|
||||
dataSource === order.SymbolProfile.dataSource &&
|
||||
symbol === order.SymbolProfile.symbol
|
||||
);
|
||||
});
|
||||
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
// TODO: Use exchange rate of date
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
SymbolProfile: assetProfile,
|
||||
// TODO: Use exchange rate of date
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
const [
|
||||
feeInAssetProfileCurrency,
|
||||
feeInBaseCurrency,
|
||||
unitPriceInAssetProfileCurrency,
|
||||
valueInBaseCurrency
|
||||
] = await Promise.all([
|
||||
this.exchangeRateDataService.toCurrencyAtDate(
|
||||
order.fee,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
order.SymbolProfile.currency,
|
||||
order.date
|
||||
),
|
||||
this.exchangeRateDataService.toCurrencyAtDate(
|
||||
order.fee,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
userCurrency,
|
||||
order.date
|
||||
),
|
||||
this.exchangeRateDataService.toCurrencyAtDate(
|
||||
order.unitPrice,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
order.SymbolProfile.currency,
|
||||
order.date
|
||||
),
|
||||
this.exchangeRateDataService.toCurrencyAtDate(
|
||||
value,
|
||||
order.currency ?? order.SymbolProfile.currency,
|
||||
userCurrency,
|
||||
order.date
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
...order,
|
||||
feeInAssetProfileCurrency,
|
||||
feeInBaseCurrency,
|
||||
unitPriceInAssetProfileCurrency,
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
valueInBaseCurrency,
|
||||
SymbolProfile: assetProfile
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return { activities, count };
|
||||
}
|
||||
|
||||
@LogPerformance
|
||||
public async getOrdersForPortfolioCalculator({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}) {
|
||||
return this.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: false // TODO
|
||||
});
|
||||
}
|
||||
|
||||
public async getStatisticsByCurrency(
|
||||
currency: EnhancedSymbolProfile['currency']
|
||||
): Promise<{
|
||||
@ -569,8 +649,8 @@ export class OrderService {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
delete data.SymbolProfile.connect;
|
||||
|
||||
if (data.Account?.connect?.id_userId?.id === null) {
|
||||
data.Account = { disconnect: true };
|
||||
if (data.account?.connect?.id_userId?.id === null) {
|
||||
data.account = { disconnect: true };
|
||||
}
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
@ -584,7 +664,7 @@ export class OrderService {
|
||||
{
|
||||
dataSource:
|
||||
data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
||||
date: <Date>data.date,
|
||||
date: data.date as Date,
|
||||
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
||||
}
|
||||
],
|
||||
|
@ -26,6 +26,7 @@ export class PlatformController {
|
||||
public constructor(private readonly platformService: PlatformService) {}
|
||||
|
||||
@Get()
|
||||
@HasPermission(permissions.readPlatforms)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPlatforms() {
|
||||
return this.platformService.getPlatformsWithAccountCount();
|
||||
|
@ -54,7 +54,7 @@ export class PlatformService {
|
||||
await this.prismaService.platform.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { Account: true }
|
||||
select: { accounts: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -64,7 +64,7 @@ export class PlatformService {
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
accountCount: _count.Account
|
||||
accountCount: _count.accounts
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -3,28 +3,21 @@ import {
|
||||
AssetProfileIdentifier,
|
||||
SymbolMetrics
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
import { PortfolioSnapshot } from '@ghostfolio/common/models';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||
protected calculateOverallPerformance(
|
||||
positions: TimelinePosition[]
|
||||
): PortfolioSnapshot {
|
||||
export class MwrPortfolioCalculator extends PortfolioCalculator {
|
||||
protected calculateOverallPerformance(): PortfolioSnapshot {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
protected getSymbolMetrics({
|
||||
dataSource,
|
||||
end,
|
||||
exchangeRates,
|
||||
isChartMode = false,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step = 1,
|
||||
symbol
|
||||
}: {
|
||||
protected getPerformanceCalculationType() {
|
||||
return PerformanceCalculationType.MWR;
|
||||
}
|
||||
|
||||
protected getSymbolMetrics({}: {
|
||||
end: Date;
|
||||
exchangeRates: { [dateString: string]: number };
|
||||
isChartMode?: boolean;
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
|
@ -1,13 +1,19 @@
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
export const activityDummyData = {
|
||||
accountId: undefined,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
createdAt: new Date(),
|
||||
currency: undefined,
|
||||
fee: undefined,
|
||||
feeInAssetProfileCurrency: undefined,
|
||||
feeInBaseCurrency: undefined,
|
||||
id: undefined,
|
||||
isDraft: false,
|
||||
symbolProfileId: undefined,
|
||||
unitPrice: undefined,
|
||||
unitPriceInAssetProfileCurrency: undefined,
|
||||
updatedAt: new Date(),
|
||||
userId: undefined,
|
||||
value: undefined,
|
||||
@ -22,6 +28,7 @@ export const symbolProfileDummyData = {
|
||||
createdAt: undefined,
|
||||
holdings: [],
|
||||
id: undefined,
|
||||
isActive: true,
|
||||
sectors: [],
|
||||
updatedAt: undefined
|
||||
};
|
||||
@ -29,3 +36,7 @@ export const symbolProfileDummyData = {
|
||||
export const userDummyData = {
|
||||
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||||
};
|
||||
|
||||
export function loadActivityExportFile(filePath: string) {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8')).activities;
|
||||
}
|
||||
|
@ -3,19 +3,17 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
|
||||
import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
|
||||
|
||||
export enum PerformanceCalculationType {
|
||||
MWR = 'MWR', // Money-Weighted Rate of Return
|
||||
TWR = 'TWR' // Time-Weighted Rate of Return
|
||||
}
|
||||
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
|
||||
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
|
||||
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioCalculatorFactory {
|
||||
@ -23,6 +21,7 @@ export class PortfolioCalculatorFactory {
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly currentRateService: CurrentRateService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioSnapshotService: PortfolioSnapshotService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {}
|
||||
|
||||
@ -31,49 +30,73 @@ export class PortfolioCalculatorFactory {
|
||||
activities,
|
||||
calculationType,
|
||||
currency,
|
||||
dateRange = 'max',
|
||||
hasFilters,
|
||||
isExperimentalFeatures = false,
|
||||
filters = [],
|
||||
userId
|
||||
}: {
|
||||
accountBalanceItems?: HistoricalDataItem[];
|
||||
activities: Activity[];
|
||||
calculationType: PerformanceCalculationType;
|
||||
currency: string;
|
||||
dateRange?: DateRange;
|
||||
hasFilters: boolean;
|
||||
isExperimentalFeatures?: boolean;
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
}): PortfolioCalculator {
|
||||
const useCache = !hasFilters && isExperimentalFeatures;
|
||||
|
||||
switch (calculationType) {
|
||||
case PerformanceCalculationType.MWR:
|
||||
return new MWRPortfolioCalculator({
|
||||
return new MwrPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
dateRange,
|
||||
useCache,
|
||||
filters,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
portfolioSnapshotService: this.portfolioSnapshotService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
|
||||
case PerformanceCalculationType.ROAI:
|
||||
return new RoaiPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
filters,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
portfolioSnapshotService: this.portfolioSnapshotService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
|
||||
case PerformanceCalculationType.ROI:
|
||||
return new RoiPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
filters,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
portfolioSnapshotService: this.portfolioSnapshotService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
|
||||
case PerformanceCalculationType.TWR:
|
||||
return new TWRPortfolioCalculator({
|
||||
return new TwrPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
currentRateService: this.currentRateService,
|
||||
dateRange,
|
||||
useCache,
|
||||
filters,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
portfolioSnapshotService: this.portfolioSnapshotService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
|
||||
default:
|
||||
throw new Error('Invalid calculation type');
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user