Compare commits
1173 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 | |||
43f5bb7773 | |||
e85cc0fcfc | |||
dc1948016f | |||
4410040a14 | |||
b2ed0b2c80 | |||
42fe653e1e | |||
8a81fa814f | |||
98f3fa9d7c | |||
202e27fe25 | |||
757ff527d0 | |||
41f5801b5e | |||
4c7657a90e | |||
aef650753e |
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>",
|
||||
}
|
||||
|
1358
CHANGELOG.md
1358
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
|
||||
|
172
README.md
172
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
|
||||
@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
|
||||
## Self-hosting
|
||||
|
||||
@ -85,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,
|
||||
UniqueAsset
|
||||
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
|
||||
) {}
|
||||
|
||||
@ -59,7 +68,9 @@ export class AdminService {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
||||
}: AssetProfileIdentifier & { currency?: string }): Promise<
|
||||
SymbolProfile | never
|
||||
> {
|
||||
try {
|
||||
if (dataSource === 'MANUAL') {
|
||||
return this.symbolProfileService.add({
|
||||
@ -96,41 +107,68 @@ export class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
public async deleteProfileData({
|
||||
dataSource,
|
||||
symbol
|
||||
}: AssetProfileIdentifier) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
|
||||
const currency = getCurrencyFromSymbol(symbol);
|
||||
const customCurrencies =
|
||||
await this.propertyService.getByKey<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
|
||||
};
|
||||
}
|
||||
@ -206,7 +244,7 @@ export class AdminService {
|
||||
|
||||
if (sortColumn === 'activitiesCount') {
|
||||
orderBy = {
|
||||
Order: {
|
||||
activities: {
|
||||
_count: sortDirection
|
||||
}
|
||||
};
|
||||
@ -216,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,
|
||||
@ -224,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,
|
||||
@ -233,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,
|
||||
@ -259,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 (
|
||||
@ -275,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,
|
||||
@ -285,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
|
||||
};
|
||||
}
|
||||
)
|
||||
@ -325,7 +438,7 @@ export class AdminService {
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
|
||||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||
@ -367,40 +480,116 @@ 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
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
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: Prisma.SymbolProfileUpdateInput & UniqueAsset =
|
||||
{
|
||||
return { count, users };
|
||||
}
|
||||
|
||||
public async patchAssetProfileData(
|
||||
{ dataSource, symbol }: AssetProfileIdentifier,
|
||||
{
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource: newDataSource,
|
||||
holdings,
|
||||
isActive,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol: newSymbol,
|
||||
symbolMapping,
|
||||
url
|
||||
}: Prisma.SymbolProfileUpdateInput
|
||||
) {
|
||||
if (
|
||||
newSymbol &&
|
||||
newDataSource &&
|
||||
(newSymbol !== symbol || newDataSource !== dataSource)
|
||||
) {
|
||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource: DataSource[newDataSource.toString()],
|
||||
symbol: newSymbol as string
|
||||
}
|
||||
]);
|
||||
|
||||
if (assetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.CONFLICT),
|
||||
StatusCodes.CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@ -417,16 +606,21 @@ export class AdminService {
|
||||
})
|
||||
};
|
||||
|
||||
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||
await this.symbolProfileService.updateSymbolProfile(
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
updatedSymbolProfile
|
||||
);
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]);
|
||||
|
||||
return symbolProfile;
|
||||
return this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource: dataSource as DataSource,
|
||||
symbol: symbol as string
|
||||
}
|
||||
])?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
public async putSetting(key: string, value: string) {
|
||||
@ -447,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');
|
||||
|
||||
@ -461,10 +671,10 @@ export class AdminService {
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
Order: {
|
||||
activities: {
|
||||
where: {
|
||||
User: {
|
||||
Subscription: {
|
||||
user: {
|
||||
subscriptions: {
|
||||
some: {
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
@ -482,7 +692,7 @@ export class AdminService {
|
||||
}
|
||||
});
|
||||
|
||||
return _count.Order > 0;
|
||||
return _count.activities > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -494,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,
|
||||
@ -605,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,
|
||||
|
2
apps/api/src/app/cache/cache.controller.ts
vendored
2
apps/api/src/app/cache/cache.controller.ts
vendored
@ -14,6 +14,6 @@ export class CacheController {
|
||||
@Post('flush')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async flushCache(): Promise<void> {
|
||||
return this.redisCacheService.reset();
|
||||
await this.redisCacheService.reset();
|
||||
}
|
||||
}
|
||||
|
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,12 +1,15 @@
|
||||
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,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
BenchmarkResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -16,6 +19,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
@ -29,19 +33,23 @@ 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
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||
public async addBenchmark(
|
||||
@Body() { dataSource, symbol }: AssetProfileIdentifier
|
||||
) {
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.addBenchmark({
|
||||
dataSource,
|
||||
@ -106,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,17 +9,15 @@ 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';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccountWithPlatform,
|
||||
OrderWithAccount,
|
||||
@ -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
|
||||
}: UniqueAsset & { 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();
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
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()
|
||||
@ -17,7 +16,7 @@ export class LogoService {
|
||||
public async getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset) {
|
||||
}: AssetProfileIdentifier) {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
@ -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
|
||||
@ -36,7 +36,7 @@ import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
import { Activities, Activity } from './interfaces/activities.interface';
|
||||
import { OrderService } from './order.service';
|
||||
import { UpdateOrderDto } from './update-order.dto';
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -140,6 +150,38 @@ export class OrderController {
|
||||
return { activities, count };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getOrderById(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('id') id: string
|
||||
): Promise<Activity> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
const activity = activities.find((activity) => {
|
||||
return activity.id === id;
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createOrder)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@ -172,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
|
||||
});
|
||||
|
||||
@ -227,7 +269,7 @@ export class OrderController {
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
@ -245,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,19 +1,20 @@
|
||||
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 {
|
||||
AssetProfileIdentifier,
|
||||
EnhancedSymbolProfile,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
@ -51,7 +52,7 @@ export class OrderService {
|
||||
symbol,
|
||||
tags,
|
||||
userId
|
||||
}: { tags: Tag[]; userId: string } & UniqueAsset) {
|
||||
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
|
||||
const orders = await this.prismaService.order.findMany({
|
||||
where: {
|
||||
userId,
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -285,7 +291,7 @@ export class OrderService {
|
||||
return count;
|
||||
}
|
||||
|
||||
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
|
||||
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return this.prismaService.order.findFirst({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
@ -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
|
||||
@ -464,7 +500,7 @@ export class OrderService {
|
||||
this.prismaService.order.count({ where })
|
||||
]);
|
||||
|
||||
const uniqueAssets = uniqBy(
|
||||
const assetProfileIdentifiers = uniqBy(
|
||||
orders.map(({ SymbolProfile }) => {
|
||||
return {
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
@ -479,41 +515,86 @@ export class OrderService {
|
||||
}
|
||||
);
|
||||
|
||||
const assetProfiles =
|
||||
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
||||
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
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<{
|
||||
@ -568,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;
|
||||
@ -583,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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -1,33 +1,29 @@
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
SymbolMetrics
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
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 };
|
||||
};
|
||||
start: Date;
|
||||
step?: number;
|
||||
} & UniqueAsset): SymbolMetrics {
|
||||
} & AssetProfileIdentifier): SymbolMetrics {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user