Compare commits
1121 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 | ||
|
420f331be9 | ||
|
e0068c4d5d | ||
|
85661884a6 | ||
|
8f6203d296 | ||
|
2fa723dc3c | ||
|
a500fb72c5 | ||
|
02db0db733 | ||
|
c87b08ca8b | ||
|
fcc2ab1a48 | ||
|
7efda2f890 | ||
|
3794a61d2d | ||
|
c1d1ea9dde | ||
|
0d676a46c8 | ||
|
97db144e01 | ||
|
cec55127c8 | ||
|
f3f359bcfb | ||
|
601e6f4147 | ||
|
e228b4925c | ||
|
62e3ffe413 | ||
|
6af885fde0 | ||
|
dd15bba359 | ||
|
43fca7ff43 | ||
|
faa6af5694 | ||
|
d2ea7a0bfb | ||
|
3f6319e00b | ||
|
5601299648 | ||
|
6060c7cfe0 | ||
|
ba78c2783d | ||
|
48eee5f865 | ||
|
f4a8acdb46 | ||
|
1d6ba22598 | ||
|
e38be8d710 | ||
|
da5be3fb57 | ||
|
b5317a7f95 | ||
|
43afb16808 | ||
|
d5c56fb16c | ||
|
b94c1f280b | ||
|
acc59866a3 | ||
|
c9fc3e402d | ||
|
6c1317f978 | ||
|
89be438e66 | ||
|
9d6214e93a | ||
|
0640b24290 | ||
|
6eb9d9d973 | ||
|
9ecc3176a5 | ||
|
96434c5a54 | ||
|
4063c62a17 | ||
|
890c5b986c | ||
|
423bd92b89 | ||
|
5dc331e386 | ||
|
744dc51dcd | ||
|
b0c53d050a | ||
|
830569b38e | ||
|
35b4aef06f | ||
|
bc2fd9c970 | ||
|
c42a8aebed | ||
|
fad1adb91b | ||
|
9cd37f8de0 | ||
|
d49b90d7a5 | ||
|
130a9ea062 | ||
|
ffc6309850 | ||
|
976cc7f243 | ||
|
7067aca04b | ||
|
1c9805bb96 | ||
|
8227a2d91a | ||
|
194aee97db | ||
|
0f77169952 | ||
|
0f8dc62c53 | ||
|
554136cdcd | ||
|
83b5cfff1f | ||
|
dcec3accf0 | ||
|
f08b0b570b | ||
|
8386fec98a | ||
|
4d3dff3e5b | ||
|
76890e63fa | ||
|
4fb2aebf4f | ||
|
ed5cd3b978 | ||
|
469c1936b4 | ||
|
8b3cc5c11a | ||
|
ee086638f3 | ||
|
58d1abbd38 | ||
|
ba979cbae2 | ||
|
8cda43bb63 | ||
|
c4499df74c | ||
|
24bcc15b6a | ||
|
ff121243e4 | ||
|
70e633b997 | ||
|
0780ee4adb | ||
|
09613f9324 | ||
|
8642b1a7af | ||
|
f96f861341 | ||
|
a201fc7a97 | ||
|
a97110348c | ||
|
a25d5b9dc0 | ||
|
6c2acf2aa6 | ||
|
519827045a | ||
|
79a7e12a9f | ||
|
bf20a5de82 | ||
|
0adefe14e1 | ||
|
f24561cc3d | ||
|
873fd53715 | ||
|
e5d8faf2dc | ||
|
65d3bd2802 | ||
|
ad60373813 | ||
|
b725e6e2ec | ||
|
88c420ca5e | ||
|
118e17f78c | ||
|
cc92592d86 | ||
|
46eb3254a9 | ||
|
2477491f18 | ||
|
5fc9fde129 | ||
|
00e50c6abe | ||
|
8131a7ad03 | ||
|
f5e6f7dcfe | ||
|
87501e094d | ||
|
d3bfdf78c3 | ||
|
fc4e6ae6db | ||
|
23e4d5454d | ||
|
fdcf5fd396 | ||
|
8a9ae9bb33 | ||
|
3fb7e746df | ||
|
137e8e090a | ||
|
6d941500cd | ||
|
9c4e22978d | ||
|
af65a99398 | ||
|
1a0cb561cd | ||
|
9f875adf0c | ||
|
6b0dadb895 | ||
|
98a9523eee | ||
|
dfb3365efb | ||
|
0e08d8830e | ||
|
69d85eadfd | ||
|
c009f8c12f | ||
|
60ef46accf | ||
|
b12ac1fe84 | ||
|
4355c96ab6 | ||
|
fb326fe0cc | ||
|
02cfebd98c | ||
|
dd2936d703 | ||
|
918d0b85d4 | ||
|
a061595101 | ||
|
dcd496ac50 | ||
|
6b9ec549da | ||
|
b5bd4df483 | ||
|
766a2d7c2f | ||
|
6dabf7516a | ||
|
5d49ff7a4a | ||
|
8998c18836 | ||
|
741a0e36d2 | ||
|
e31b4c64cb | ||
|
812ff5cbdc | ||
|
035b90a689 | ||
|
5ea3a187f4 | ||
|
5d9c38663d | ||
|
5616bc4956 | ||
|
9ad1c2177c | ||
|
15bf9f2f9c | ||
|
ebc5008569 | ||
|
37759ba03f | ||
|
8319b216bb | ||
|
782d131b0d | ||
|
72e75208df | ||
|
4b1c27c245 | ||
|
61f0da35bc | ||
|
80464c7846 | ||
|
74f4323903 | ||
|
127dbf9dcd | ||
|
66bdb374e8 | ||
|
4ad4fa2b30 | ||
|
1fd836194f | ||
|
2090db1199 | ||
|
053c7e591e | ||
|
9b5e350e3b | ||
|
378e57c3bc | ||
|
6765191a8c | ||
|
8438a45bcf | ||
|
30a64e7fc1 | ||
|
f2cb671c7f | ||
|
3f41e5c5de | ||
|
c1ad483f33 | ||
|
f3d961bc16 | ||
|
42b70ef568 | ||
|
77beaaba08 | ||
|
d9c07456cd | ||
|
0a53df4293 | ||
|
4416ba0c88 | ||
|
486de968a2 | ||
|
a5833566a8 | ||
|
261f5844dd | ||
|
2173c418a7 | ||
|
4efd5cefd8 | ||
|
d735e4db75 | ||
|
e10707fde4 | ||
|
ac953df809 | ||
|
bb4ee50738 | ||
|
4f41bac328 | ||
|
cd07802400 | ||
|
b692b7432c | ||
|
a4efbc0131 | ||
|
55b0fe232c | ||
|
46432edce9 | ||
|
990028316e | ||
|
37871fbabc | ||
|
0fdeef7953 | ||
|
cdbe6eedeb | ||
|
a6dde8ad43 | ||
|
39bd4a349b | ||
|
ab59eb5c92 | ||
|
1132dc9bdd | ||
|
2d70b18593 | ||
|
b6ea7d23fa | ||
|
95382581f1 | ||
|
551b83a6e3 | ||
|
895c4fe299 | ||
|
ccb1bf881e | ||
|
7788b5a987 | ||
|
47fd029e0c | ||
|
458ee159e1 | ||
|
dfacbed66d | ||
|
22d63c6102 | ||
|
212aa6a63b | ||
|
bed9ae916c | ||
|
b6ad362850 | ||
|
ab86dd5318 | ||
|
dba73d80a3 | ||
|
92fb05320a | ||
|
73d62bb51f | ||
|
127b7d4f25 | ||
|
e79d607ab8 | ||
|
5f7d083f7c | ||
|
15857118fe | ||
|
ff91ed21df | ||
|
9241c04d5a | ||
|
5d4e2fba8c | ||
|
6c57609db8 | ||
|
b31bbbe2d1 | ||
|
7d308917dd | ||
|
4e7d93db13 | ||
|
45340b581f | ||
|
6f8fe45fc2 | ||
|
34d9ceb009 | ||
|
2b97bbd05d | ||
|
3bf7ac76a0 | ||
|
71892e67b2 | ||
|
6e2885ed20 | ||
|
07c0e5a612 | ||
|
719bbe156e | ||
|
b51255a543 | ||
|
50dbbf0569 | ||
|
ca2e748c56 | ||
|
5c480109d5 | ||
|
6152ff4b44 | ||
|
c6641fde36 | ||
|
4ae7e9fcbe | ||
|
c10ae431a2 | ||
|
883e30e451 | ||
|
f1f4f6247d | ||
|
82fe1de1a7 | ||
|
371c999fbc | ||
|
26b9660e11 | ||
|
ca7717f9c5 | ||
|
6f3cce1c5f | ||
|
efdc9b387f | ||
|
d7b579e3e8 | ||
|
b8533050b0 | ||
|
1b81409b35 | ||
|
8cd6c34ed8 | ||
|
0c68474802 | ||
|
34997f91db | ||
|
084467ee9a | ||
|
af47889d65 | ||
|
51203ec96e | ||
|
a2277dea2c | ||
|
debd233c32 | ||
|
f1eeee0525 | ||
|
5ffc39c32f | ||
|
a668a66e84 | ||
|
0581b8b9ec | ||
|
63a61fb492 | ||
|
5788c6474e | ||
|
5529fdc0ee | ||
|
88a9b518f6 | ||
|
98de2355c4 | ||
|
b41eb60348 | ||
|
0edebe30e1 | ||
|
e3abe4feee | ||
|
50391e199a | ||
|
a33f8d5bed | ||
|
636be8441e | ||
|
654dc2ba32 | ||
|
458ef169f4 | ||
|
5bb01bb03c | ||
|
43e9528d8c | ||
|
522c54c9b4 | ||
|
0004ced4e1 | ||
|
274c60e961 | ||
|
754e98099c | ||
|
87bf8df1c3 | ||
|
3f7d6b25c7 | ||
|
8a062e03ab | ||
|
a70f45cbf3 | ||
|
f268264c46 | ||
|
bbe5d70720 | ||
|
f1d2a52cba | ||
|
87cc887865 | ||
|
61ecd15f1d | ||
|
eb853f05ae | ||
|
6285417903 | ||
|
ca674a654e | ||
|
2729c5651f | ||
|
7e28e42995 | ||
|
e21563d903 | ||
|
3ede69650c | ||
|
c289793c6d | ||
|
a90c067da0 | ||
|
38c2baf943 | ||
|
82c78cad6b | ||
|
bffe6060bd | ||
|
841bd5c33f | ||
|
3b895afc9e | ||
|
00c2ede85e | ||
|
8420cb830c | ||
|
a0ddd1f9b9 | ||
|
40d93066ff | ||
|
671e4e316b | ||
|
473136e9aa | ||
|
9a3db91982 | ||
|
d23cb5f190 | ||
|
7a364472c8 | ||
|
59c064e3c8 | ||
|
e792924606 | ||
|
d32dd5e860 | ||
|
bb86f85203 | ||
|
0bca8897d6 | ||
|
ba73f6de2e | ||
|
eb75be8535 | ||
|
6d2a897366 | ||
|
d8bfb23f20 | ||
|
d9d71e7827 | ||
|
b642ce08e5 | ||
|
bc8d8309d4 | ||
|
1f2f9f22f2 | ||
|
7a3237f1ff | ||
|
07661d9262 | ||
|
77358eed65 |
25
.env.dev
Normal file
25
.env.dev
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||||
|
|
||||||
|
# CACHE
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||||
|
|
||||||
|
# POSTGRES
|
||||||
|
POSTGRES_DB=ghostfolio-db
|
||||||
|
POSTGRES_USER=user
|
||||||
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
|
# VARIOUS
|
||||||
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
|
||||||
|
# DEVELOPMENT
|
||||||
|
|
||||||
|
# Nx 18 enables using plugins to infer targets by default
|
||||||
|
# This is disabled for existing workspaces to maintain compatibility
|
||||||
|
# For more info, see: https://nx.dev/concepts/inferred-tasks
|
||||||
|
NX_ADD_PLUGINS=false
|
||||||
|
|
||||||
|
NX_NATIVE_COMMAND_RUNNER=false
|
@ -1,7 +1,7 @@
|
|||||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
COMPOSE_PROJECT_NAME=ghostfolio
|
||||||
|
|
||||||
# CACHE
|
# CACHE
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=redis
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||||
|
|
||||||
@ -10,6 +10,7 @@ POSTGRES_DB=ghostfolio-db
|
|||||||
POSTGRES_USER=user
|
POSTGRES_USER=user
|
||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
|
# VARIOUS
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
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>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
118
.eslintrc.json
118
.eslintrc.json
@ -1,118 +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": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["*.js", "*.jsx"],
|
|
||||||
"extends": ["plugin:@nx/javascript"],
|
|
||||||
"rules": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["*.ts"],
|
|
||||||
"plugins": ["eslint-plugin-import", "@typescript-eslint"],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/consistent-type-definitions": "error",
|
|
||||||
"@typescript-eslint/dot-notation": "off",
|
|
||||||
"@typescript-eslint/explicit-member-accessibility": [
|
|
||||||
"off",
|
|
||||||
{
|
|
||||||
"accessibility": "explicit"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@typescript-eslint/member-ordering": "error",
|
|
||||||
"@typescript-eslint/naming-convention": "off",
|
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
|
||||||
"@typescript-eslint/no-empty-interface": "error",
|
|
||||||
"@typescript-eslint/no-inferrable-types": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"ignoreParameters": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@typescript-eslint/no-misused-new": "error",
|
|
||||||
"@typescript-eslint/no-non-null-assertion": "error",
|
|
||||||
"@typescript-eslint/no-shadow": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"hoist": "all"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@typescript-eslint/no-unused-expressions": "error",
|
|
||||||
"@typescript-eslint/prefer-function-type": "error",
|
|
||||||
"@typescript-eslint/unified-signatures": "error",
|
|
||||||
"arrow-body-style": "off",
|
|
||||||
"constructor-super": "error",
|
|
||||||
"eqeqeq": ["error", "smart"],
|
|
||||||
"guard-for-in": "error",
|
|
||||||
"id-blacklist": "off",
|
|
||||||
"id-match": "off",
|
|
||||||
"import/no-deprecated": "warn",
|
|
||||||
"no-bitwise": "error",
|
|
||||||
"no-caller": "error",
|
|
||||||
"no-console": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"allow": [
|
|
||||||
"log",
|
|
||||||
"warn",
|
|
||||||
"dir",
|
|
||||||
"timeLog",
|
|
||||||
"assert",
|
|
||||||
"clear",
|
|
||||||
"count",
|
|
||||||
"countReset",
|
|
||||||
"group",
|
|
||||||
"groupEnd",
|
|
||||||
"table",
|
|
||||||
"dirxml",
|
|
||||||
"error",
|
|
||||||
"groupCollapsed",
|
|
||||||
"Console",
|
|
||||||
"profile",
|
|
||||||
"profileEnd",
|
|
||||||
"timeStamp",
|
|
||||||
"context"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"no-debugger": "error",
|
|
||||||
"no-empty": "off",
|
|
||||||
"no-eval": "error",
|
|
||||||
"no-fallthrough": "error",
|
|
||||||
"no-new-wrappers": "error",
|
|
||||||
"no-restricted-imports": ["error", "rxjs/Rx"],
|
|
||||||
"no-throw-literal": "error",
|
|
||||||
"no-undef-init": "error",
|
|
||||||
"no-underscore-dangle": "off",
|
|
||||||
"no-var": "error",
|
|
||||||
"prefer-const": "error",
|
|
||||||
"radix": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"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.
|
2.
|
||||||
3.
|
3.
|
||||||
|
|
||||||
**Expected behavior**
|
**Expected Behavior**
|
||||||
|
|
||||||
<!-- A clear and concise description of what you expected to happen. -->
|
<!-- A clear and concise description of what you expected to happen. -->
|
||||||
|
|
||||||
@ -48,6 +48,6 @@ Thank you for your understanding and cooperation!
|
|||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
|
||||||
**Additional context**
|
**Additional Context**
|
||||||
|
|
||||||
<!-- Add any other context about the problem here. -->
|
<!-- Add any other context about the problem here. -->
|
||||||
|
39
.github/workflows/build-code.yml
vendored
39
.github/workflows/build-code.yml
vendored
@ -1,39 +0,0 @@
|
|||||||
name: Build code
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node_version:
|
|
||||||
- 18
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node_version }}
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node_version }}
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: yarn format:check
|
|
||||||
|
|
||||||
- name: Execute tests
|
|
||||||
run: yarn test
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: yarn build:production
|
|
47
.github/workflows/docker-image.yml
vendored
47
.github/workflows/docker-image.yml
vendored
@ -4,9 +4,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- '*.*.*'
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_and_push:
|
build_and_push:
|
||||||
@ -15,14 +18,11 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker metadata
|
- name: Get Meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4
|
run: |
|
||||||
with:
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
images: ghostfolio/ghostfolio
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
@ -35,16 +35,35 @@ jobs:
|
|||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
registry: gitea.suda.codes
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
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' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: |
|
||||||
labels: ${{ steps.meta.output.labels }}
|
gitea.suda.codes/sudacode/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||||
cache-from: type=gha
|
gitea.suda.codes/sudacode/${{ steps.meta.outputs.REPO_NAME }}:latest
|
||||||
cache-to: type=gha,mode=max
|
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
|
||||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,12 +1,14 @@
|
|||||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
scripts/*
|
||||||
|
|
||||||
# compiled output
|
# compiled output
|
||||||
/out-tsc
|
/out-tsc
|
||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/.yarn
|
|
||||||
/node_modules
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
/.idea
|
/.idea
|
||||||
@ -28,15 +30,14 @@
|
|||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
.nx/cache
|
.nx/cache
|
||||||
|
.nx/workspace-data
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/dist
|
/dist
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
npm-debug.log
|
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
yarn-error.log
|
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
6
.husky/pre-commit
Normal file
6
.husky/pre-commit
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Run linting and stop the commit process if any errors are found
|
||||||
|
# --quiet suppresses warnings (temporary until all warnings are fixed)
|
||||||
|
npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1
|
||||||
|
|
||||||
|
# Check formatting on modified and uncommitted files, stop the commit if issues are found
|
||||||
|
npm run format:check --uncommitted || exit 1
|
@ -1,4 +1,5 @@
|
|||||||
/.nx/cache
|
/.nx/cache
|
||||||
|
/.nx/workspace-data
|
||||||
/apps/client/src/polyfills.ts
|
/apps/client/src/polyfills.ts
|
||||||
/dist
|
/dist
|
||||||
/test/import
|
/test/import
|
||||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"vim.highlightedyank.enable": true,
|
||||||
|
"vim.hlsearch": true,
|
||||||
|
"vim.leader": "<space>",
|
||||||
}
|
}
|
||||||
|
1549
CHANGELOG.md
1549
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,53 @@
|
|||||||
# Ghostfolio Development Guide
|
# Ghostfolio Development Guide
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
||||||
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Run `npm install`
|
||||||
|
1. Run `docker compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
|
1. Run `npm run database:setup` to initialize the database schema
|
||||||
|
1. Start the [server](#start-server) and the [client](#start-client)
|
||||||
|
1. Open https://localhost:4200/en in your browser
|
||||||
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
|
|
||||||
|
### Start Server
|
||||||
|
|
||||||
|
#### Debug
|
||||||
|
|
||||||
|
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
|
|
||||||
|
#### Serve
|
||||||
|
|
||||||
|
Run `npm run start:server`
|
||||||
|
|
||||||
|
### Start Client
|
||||||
|
|
||||||
|
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
||||||
|
|
||||||
|
### Start _Storybook_
|
||||||
|
|
||||||
|
Run `npm run start:storybook`
|
||||||
|
|
||||||
|
### Migrate Database
|
||||||
|
|
||||||
|
With the following command you can keep your database schema in sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run database:push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run `npm test`
|
||||||
|
|
||||||
## Experimental Features
|
## Experimental Features
|
||||||
|
|
||||||
New functionality can be enabled using a feature flag switch from the user settings.
|
New functionality can be enabled using a feature flag switch from the user settings.
|
||||||
@ -10,7 +58,11 @@ Remove permission in `UserService` using `without()`
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||||
|
|
||||||
|
## Component Library (_Storybook_)
|
||||||
|
|
||||||
|
https://ghostfol.io/development/storybook
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
@ -30,26 +82,35 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
|||||||
|
|
||||||
#### Upgrade
|
#### Upgrade
|
||||||
|
|
||||||
1. Run `yarn nx migrate latest`
|
1. Run `npx nx migrate latest`
|
||||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
1. Make sure `package.json` changes make sense and then run `npm install`
|
||||||
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
1. Run `npx nx migrate --run-migrations`
|
||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
#### Access database via GUI
|
#### Access database via GUI
|
||||||
|
|
||||||
Run `yarn database:gui`
|
Run `npm run database:gui`
|
||||||
|
|
||||||
https://www.prisma.io/studio
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `yarn database:push`
|
Run `npm run database:push`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||||
|
|
||||||
#### Create schema migration
|
#### Create schema migration
|
||||||
|
|
||||||
Run `yarn prisma migrate dev --name added_job_title`
|
Run `npm run prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
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"
|
||||||
|
```
|
||||||
|
59
Dockerfile
59
Dockerfile
@ -1,62 +1,67 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
|
|
||||||
# Only add basic files without the application itself to avoid rebuilding
|
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||||
# layers when files (package.json etc.) have not changed
|
|
||||||
COPY ./CHANGELOG.md CHANGELOG.md
|
|
||||||
COPY ./LICENSE LICENSE
|
|
||||||
COPY ./package.json package.json
|
|
||||||
COPY ./yarn.lock yarn.lock
|
|
||||||
COPY ./.yarnrc .yarnrc
|
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
|
||||||
|
|
||||||
RUN apt update && apt install -y \
|
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
make \
|
make \
|
||||||
openssl \
|
openssl \
|
||||||
python3 \
|
python3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN yarn install
|
|
||||||
|
# Only add basic files without the application itself to avoid rebuilding
|
||||||
|
# layers when files (package.json etc.) have not changed
|
||||||
|
COPY ./CHANGELOG.md CHANGELOG.md
|
||||||
|
COPY ./LICENSE LICENSE
|
||||||
|
COPY ./package.json package.json
|
||||||
|
COPY ./package-lock.json package-lock.json
|
||||||
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
RUN node 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 ./apps apps
|
||||||
|
COPY ./libs libs
|
||||||
|
COPY ./jest.config.ts jest.config.ts
|
||||||
|
COPY ./jest.preset.js jest.preset.js
|
||||||
|
COPY ./nx.json nx.json
|
||||||
|
COPY ./replace.build.mjs replace.build.mjs
|
||||||
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
|
|
||||||
RUN yarn build:production
|
RUN npm run build:production
|
||||||
|
|
||||||
# Prepare the dist image with additional node_modules
|
# Prepare the dist image with additional node_modules
|
||||||
WORKDIR /ghostfolio/dist/apps/api
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
# package.json was generated by the build process, however the original
|
# package.json was generated by the build process, however the original
|
||||||
# yarn.lock needs to be used to ensure the same versions
|
# package-lock.json needs to be used to ensure the same versions
|
||||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||||
|
|
||||||
RUN yarn
|
RUN npm install
|
||||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||||
|
|
||||||
# Overwrite the generated package.json with the original one to ensure having
|
# 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
|
COPY package.json /ghostfolio/dist/apps/api
|
||||||
RUN yarn database:generate-typings
|
RUN npm run database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:18-slim
|
FROM node:20-slim
|
||||||
RUN apt update && apt install -y \
|
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||||
curl \
|
curl \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
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
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "yarn", "start:production" ]
|
USER node
|
||||||
|
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||||
|
203
README.md
203
README.md
@ -7,13 +7,11 @@
|
|||||||
**Open Source Wealth Management Software**
|
**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) |
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
[**Blog**](https://ghostfol.io/en/blog) | [**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)
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
[](#contributing)
|
[](#contributing) [](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|
|
||||||
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -49,7 +47,7 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Import and export transactions
|
- ✅ Import and export transactions
|
||||||
@ -73,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
### Frontend
|
### 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
|
## Self-hosting
|
||||||
|
|
||||||
@ -87,23 +85,24 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | Type | Default Value | Description |
|
||||||
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
|
||||||
| `API_KEY_COINGECKO_DEMO` | | The _CoinGecko_ Demo API key |
|
| `API_KEY_COINGECKO_DEMO` | `string` (optional) | | The _CoinGecko_ Demo API key |
|
||||||
| `API_KEY_COINGECKO_PRO` | | The _CoinGecko_ Pro API |
|
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
|
||||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
|
||||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
|
||||||
| `REDIS_DB` | `0` | The database index of _Redis_ |
|
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
|
||||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
|
||||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
|
||||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
|
||||||
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
|
| `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
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -119,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):
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```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
|
#### b. Build and run environment
|
||||||
@ -127,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:
|
Run the following commands to build and start the Docker images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file ./.env -f docker/docker-compose.build.yml build
|
docker compose -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 up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
@ -138,63 +137,27 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
|||||||
|
|
||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Update the _Ghostfolio_ Docker image
|
||||||
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.
|
- 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)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
For detailed information on the environment setup and development process, please refer to [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
|
||||||
- Create a local copy of this Git repository (clone)
|
|
||||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
1. Run `yarn install`
|
|
||||||
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
|
||||||
1. Run `yarn database:setup` to initialize the database schema
|
|
||||||
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
|
||||||
1. Open http://localhost:4200/en in your browser
|
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
|
||||||
|
|
||||||
### Start Server
|
|
||||||
|
|
||||||
#### Debug
|
|
||||||
|
|
||||||
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
|
||||||
|
|
||||||
#### Serve
|
|
||||||
|
|
||||||
Run `yarn start:server`
|
|
||||||
|
|
||||||
### Start Client
|
|
||||||
|
|
||||||
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
|
||||||
|
|
||||||
### Start _Storybook_
|
|
||||||
|
|
||||||
Run `yarn start:storybook`
|
|
||||||
|
|
||||||
### Migrate Database
|
|
||||||
|
|
||||||
With the following command you can keep your database schema in sync:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn database:push
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run `yarn test`
|
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
@ -206,12 +169,36 @@ Set the header for each request as follows:
|
|||||||
"Authorization": "Bearer eyJh..."
|
"Authorization": "Bearer eyJh..."
|
||||||
```
|
```
|
||||||
|
|
||||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
|
||||||
|
|
||||||
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
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
|
### Import Activities
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
[Bearer Token](#authorization-bearer-token) for authorization
|
||||||
|
|
||||||
#### Request
|
#### Request
|
||||||
|
|
||||||
`POST http://localhost:3333/api/v1/import`
|
`POST http://localhost:3333/api/v1/import`
|
||||||
@ -236,17 +223,17 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
|
||||||
| accountId | string (`optional`) | Id of the account |
|
| `accountId` | `string` (optional) | Id of the account |
|
||||||
| comment | string (`optional`) | Comment of the activity |
|
| `comment` | `string` (optional) | Comment of the activity |
|
||||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| date | string | Date in the format `ISO-8601` |
|
| `date` | `string` | Date in the format `ISO-8601` |
|
||||||
| fee | number | Fee of the activity |
|
| `fee` | `number` | Fee of the activity |
|
||||||
| quantity | number | Quantity of the activity |
|
| `quantity` | `number` | Quantity of the activity |
|
||||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
|
||||||
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||||
| unitPrice | number | Price per unit of the activity |
|
| `unitPrice` | `number` | Price per unit of the activity |
|
||||||
|
|
||||||
#### Response
|
#### Response
|
||||||
|
|
||||||
@ -267,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
|
## Community Projects
|
||||||
|
|
||||||
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||||
@ -277,7 +296,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
|||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
|
||||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
@ -287,6 +306,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
|
|||||||
|
|
||||||
## License
|
## 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).
|
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: {}
|
||||||
|
}
|
||||||
|
];
|
@ -13,7 +13,6 @@ export default {
|
|||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000,
|
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
preset: '../../jest.preset.js'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -83,7 +83,7 @@ export class AccessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.accessService.createAccess({
|
return this.accessService.createAccess({
|
||||||
alias: data.alias || undefined,
|
alias: data.alias || undefined,
|
||||||
GranteeUser: data.granteeUserId
|
GranteeUser: data.granteeUserId
|
||||||
? { connect: { id: data.granteeUserId } }
|
? { connect: { id: data.granteeUserId } }
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
Body,
|
||||||
|
Post,
|
||||||
Delete,
|
Delete,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
@ -17,14 +20,44 @@ import { AccountBalance } from '@prisma/client';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AccountBalanceService } from './account-balance.service';
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||||
|
|
||||||
@Controller('account-balance')
|
@Controller('account-balance')
|
||||||
export class AccountBalanceController {
|
export class AccountBalanceController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
|
private readonly accountService: AccountService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createAccountBalance)
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async createAccountBalance(
|
||||||
|
@Body() data: CreateAccountBalanceDto
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
const account = await this.accountService.account({
|
||||||
|
id_userId: {
|
||||||
|
id: data.accountId,
|
||||||
|
userId: this.request.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
|
accountId: account.id,
|
||||||
|
balance: data.balance,
|
||||||
|
date: data.date,
|
||||||
|
userId: account.userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.deleteAccountBalance)
|
@HasPermission(permissions.deleteAccountBalance)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@ -32,10 +65,11 @@ export class AccountBalanceController {
|
|||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountBalance> {
|
): Promise<AccountBalance> {
|
||||||
const accountBalance = await this.accountBalanceService.accountBalance({
|
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||||
id
|
id,
|
||||||
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
|
if (!accountBalance) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -43,7 +77,8 @@ export class AccountBalanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accountBalanceService.deleteAccountBalance({
|
return this.accountBalanceService.deleteAccountBalance({
|
||||||
id
|
id: accountBalance.id,
|
||||||
|
userId: accountBalance.userId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service';
|
|||||||
controllers: [AccountBalanceController],
|
controllers: [AccountBalanceController],
|
||||||
exports: [AccountBalanceService],
|
exports: [AccountBalanceService],
|
||||||
imports: [ExchangeRateDataModule, PrismaModule],
|
imports: [ExchangeRateDataModule, PrismaModule],
|
||||||
providers: [AccountBalanceService]
|
providers: [AccountBalanceService, AccountService]
|
||||||
})
|
})
|
||||||
export class AccountBalanceModule {}
|
export class AccountBalanceModule {}
|
||||||
|
@ -1,14 +1,26 @@
|
|||||||
|
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
import {
|
||||||
|
AccountBalancesResponse,
|
||||||
|
Filter,
|
||||||
|
HistoricalDataItem
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { AccountBalance, Prisma } from '@prisma/client';
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountBalanceService {
|
export class AccountBalanceService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -24,32 +36,114 @@ export class AccountBalanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createAccountBalance(
|
public async createOrUpdateAccountBalance({
|
||||||
data: Prisma.AccountBalanceCreateInput
|
accountId,
|
||||||
): Promise<AccountBalance> {
|
balance,
|
||||||
return this.prismaService.accountBalance.create({
|
date,
|
||||||
data
|
userId
|
||||||
|
}: CreateAccountBalanceDto & {
|
||||||
|
userId: string;
|
||||||
|
}): Promise<AccountBalance> {
|
||||||
|
const accountBalance = await this.prismaService.accountBalance.upsert({
|
||||||
|
create: {
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
date: resetHours(parseISO(date)),
|
||||||
|
value: balance
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value: balance
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
accountId_date: {
|
||||||
|
accountId,
|
||||||
|
date: resetHours(parseISO(date))
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return accountBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAccountBalance(
|
public async deleteAccountBalance(
|
||||||
where: Prisma.AccountBalanceWhereUniqueInput
|
where: Prisma.AccountBalanceWhereUniqueInput
|
||||||
): Promise<AccountBalance> {
|
): Promise<AccountBalance> {
|
||||||
return this.prismaService.accountBalance.delete({
|
const accountBalance = await this.prismaService.accountBalance.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: where.userId as string
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return accountBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAccountBalanceItems({
|
||||||
|
filters,
|
||||||
|
userCurrency,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
userCurrency: string;
|
||||||
|
userId: string;
|
||||||
|
}): Promise<HistoricalDataItem[]> {
|
||||||
|
const { balances } = await this.getAccountBalances({
|
||||||
|
filters,
|
||||||
|
userCurrency,
|
||||||
|
userId,
|
||||||
|
withExcludedAccounts: false // TODO
|
||||||
|
});
|
||||||
|
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
|
||||||
|
{};
|
||||||
|
const lastBalancesByAccount: { [accountId: string]: Big } = {};
|
||||||
|
|
||||||
|
for (const { accountId, date, valueInBaseCurrency } of balances) {
|
||||||
|
const formattedDate = format(date, DATE_FORMAT);
|
||||||
|
|
||||||
|
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);
|
||||||
|
|
||||||
|
const totalBalance = getSum(Object.values(lastBalancesByAccount));
|
||||||
|
|
||||||
|
// Add or update the accumulated balance for this date
|
||||||
|
accumulatedBalancesByDate[formattedDate] = {
|
||||||
|
date: formattedDate,
|
||||||
|
value: totalBalance.toNumber()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(accumulatedBalancesByDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LogPerformance
|
||||||
public async getAccountBalances({
|
public async getAccountBalances({
|
||||||
filters,
|
filters,
|
||||||
user,
|
userCurrency,
|
||||||
|
userId,
|
||||||
withExcludedAccounts
|
withExcludedAccounts
|
||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
user: UserWithSettings;
|
userCurrency: string;
|
||||||
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<AccountBalancesResponse> {
|
}): Promise<AccountBalancesResponse> {
|
||||||
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
|
const where: Prisma.AccountBalanceWhereInput = { userId };
|
||||||
|
|
||||||
const accountFilter = filters?.find(({ type }) => {
|
const accountFilter = filters?.find(({ type }) => {
|
||||||
return type === 'ACCOUNT';
|
return type === 'ACCOUNT';
|
||||||
@ -80,10 +174,11 @@ export class AccountBalanceService {
|
|||||||
balances: balances.map((balance) => {
|
balances: balances.map((balance) => {
|
||||||
return {
|
return {
|
||||||
...balance,
|
...balance,
|
||||||
|
accountId: balance.Account.id,
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
balance.value,
|
balance.value,
|
||||||
balance.Account.currency,
|
balance.Account.currency,
|
||||||
user.Settings.settings.baseCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAccountBalanceDto {
|
||||||
|
@IsUUID()
|
||||||
|
accountId: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@IsISO8601()
|
||||||
|
date: string;
|
||||||
|
}
|
@ -2,7 +2,9 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
@ -26,6 +28,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -44,6 +47,7 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
@ -70,27 +74,33 @@ export class AccountController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.accountService.deleteAccount(
|
return this.accountService.deleteAccount({
|
||||||
{
|
|
||||||
id_userId: {
|
id_userId: {
|
||||||
id,
|
id,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
|
@Query('dataSource') filterByDataSource?: string,
|
||||||
|
@Query('symbol') filterBySymbol?: string
|
||||||
): Promise<Accounts> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
|
|
||||||
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByDataSource,
|
||||||
|
filterBySymbol
|
||||||
|
});
|
||||||
|
|
||||||
return this.portfolioService.getAccountsWithAggregations({
|
return this.portfolioService.getAccountsWithAggregations({
|
||||||
|
filters,
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
@ -124,7 +134,8 @@ export class AccountController {
|
|||||||
): Promise<AccountBalancesResponse> {
|
): Promise<AccountBalancesResponse> {
|
||||||
return this.accountBalanceService.getAccountBalances({
|
return this.accountBalanceService.getAccountBalances({
|
||||||
filters: [{ id, type: 'ACCOUNT' }],
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
user: this.request.user
|
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||||
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
@ -18,14 +17,13 @@ import { AccountService } from './account.service';
|
|||||||
exports: [AccountService],
|
exports: [AccountService],
|
||||||
imports: [
|
imports: [
|
||||||
AccountBalanceModule,
|
AccountBalanceModule,
|
||||||
|
ApiModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedactValuesInResponseModule
|
||||||
UserModule
|
|
||||||
],
|
],
|
||||||
providers: [AccountService]
|
providers: [AccountService]
|
||||||
})
|
})
|
||||||
|
@ -1,11 +1,21 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
|
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import Big from 'big.js';
|
import {
|
||||||
|
Account,
|
||||||
|
AccountBalance,
|
||||||
|
Order,
|
||||||
|
Platform,
|
||||||
|
Prisma
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
@ -14,6 +24,7 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
|||||||
export class AccountService {
|
export class AccountService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -51,13 +62,19 @@ export class AccountService {
|
|||||||
orderBy?: Prisma.AccountOrderByWithRelationInput;
|
orderBy?: Prisma.AccountOrderByWithRelationInput;
|
||||||
}): Promise<
|
}): Promise<
|
||||||
(Account & {
|
(Account & {
|
||||||
|
balances?: AccountBalance[];
|
||||||
Order?: Order[];
|
Order?: Order[];
|
||||||
Platform?: Platform;
|
Platform?: Platform;
|
||||||
})[]
|
})[]
|
||||||
> {
|
> {
|
||||||
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
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({
|
const accounts = await this.prismaService.account.findMany({
|
||||||
cursor,
|
cursor,
|
||||||
@ -71,7 +88,9 @@ export class AccountService {
|
|||||||
return accounts.map((account) => {
|
return accounts.map((account) => {
|
||||||
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
||||||
|
|
||||||
|
if (!isBalancesIncluded) {
|
||||||
delete account.balances;
|
delete account.balances;
|
||||||
|
}
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
});
|
});
|
||||||
@ -85,27 +104,38 @@ export class AccountService {
|
|||||||
data
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.prismaService.accountBalance.create({
|
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
data: {
|
accountId: account.id,
|
||||||
Account: {
|
balance: data.balance,
|
||||||
connect: {
|
date: format(new Date(), DATE_FORMAT),
|
||||||
id_userId: { id: account.id, userId: aUserId }
|
userId: aUserId
|
||||||
}
|
|
||||||
},
|
|
||||||
value: data.balance
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: account.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAccount(
|
public async deleteAccount(
|
||||||
where: Prisma.AccountWhereUniqueInput,
|
where: Prisma.AccountWhereUniqueInput
|
||||||
aUserId: string
|
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
return this.prismaService.account.delete({
|
const account = await this.prismaService.account.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: account.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||||
@ -153,12 +183,8 @@ export class AccountService {
|
|||||||
where.isExcluded = false;
|
where.isExcluded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
|
||||||
ACCOUNT: filtersByAccount,
|
return type;
|
||||||
ASSET_CLASS: filtersByAssetClass,
|
|
||||||
TAG: filtersByTag
|
|
||||||
} = groupBy(filters, (filter) => {
|
|
||||||
return filter.type;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filtersByAccount?.length > 0) {
|
if (filtersByAccount?.length > 0) {
|
||||||
@ -196,21 +222,26 @@ export class AccountService {
|
|||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
await this.prismaService.accountBalance.create({
|
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
data: {
|
accountId: data.id as string,
|
||||||
Account: {
|
balance: data.balance as number,
|
||||||
connect: {
|
date: format(new Date(), DATE_FORMAT),
|
||||||
id_userId: where.id_userId
|
userId: aUserId
|
||||||
}
|
|
||||||
},
|
|
||||||
value: <number>data.balance
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.prismaService.account.update({
|
const account = await this.prismaService.account.update({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventEmitter.emit(
|
||||||
|
PortfolioChangedEvent.getName(),
|
||||||
|
new PortfolioChangedEvent({
|
||||||
|
userId: account.userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateAccountBalance({
|
public async updateAccountBalance({
|
||||||
@ -242,17 +273,11 @@ export class AccountService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (amountInCurrencyOfAccount) {
|
if (amountInCurrencyOfAccount) {
|
||||||
await this.accountBalanceService.createAccountBalance({
|
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||||
date,
|
accountId,
|
||||||
Account: {
|
|
||||||
connect: {
|
|
||||||
id_userId: {
|
|
||||||
userId,
|
userId,
|
||||||
id: accountId
|
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
|
||||||
}
|
date: date.toISOString()
|
||||||
}
|
|
||||||
},
|
|
||||||
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -20,7 +21,7 @@ export class CreateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -35,6 +36,6 @@ export class CreateAccountDto {
|
|||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ValidateIf((object, value) => value !== null)
|
@ValidateIf((_object, value) => value !== null)
|
||||||
platformId: string | null;
|
platformId: string | null;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@ -20,7 +21,7 @@ export class UpdateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -34,6 +35,6 @@ export class UpdateAccountDto {
|
|||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ValidateIf((object, value) => value !== null)
|
@ValidateIf((_object, value) => value !== null)
|
||||||
platformId: string | null;
|
platformId: string | null;
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,23 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.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 { 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 { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||||
import {
|
import {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
getAssetProfileIdentifier,
|
|
||||||
resetHours
|
|
||||||
} from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
|
AdminUsers,
|
||||||
EnhancedSymbolProfile
|
EnhancedSymbolProfile
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -82,19 +82,21 @@ export class AdminController {
|
|||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const assetProfileIdentifiers =
|
||||||
|
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -107,19 +109,21 @@ export class AdminController {
|
|||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const assetProfileIdentifiers =
|
||||||
|
await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -138,10 +142,11 @@ export class AdminController {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
|
||||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -209,6 +214,9 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@ -235,14 +243,19 @@ export class AdminController {
|
|||||||
return { 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) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'AdminController');
|
||||||
|
|
||||||
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('market-data/:dataSource/:symbol')
|
@Post('market-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@ -321,15 +334,14 @@ export class AdminController {
|
|||||||
@Patch('profile-data/:dataSource/:symbol')
|
@Patch('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async patchAssetProfileData(
|
public async patchAssetProfileData(
|
||||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
@Body() assetProfile: UpdateAssetProfileDto,
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<EnhancedSymbolProfile> {
|
): Promise<EnhancedSymbolProfile> {
|
||||||
return this.adminService.patchAssetProfileData({
|
return this.adminService.patchAssetProfileData(
|
||||||
...assetProfileData,
|
{ dataSource, symbol },
|
||||||
dataSource,
|
assetProfile
|
||||||
symbol
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@ -339,6 +351,19 @@ export class AdminController {
|
|||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Body() data: PropertyDto
|
@Body() data: PropertyDto
|
||||||
) {
|
) {
|
||||||
return await this.adminService.putSetting(key, data.value);
|
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,12 +1,14 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.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 { 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 { 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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -18,16 +20,18 @@ import { QueueModule } from './queue/queue.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ApiModule,
|
ApiModule,
|
||||||
|
BenchmarkModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
SubscriptionModule,
|
SymbolProfileModule,
|
||||||
SymbolProfileModule
|
TransformDataSourceInRequestModule
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminService],
|
providers: [AdminService],
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
@ -8,42 +9,58 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
PROPERTY_CURRENCIES,
|
PROPERTY_CURRENCIES,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
getCurrencyFromSymbol,
|
||||||
|
isCurrency
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
Filter,
|
AdminUsers,
|
||||||
UniqueAsset
|
AssetProfileIdentifier,
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
Filter
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
HttpException,
|
||||||
|
Injectable,
|
||||||
|
Logger
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
DataSource,
|
DataSource,
|
||||||
Prisma,
|
Prisma,
|
||||||
|
PrismaClient,
|
||||||
Property,
|
Property,
|
||||||
SymbolProfile
|
SymbolProfile
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -51,7 +68,9 @@ export class AdminService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
}: AssetProfileIdentifier & { currency?: string }): Promise<
|
||||||
|
SymbolProfile | never
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
if (dataSource === 'MANUAL') {
|
if (dataSource === 'MANUAL') {
|
||||||
return this.symbolProfileService.add({
|
return this.symbolProfileService.add({
|
||||||
@ -71,7 +90,7 @@ export class AdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.symbolProfileService.add(
|
return this.symbolProfileService.add(
|
||||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -88,41 +107,44 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: AssetProfileIdentifier) {
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
|
|
||||||
|
const currency = getCurrencyFromSymbol(symbol);
|
||||||
|
const customCurrencies = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_CURRENCIES
|
||||||
|
)) as string[];
|
||||||
|
|
||||||
|
if (customCurrencies.includes(currency)) {
|
||||||
|
const updatedCustomCurrencies = customCurrencies.filter(
|
||||||
|
(customCurrency) => {
|
||||||
|
return customCurrency !== currency;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.putSetting(
|
||||||
|
PROPERTY_CURRENCIES,
|
||||||
|
JSON.stringify(updatedCustomCurrencies)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
const [settings, transactionCount, userCount] = await Promise.all([
|
||||||
exchangeRates: this.exchangeRateDataService
|
this.propertyService.get(),
|
||||||
.getCurrencies()
|
this.prismaService.order.count(),
|
||||||
.filter((currency) => {
|
this.countUsersWithAnalytics()
|
||||||
return currency !== DEFAULT_CURRENCY;
|
]);
|
||||||
})
|
|
||||||
.map((currency) => {
|
|
||||||
const label1 = DEFAULT_CURRENCY;
|
|
||||||
const label2 = currency;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label1,
|
settings,
|
||||||
label2,
|
transactionCount,
|
||||||
dataSource:
|
userCount,
|
||||||
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(),
|
|
||||||
version: environment.version
|
version: environment.version
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -146,7 +168,16 @@ export class AdminService {
|
|||||||
[{ symbol: 'asc' }];
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
if (presetId === 'CURRENCIES') {
|
if (presetId === 'BENCHMARKS') {
|
||||||
|
const benchmarkAssetProfiles =
|
||||||
|
await this.benchmarkService.getBenchmarkAssetProfiles();
|
||||||
|
|
||||||
|
where.id = {
|
||||||
|
in: benchmarkAssetProfiles.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
} else if (presetId === 'CURRENCIES') {
|
||||||
return this.getMarketDataForCurrencies();
|
return this.getMarketDataForCurrencies();
|
||||||
} else if (
|
} else if (
|
||||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||||
@ -196,8 +227,11 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let [assetProfiles, count] = await Promise.all([
|
const extendedPrismaClient = this.getExtendedPrismaClient();
|
||||||
this.prismaService.symbolProfile.findMany({
|
|
||||||
|
try {
|
||||||
|
const symbolProfileResult = await Promise.all([
|
||||||
|
extendedPrismaClient.symbolProfile.findMany({
|
||||||
orderBy,
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
@ -212,6 +246,9 @@ export class AdminService {
|
|||||||
countries: true,
|
countries: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
|
id: true,
|
||||||
|
isActive: true,
|
||||||
|
isUsedByUsersWithSubscription: true,
|
||||||
name: true,
|
name: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
@ -220,14 +257,49 @@ export class AdminService {
|
|||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
sectors: true,
|
sectors: true,
|
||||||
symbol: true
|
symbol: true,
|
||||||
|
SymbolProfileOverrides: true
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
this.prismaService.symbolProfile.count({ where })
|
this.prismaService.symbolProfile.count({ where })
|
||||||
]);
|
]);
|
||||||
|
const assetProfiles = symbolProfileResult[0];
|
||||||
|
let count = symbolProfileResult[1];
|
||||||
|
|
||||||
let marketData: AdminMarketDataItem[] = assetProfiles.map(
|
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,
|
_count,
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
@ -235,12 +307,21 @@ export class AdminService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
id,
|
||||||
|
isActive,
|
||||||
|
isUsedByUsersWithSubscription,
|
||||||
name,
|
name,
|
||||||
Order,
|
Order,
|
||||||
sectors,
|
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 =
|
const marketDataItemCount =
|
||||||
marketDataItems.find((marketDataItem) => {
|
marketDataItems.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -248,7 +329,35 @@ export class AdminService {
|
|||||||
marketDataItem.symbol === symbol
|
marketDataItem.symbol === symbol
|
||||||
);
|
);
|
||||||
})?._count ?? 0;
|
})?._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 {
|
return {
|
||||||
assetClass,
|
assetClass,
|
||||||
@ -257,14 +366,19 @@ export class AdminService {
|
|||||||
currency,
|
currency,
|
||||||
countriesCount,
|
countriesCount,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
id,
|
||||||
|
isActive,
|
||||||
|
lastMarketPrice,
|
||||||
name,
|
name,
|
||||||
symbol,
|
symbol,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
sectorsCount,
|
sectorsCount,
|
||||||
activitiesCount: _count.Order,
|
activitiesCount: _count.Order,
|
||||||
date: Order?.[0]?.date
|
date: Order?.[0]?.date,
|
||||||
|
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (presetId) {
|
if (presetId) {
|
||||||
@ -285,12 +399,27 @@ export class AdminService {
|
|||||||
count,
|
count,
|
||||||
marketData
|
marketData
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
await extendedPrismaClient.$disconnect();
|
||||||
|
|
||||||
|
Logger.debug('Disconnect extended prisma client', 'AdminService');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
|
||||||
|
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||||
|
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||||
|
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||||
|
|
||||||
|
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||||
|
currency = getCurrencyFromSymbol(symbol);
|
||||||
|
({ activitiesCount, dateOfFirstActivity } =
|
||||||
|
await this.orderService.getStatisticsByCurrency(currency));
|
||||||
|
}
|
||||||
|
|
||||||
const [[assetProfile], marketData] = await Promise.all([
|
const [[assetProfile], marketData] = await Promise.all([
|
||||||
this.symbolProfileService.getSymbolProfiles([
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -309,50 +438,160 @@ export class AdminService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (assetProfile) {
|
||||||
|
assetProfile.dataProviderInfo = this.dataProviderService
|
||||||
|
.getDataProvider(assetProfile.dataSource)
|
||||||
|
.getDataProviderInfo();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketData,
|
marketData,
|
||||||
assetProfile: assetProfile ?? {
|
assetProfile: assetProfile ?? {
|
||||||
|
activitiesCount,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
dateOfFirstActivity,
|
||||||
symbol,
|
symbol,
|
||||||
currency: '-'
|
isActive: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async patchAssetProfileData({
|
public async getUsers({
|
||||||
assetClass,
|
skip,
|
||||||
assetSubClass,
|
take = Number.MAX_SAFE_INTEGER
|
||||||
comment,
|
}: {
|
||||||
countries,
|
skip?: number;
|
||||||
currency,
|
take?: number;
|
||||||
dataSource,
|
}): Promise<AdminUsers> {
|
||||||
name,
|
const [count, users] = await Promise.all([
|
||||||
scraperConfiguration,
|
this.countUsersWithAnalytics(),
|
||||||
sectors,
|
this.getUsersWithAnalytics({ skip, take })
|
||||||
symbol,
|
]);
|
||||||
symbolMapping
|
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
|
||||||
assetClass,
|
|
||||||
assetSubClass,
|
|
||||||
comment,
|
|
||||||
countries,
|
|
||||||
currency,
|
|
||||||
dataSource,
|
|
||||||
name,
|
|
||||||
scraperConfiguration,
|
|
||||||
sectors,
|
|
||||||
symbol,
|
|
||||||
symbolMapping
|
|
||||||
});
|
|
||||||
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
return { count, users };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async patchAssetProfileData(
|
||||||
|
{ dataSource, symbol }: AssetProfileIdentifier,
|
||||||
{
|
{
|
||||||
dataSource,
|
assetClass,
|
||||||
symbol
|
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
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return symbolProfile;
|
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,
|
||||||
|
symbolMapping,
|
||||||
|
...(dataSource === 'MANUAL'
|
||||||
|
? { assetClass, assetSubClass, name, url }
|
||||||
|
: {
|
||||||
|
SymbolProfileOverrides: {
|
||||||
|
upsert: {
|
||||||
|
create: symbolProfileOverrides,
|
||||||
|
update: symbolProfileOverrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.symbolProfileService.updateSymbolProfile(
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
updatedSymbolProfile
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource: dataSource as DataSource,
|
||||||
|
symbol: symbol as string
|
||||||
|
}
|
||||||
|
])?.[0];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async putSetting(key: string, value: string) {
|
public async putSetting(key: string, value: string) {
|
||||||
@ -373,15 +612,124 @@ export class AdminService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
private async countUsersWithAnalytics() {
|
||||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
let where: Prisma.UserWhereInput;
|
||||||
_count: true,
|
|
||||||
by: ['dataSource', 'symbol']
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
where = {
|
||||||
|
NOT: {
|
||||||
|
Analytics: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prismaService.user.count({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExtendedPrismaClient() {
|
||||||
|
Logger.debug('Connect extended prisma client', 'AdminService');
|
||||||
|
|
||||||
|
const symbolProfileExtension = Prisma.defineExtension((client) => {
|
||||||
|
return client.$extends({
|
||||||
|
result: {
|
||||||
|
symbolProfile: {
|
||||||
|
isUsedByUsersWithSubscription: {
|
||||||
|
compute: async ({ id }) => {
|
||||||
|
const { _count } =
|
||||||
|
await this.prismaService.symbolProfile.findUnique({
|
||||||
|
select: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
Order: {
|
||||||
|
where: {
|
||||||
|
User: {
|
||||||
|
subscriptions: {
|
||||||
|
some: {
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
|
return _count.Order > 0;
|
||||||
.getCurrencyPairs()
|
}
|
||||||
.map(({ dataSource, symbol }) => {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return new PrismaClient().$extends(symbolProfileExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||||
|
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
|
||||||
|
|
||||||
|
const [lastMarketPrices, marketDataItems] = await Promise.all([
|
||||||
|
this.prismaService.marketData.findMany({
|
||||||
|
distinct: ['dataSource', 'symbol'],
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
select: {
|
||||||
|
dataSource: true,
|
||||||
|
marketPrice: true,
|
||||||
|
symbol: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource: {
|
||||||
|
in: currencyPairs.map(({ dataSource }) => {
|
||||||
|
return dataSource;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
symbol: {
|
||||||
|
in: currencyPairs.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.prismaService.marketData.groupBy({
|
||||||
|
_count: true,
|
||||||
|
by: ['dataSource', 'symbol']
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lastMarketPriceMap = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
|
||||||
|
lastMarketPriceMap.set(
|
||||||
|
getAssetProfileIdentifier({ dataSource, symbol }),
|
||||||
|
marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
|
||||||
|
async ({ dataSource, symbol }) => {
|
||||||
|
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||||
|
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||||
|
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||||
|
|
||||||
|
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||||
|
currency = getCurrencyFromSymbol(symbol);
|
||||||
|
({ activitiesCount, dateOfFirstActivity } =
|
||||||
|
await this.orderService.getStatisticsByCurrency(currency));
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMarketPrice = lastMarketPriceMap.get(
|
||||||
|
getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketDataItems.find((marketDataItem) => {
|
marketDataItems.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -391,30 +739,44 @@ export class AdminService {
|
|||||||
})?._count ?? 0;
|
})?._count ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
activitiesCount,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
lastMarketPrice,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
symbol,
|
symbol,
|
||||||
assetClass: 'CASH',
|
assetClass: AssetClass.LIQUIDITY,
|
||||||
|
assetSubClass: AssetSubClass.CASH,
|
||||||
countriesCount: 0,
|
countriesCount: 0,
|
||||||
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
date: dateOfFirstActivity,
|
||||||
|
id: undefined,
|
||||||
|
isActive: true,
|
||||||
name: symbol,
|
name: symbol,
|
||||||
sectorsCount: 0
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const marketData = await Promise.all(marketDataPromise);
|
||||||
return { marketData, count: marketData.length };
|
return { marketData, count: marketData.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics({
|
||||||
let orderBy: any = {
|
skip,
|
||||||
|
take
|
||||||
|
}: {
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
}): Promise<AdminUsers['users']> {
|
||||||
|
let orderBy: Prisma.UserOrderByWithRelationInput = {
|
||||||
createdAt: 'desc'
|
createdAt: 'desc'
|
||||||
};
|
};
|
||||||
let where;
|
let where: Prisma.UserWhereInput;
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
orderBy = {
|
orderBy = {
|
||||||
Analytics: {
|
Analytics: {
|
||||||
updatedAt: 'desc'
|
lastRequestAt: 'desc'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
where = {
|
where = {
|
||||||
@ -426,6 +788,8 @@ export class AdminService {
|
|||||||
|
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
orderBy,
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
@ -435,40 +799,50 @@ export class AdminService {
|
|||||||
select: {
|
select: {
|
||||||
activityCount: true,
|
activityCount: true,
|
||||||
country: true,
|
country: true,
|
||||||
|
dataProviderGhostfolioDailyRequests: true,
|
||||||
updatedAt: true
|
updatedAt: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
id: true,
|
id: true,
|
||||||
Subscription: true
|
role: true,
|
||||||
|
subscriptions: {
|
||||||
|
orderBy: {
|
||||||
|
expiresAt: 'desc'
|
||||||
},
|
},
|
||||||
take: 30
|
take: 1,
|
||||||
|
where: {
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, role, subscriptions }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics
|
const engagement = Analytics
|
||||||
? Analytics.activityCount / daysSinceRegistration
|
? Analytics.activityCount / daysSinceRegistration
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const subscription = this.configurationService.get(
|
const subscription =
|
||||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
)
|
subscriptions?.length > 0
|
||||||
? this.subscriptionService.getSubscription({
|
? subscriptions[0]
|
||||||
createdAt,
|
|
||||||
subscriptions: Subscription
|
|
||||||
})
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
|
role,
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
country: Analytics?.country,
|
country: Analytics?.country,
|
||||||
|
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
|
||||||
lastActivity: Analytics?.updatedAt,
|
lastActivity: Analytics?.updatedAt,
|
||||||
transactionCount: _count.Order || 0
|
transactionCount: _count.Order || 0
|
||||||
};
|
};
|
||||||
|
@ -26,7 +26,7 @@ export class QueueController {
|
|||||||
public async deleteJobs(
|
public async deleteJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
|
||||||
return this.queueService.deleteJobs({ status });
|
return this.queueService.deleteJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ export class QueueController {
|
|||||||
public async getJobs(
|
public async getJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<AdminJobs> {
|
): Promise<AdminJobs> {
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
|
||||||
return this.queueService.getJobs({ status });
|
return this.queueService.getJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,4 +46,11 @@ export class QueueController {
|
|||||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||||
return this.queueService.deleteJob(id);
|
return this.queueService.deleteJob(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('job/:id/execute')
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async executeJob(@Param('id') id: string): Promise<void> {
|
||||||
|
return this.queueService.executeJob(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,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';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -7,7 +8,7 @@ import { QueueService } from './queue.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [QueueController],
|
controllers: [QueueController],
|
||||||
imports: [DataGatheringModule],
|
imports: [DataGatheringModule, PortfolioSnapshotQueueModule],
|
||||||
providers: [QueueService]
|
providers: [QueueService]
|
||||||
})
|
})
|
||||||
export class QueueModule {}
|
export class QueueModule {}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE,
|
DATA_GATHERING_QUEUE,
|
||||||
|
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
|
||||||
QUEUE_JOB_STATUS_LIST
|
QUEUE_JOB_STATUS_LIST
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
@ -12,11 +13,19 @@ import { JobStatus, Queue } from 'bull';
|
|||||||
export class QueueService {
|
export class QueueService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@InjectQueue(DATA_GATHERING_QUEUE)
|
@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) {
|
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({
|
public async deleteJobs({
|
||||||
@ -25,13 +34,23 @@ export class QueueService {
|
|||||||
status?: JobStatus[];
|
status?: JobStatus[];
|
||||||
}) {
|
}) {
|
||||||
for (const statusItem of status) {
|
for (const statusItem of status) {
|
||||||
await this.dataGatheringQueue.clean(
|
const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem;
|
||||||
300,
|
|
||||||
statusItem === 'waiting' ? 'wait' : statusItem
|
await this.dataGatheringQueue.clean(300, queueStatus);
|
||||||
);
|
await this.portfolioSnapshotQueue.clean(300, queueStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async executeJob(aId: string) {
|
||||||
|
let job = await this.dataGatheringQueue.getJob(aId);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
job = await this.portfolioSnapshotQueue.getJob(aId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return job?.promote();
|
||||||
|
}
|
||||||
|
|
||||||
public async getJobs({
|
public async getJobs({
|
||||||
limit = 1000,
|
limit = 1000,
|
||||||
status = QUEUE_JOB_STATUS_LIST
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
@ -39,10 +58,13 @@ export class QueueService {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
status?: JobStatus[];
|
status?: JobStatus[];
|
||||||
}): Promise<AdminJobs> {
|
}): 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(
|
const jobsWithState = await Promise.all(
|
||||||
jobs
|
[...dataGatheringJobs, ...portfolioSnapshotJobs]
|
||||||
.filter((job) => {
|
.filter((job) => {
|
||||||
return job;
|
return job;
|
||||||
})
|
})
|
||||||
@ -54,6 +76,7 @@ export class QueueService {
|
|||||||
finishedOn: job.finishedOn,
|
finishedOn: job.finishedOn,
|
||||||
id: job.id,
|
id: job.id,
|
||||||
name: job.name,
|
name: job.name,
|
||||||
|
opts: job.opts,
|
||||||
stacktrace: job.stacktrace,
|
stacktrace: job.stacktrace,
|
||||||
state: await job.getState(),
|
state: await job.getState(),
|
||||||
timestamp: job.timestamp
|
timestamp: job.timestamp
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
|
||||||
|
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString,
|
||||||
|
IsUrl
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
@ -17,20 +20,28 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
assetSubClass?: AssetSubClass;
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
countries?: Prisma.InputJsonArray;
|
countries?: Prisma.InputJsonArray;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEnum(DataSource)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
dataSource?: DataSource;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@ -41,9 +52,20 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
sectors?: Prisma.InputJsonArray;
|
sectors?: Prisma.InputJsonArray;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
symbol?: string;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
symbolMapping?: {
|
symbolMapping?: {
|
||||||
[dataProvider: string]: string;
|
[dataProvider: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({
|
||||||
|
protocols: ['https'],
|
||||||
|
require_protocol: true
|
||||||
|
})
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Type } from 'class-transformer';
|
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';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
|
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
||||||
|
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
@ -14,6 +16,7 @@ import {
|
|||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
@ -23,10 +26,19 @@ import { AccessModule } from './access/access.module';
|
|||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
|
import { AssetModule } from './asset/asset.module';
|
||||||
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
|
||||||
import { CacheModule } from './cache/cache.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 { TagsModule } from './endpoints/tags/tags.module';
|
||||||
|
import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
|
||||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
@ -40,17 +52,21 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
|||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
import { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
import { SymbolModule } from './symbol/symbol.module';
|
import { SymbolModule } from './symbol/symbol.module';
|
||||||
import { TagModule } from './tag/tag.module';
|
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [AppController],
|
||||||
imports: [
|
imports: [
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AccessModule,
|
AccessModule,
|
||||||
AccountModule,
|
AccountModule,
|
||||||
|
AiModule,
|
||||||
|
ApiKeysModule,
|
||||||
|
AssetModule,
|
||||||
|
AssetsModule,
|
||||||
AuthDeviceModule,
|
AuthDeviceModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
BenchmarkModule,
|
BenchmarksModule,
|
||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
redis: {
|
redis: {
|
||||||
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
||||||
@ -64,18 +80,24 @@ import { UserModule } from './user/user.module';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
EventsModule,
|
||||||
ExchangeRateModule,
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
|
GhostfolioModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
LogoModule,
|
LogoModule,
|
||||||
|
MarketDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
|
PortfolioSnapshotQueueModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
PublicModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
@ -105,11 +127,11 @@ import { UserModule } from './user/user.module';
|
|||||||
SitemapModule,
|
SitemapModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
TagModule,
|
TagsModule,
|
||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule,
|
||||||
|
WatchlistModule
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
29
apps/api/src/app/asset/asset.controller.ts
Normal file
29
apps/api/src/app/asset/asset.controller.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
|
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
|
||||||
|
@Controller('asset')
|
||||||
|
export class AssetController {
|
||||||
|
public constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getAsset(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
const { assetProfile, marketData } =
|
||||||
|
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketData,
|
||||||
|
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
17
apps/api/src/app/asset/asset.module.ts
Normal file
17
apps/api/src/app/asset/asset.module.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AssetController } from './asset.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AssetController],
|
||||||
|
imports: [
|
||||||
|
AdminModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AssetModule {}
|
@ -1,6 +1,5 @@
|
|||||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -9,7 +8,6 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthDeviceController],
|
controllers: [AuthDeviceController],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -6,10 +5,7 @@ import { AuthDevice, Prisma } from '@prisma/client';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthDeviceService {
|
export class AuthDeviceService {
|
||||||
public constructor(
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly prismaService: PrismaService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async authDevice(
|
public async authDevice(
|
||||||
where: Prisma.AuthDeviceWhereUniqueInput
|
where: Prisma.AuthDeviceWhereUniqueInput
|
||||||
|
76
apps/api/src/app/auth/api-key.strategy.ts
Normal file
76
apps/api/src/app/auth/api-key.strategy.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config';
|
||||||
|
import { hasRole } from '@ghostfolio/common/permissions';
|
||||||
|
|
||||||
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyStrategy extends PassportStrategy(
|
||||||
|
HeaderAPIKeyStrategy,
|
||||||
|
'api-key'
|
||||||
|
) {
|
||||||
|
public constructor(
|
||||||
|
private readonly apiKeyService: ApiKeyService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly userService: UserService
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' },
|
||||||
|
true,
|
||||||
|
async (apiKey: string, done: (error: any, user?: any) => void) => {
|
||||||
|
try {
|
||||||
|
const user = await this.validateApiKey(apiKey);
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
if (hasRole(user, 'INACTIVE')) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prismaService.analytics.upsert({
|
||||||
|
create: { User: { connect: { id: user.id } } },
|
||||||
|
update: {
|
||||||
|
activityCount: { increment: 1 },
|
||||||
|
lastRequestAt: new Date()
|
||||||
|
},
|
||||||
|
where: { userId: user.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
done(null, user);
|
||||||
|
} catch (error) {
|
||||||
|
done(error, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateApiKey(apiKey: string) {
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||||
|
StatusCodes.UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await this.apiKeyService.getUserByApiKey(apiKey);
|
||||||
|
|
||||||
|
return this.userService.user({ id });
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||||
|
StatusCodes.UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -14,12 +14,12 @@ import {
|
|||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
VERSION_NEUTRAL,
|
Version,
|
||||||
Version
|
VERSION_NEUTRAL
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Request, Response } from 'express';
|
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 { AuthService } from './auth.service';
|
||||||
import {
|
import {
|
||||||
@ -85,7 +85,7 @@ export class AuthController {
|
|||||||
@Res() response: Response
|
@Res() response: Response
|
||||||
) {
|
) {
|
||||||
// Handles the Google OAuth2 callback
|
// Handles the Google OAuth2 callback
|
||||||
const jwt: string = (<any>request.user).jwt;
|
const jwt: string = (request.user as any).jwt;
|
||||||
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
response.redirect(
|
response.redirect(
|
||||||
@ -130,10 +130,7 @@ export class AuthController {
|
|||||||
public async verifyAttestation(
|
public async verifyAttestation(
|
||||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||||
) {
|
) {
|
||||||
return this.webAuthService.verifyAttestation(
|
return this.webAuthService.verifyAttestation(body.credential);
|
||||||
body.deviceName,
|
|
||||||
body.credential
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('webauthn/generate-assertion-options')
|
@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 { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.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 { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
import { ApiKeyStrategy } from './api-key.strategy';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { GoogleStrategy } from './google.strategy';
|
import { GoogleStrategy } from './google.strategy';
|
||||||
@ -28,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
ApiKeyService,
|
||||||
|
ApiKeyStrategy,
|
||||||
AuthDeviceService,
|
AuthDeviceService,
|
||||||
AuthService,
|
AuthService,
|
||||||
GoogleStrategy,
|
GoogleStrategy,
|
||||||
|
@ -20,10 +20,10 @@ export class AuthService {
|
|||||||
public async validateAnonymousLogin(accessToken: string): Promise<string> {
|
public async validateAnonymousLogin(accessToken: string): Promise<string> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const hashedAccessToken = this.userService.createAccessToken(
|
const hashedAccessToken = this.userService.createAccessToken({
|
||||||
accessToken,
|
password: accessToken,
|
||||||
this.configurationService.get('ACCESS_TOKEN_SALT')
|
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||||
);
|
});
|
||||||
|
|
||||||
const [user] = await this.userService.users({
|
const [user] = await this.userService.users({
|
||||||
where: { accessToken: hashedAccessToken }
|
where: { accessToken: hashedAccessToken }
|
||||||
|
@ -3,7 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
import { Strategy } from 'passport-google-oauth20';
|
import { Profile, Strategy } from 'passport-google-oauth20';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ import { AuthService } from './auth.service';
|
|||||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
readonly configurationService: ConfigurationService
|
configurationService: ConfigurationService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
callbackURL: `${configurationService.get(
|
callbackURL: `${configurationService.get(
|
||||||
@ -20,28 +20,24 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
||||||
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
||||||
passReqToCallback: true,
|
passReqToCallback: true,
|
||||||
scope: ['email', 'profile']
|
scope: ['profile']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validate(
|
public async validate(
|
||||||
request: any,
|
_request: any,
|
||||||
token: string,
|
_token: string,
|
||||||
refreshToken: string,
|
_refreshToken: string,
|
||||||
profile,
|
profile: Profile,
|
||||||
done: Function,
|
done: Function
|
||||||
done2: Function
|
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const jwt: string = await this.authService.validateOAuthLogin({
|
const jwt = await this.authService.validateOAuthLogin({
|
||||||
provider: Provider.GOOGLE,
|
provider: Provider.GOOGLE,
|
||||||
thirdPartyId: profile.id
|
thirdPartyId: profile.id
|
||||||
});
|
});
|
||||||
const user = {
|
|
||||||
jwt
|
|
||||||
};
|
|
||||||
|
|
||||||
done(null, user);
|
done(null, { jwt });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'GoogleStrategy');
|
Logger.error(error, 'GoogleStrategy');
|
||||||
done(error, false);
|
done(error, false);
|
||||||
|
@ -198,12 +198,12 @@ export interface AuthenticatorAssertionResponseJSON
|
|||||||
/**
|
/**
|
||||||
* A WebAuthn-compatible device and the information needed to verify assertions by it
|
* A WebAuthn-compatible device and the information needed to verify assertions by it
|
||||||
*/
|
*/
|
||||||
export declare type AuthenticatorDevice = {
|
export declare interface AuthenticatorDevice {
|
||||||
credentialPublicKey: Buffer;
|
credentialPublicKey: Buffer;
|
||||||
credentialID: Buffer;
|
credentialID: Buffer;
|
||||||
counter: number;
|
counter: number;
|
||||||
transports?: AuthenticatorTransport[];
|
transports?: AuthenticatorTransport[];
|
||||||
};
|
}
|
||||||
/**
|
/**
|
||||||
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
|
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
|
||||||
*/
|
*/
|
||||||
|
@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
|
import { hasRole } from '@ghostfolio/common/permissions';
|
||||||
|
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
if (hasRole(user, 'INACTIVE')) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const country =
|
const country =
|
||||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||||
|
|
||||||
@ -37,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
update: {
|
update: {
|
||||||
country,
|
country,
|
||||||
activityCount: { increment: 1 },
|
activityCount: { increment: 1 },
|
||||||
updatedAt: new Date()
|
lastRequestAt: new Date()
|
||||||
},
|
},
|
||||||
where: { userId: user.id }
|
where: { userId: user.id }
|
||||||
});
|
});
|
||||||
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
throw '';
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) {
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||||
|
StatusCodes.UNAUTHORIZED
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
throw new UnauthorizedException('unauthorized', err.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,17 +13,18 @@ import {
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import {
|
import {
|
||||||
|
generateAuthenticationOptions,
|
||||||
GenerateAuthenticationOptionsOpts,
|
GenerateAuthenticationOptionsOpts,
|
||||||
|
generateRegistrationOptions,
|
||||||
GenerateRegistrationOptionsOpts,
|
GenerateRegistrationOptionsOpts,
|
||||||
VerifiedAuthenticationResponse,
|
VerifiedAuthenticationResponse,
|
||||||
VerifiedRegistrationResponse,
|
VerifiedRegistrationResponse,
|
||||||
VerifyAuthenticationResponseOpts,
|
|
||||||
VerifyRegistrationResponseOpts,
|
|
||||||
generateAuthenticationOptions,
|
|
||||||
generateRegistrationOptions,
|
|
||||||
verifyAuthenticationResponse,
|
verifyAuthenticationResponse,
|
||||||
verifyRegistrationResponse
|
VerifyAuthenticationResponseOpts,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
VerifyRegistrationResponseOpts
|
||||||
} from '@simplewebauthn/server';
|
} from '@simplewebauthn/server';
|
||||||
|
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssertionCredentialJSON,
|
AssertionCredentialJSON,
|
||||||
@ -41,7 +42,7 @@ export class WebAuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
get rpID() {
|
get rpID() {
|
||||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
return new URL(this.configurationService.get('ROOT_URL')).hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
get expectedOrigin() {
|
get expectedOrigin() {
|
||||||
@ -54,10 +55,9 @@ export class WebAuthService {
|
|||||||
const opts: GenerateRegistrationOptionsOpts = {
|
const opts: GenerateRegistrationOptionsOpts = {
|
||||||
rpName: 'Ghostfolio',
|
rpName: 'Ghostfolio',
|
||||||
rpID: this.rpID,
|
rpID: this.rpID,
|
||||||
userID: user.id,
|
userID: isoUint8Array.fromUTF8String(user.id),
|
||||||
userName: '',
|
userName: '',
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
attestationType: 'indirect',
|
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
authenticatorAttachment: 'platform',
|
authenticatorAttachment: 'platform',
|
||||||
requireResidentKey: false,
|
requireResidentKey: false,
|
||||||
@ -80,7 +80,6 @@ export class WebAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async verifyAttestation(
|
public async verifyAttestation(
|
||||||
deviceName: string,
|
|
||||||
credential: AttestationCredentialJSON
|
credential: AttestationCredentialJSON
|
||||||
): Promise<AuthDeviceDto> {
|
): Promise<AuthDeviceDto> {
|
||||||
const user = this.request.user;
|
const user = this.request.user;
|
||||||
@ -112,11 +111,17 @@ export class WebAuthService {
|
|||||||
where: { userId: user.id }
|
where: { userId: user.id }
|
||||||
});
|
});
|
||||||
if (registrationInfo && verified) {
|
if (registrationInfo && verified) {
|
||||||
const { counter, credentialID, credentialPublicKey } = registrationInfo;
|
const {
|
||||||
|
credential: {
|
||||||
|
counter,
|
||||||
|
id: credentialId,
|
||||||
|
publicKey: credentialPublicKey
|
||||||
|
}
|
||||||
|
} = registrationInfo;
|
||||||
|
|
||||||
let existingDevice = devices.find(
|
let existingDevice = devices.find((device) => {
|
||||||
(device) => device.credentialId === credentialID
|
return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
|
||||||
);
|
});
|
||||||
|
|
||||||
if (!existingDevice) {
|
if (!existingDevice) {
|
||||||
/**
|
/**
|
||||||
@ -124,7 +129,7 @@ export class WebAuthService {
|
|||||||
*/
|
*/
|
||||||
existingDevice = await this.deviceService.createAuthDevice({
|
existingDevice = await this.deviceService.createAuthDevice({
|
||||||
counter,
|
counter,
|
||||||
credentialId: Buffer.from(credentialID),
|
credentialId: Buffer.from(credentialId),
|
||||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: user.id } }
|
||||||
});
|
});
|
||||||
@ -149,9 +154,8 @@ export class WebAuthService {
|
|||||||
const opts: GenerateAuthenticationOptionsOpts = {
|
const opts: GenerateAuthenticationOptionsOpts = {
|
||||||
allowCredentials: [
|
allowCredentials: [
|
||||||
{
|
{
|
||||||
id: device.credentialId,
|
id: isoBase64URL.fromBuffer(device.credentialId),
|
||||||
transports: ['internal'],
|
transports: ['internal']
|
||||||
type: 'public-key'
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
rpID: this.rpID,
|
rpID: this.rpID,
|
||||||
@ -188,10 +192,10 @@ export class WebAuthService {
|
|||||||
let verification: VerifiedAuthenticationResponse;
|
let verification: VerifiedAuthenticationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyAuthenticationResponseOpts = {
|
const opts: VerifyAuthenticationResponseOpts = {
|
||||||
authenticator: {
|
credential: {
|
||||||
credentialID: device.credentialId,
|
counter: device.counter,
|
||||||
credentialPublicKey: device.credentialPublicKey,
|
id: isoBase64URL.fromBuffer(device.credentialId),
|
||||||
counter: device.counter
|
publicKey: device.credentialPublicKey
|
||||||
},
|
},
|
||||||
expectedChallenge: `${user.authChallenge}`,
|
expectedChallenge: `${user.authChallenge}`,
|
||||||
expectedOrigin: this.expectedOrigin,
|
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')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
return this.redisCacheService.reset();
|
await this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
16
apps/api/src/app/cache/cache.module.ts
vendored
16
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,10 +1,4 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -12,14 +6,6 @@ import { CacheController } from './cache.controller';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
imports: [
|
imports: [RedisCacheModule]
|
||||||
ConfigurationModule,
|
|
||||||
DataGatheringModule,
|
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
|
||||||
PrismaModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
SymbolProfileModule
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
65
apps/api/src/app/endpoints/ai/ai.controller.ts
Normal file
65
apps/api/src/app/endpoints/ai/ai.controller.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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 {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
DEFAULT_LANGUAGE_CODE
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
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 ?? DEFAULT_LANGUAGE_CODE,
|
||||||
|
userCurrency:
|
||||||
|
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return { prompt };
|
||||||
|
}
|
||||||
|
}
|
53
apps/api/src/app/endpoints/ai/ai.module.ts
Normal file
53
apps/api/src/app/endpoints/ai/ai.module.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AiController } from './ai.controller';
|
||||||
|
import { AiService } from './ai.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AiController],
|
||||||
|
imports: [
|
||||||
|
ApiModule,
|
||||||
|
ConfigurationModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
ImpersonationModule,
|
||||||
|
MarketDataModule,
|
||||||
|
OrderModule,
|
||||||
|
PortfolioSnapshotQueueModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule,
|
||||||
|
UserModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AccountBalanceService,
|
||||||
|
AccountService,
|
||||||
|
AiService,
|
||||||
|
CurrentRateService,
|
||||||
|
MarketDataService,
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PortfolioService,
|
||||||
|
RulesService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AiModule {}
|
71
apps/api/src/app/endpoints/ai/ai.service.ts
Normal file
71
apps/api/src/app/endpoints/ai/ai.service.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
import type { AiPromptMode } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiService {
|
||||||
|
public constructor(private readonly portfolioService: PortfolioService) {}
|
||||||
|
|
||||||
|
public async getPrompt({
|
||||||
|
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,24 +1,30 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.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.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 {
|
import type {
|
||||||
|
AssetProfileIdentifier,
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse
|
||||||
UniqueAsset
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -27,19 +33,23 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { BenchmarkService } from './benchmark.service';
|
import { BenchmarksService } from './benchmarks.service';
|
||||||
|
|
||||||
@Controller('benchmark')
|
@Controller('benchmarks')
|
||||||
export class BenchmarkController {
|
export class BenchmarksController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly benchmarkService: BenchmarkService,
|
private readonly benchmarkService: BenchmarkService,
|
||||||
|
private readonly benchmarksService: BenchmarksService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
public async addBenchmark(
|
||||||
|
@Body() { dataSource, symbol }: AssetProfileIdentifier
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const benchmark = await this.benchmarkService.addBenchmark({
|
const benchmark = await this.benchmarkService.addBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -103,19 +113,44 @@ export class BenchmarkController {
|
|||||||
@Get(':dataSource/:symbol/:startDateString')
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
public async getBenchmarkMarketDataForUser(
|
||||||
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('startDateString') startDateString: string,
|
@Param('startDateString') startDateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('dataSource') filterByDataSource?: string,
|
||||||
|
@Query('symbol') filterBySymbol?: string,
|
||||||
|
@Query('tags') filterByTags?: string,
|
||||||
|
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
const startDate = new Date(startDateString);
|
const { endDate, startDate } = getIntervalFromDateRange(
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
dateRange,
|
||||||
|
new Date(startDateString)
|
||||||
|
);
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataBySymbol({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByDataSource,
|
||||||
|
filterBySymbol,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
|
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
||||||
|
|
||||||
|
return this.benchmarksService.getMarketDataForUser({
|
||||||
dataSource,
|
dataSource,
|
||||||
|
dateRange,
|
||||||
|
endDate,
|
||||||
|
filters,
|
||||||
|
impersonationId,
|
||||||
startDate,
|
startDate,
|
||||||
symbol,
|
symbol,
|
||||||
userCurrency
|
withExcludedAccounts,
|
||||||
|
user: this.request.user
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
63
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
Normal file
63
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BenchmarksController } from './benchmarks.controller';
|
||||||
|
import { BenchmarksService } from './benchmarks.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BenchmarksController],
|
||||||
|
imports: [
|
||||||
|
ApiModule,
|
||||||
|
ConfigurationModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
ImpersonationModule,
|
||||||
|
MarketDataModule,
|
||||||
|
OrderModule,
|
||||||
|
PortfolioSnapshotQueueModule,
|
||||||
|
PrismaModule,
|
||||||
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolModule,
|
||||||
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule,
|
||||||
|
UserModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AccountBalanceService,
|
||||||
|
AccountService,
|
||||||
|
BenchmarkService,
|
||||||
|
BenchmarksService,
|
||||||
|
CurrentRateService,
|
||||||
|
MarketDataService,
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PortfolioService,
|
||||||
|
RulesService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class BenchmarksModule {}
|
163
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
Normal file
163
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
|
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
AssetProfileIdentifier,
|
||||||
|
BenchmarkMarketDataDetails,
|
||||||
|
Filter
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { format, isSameDay } from 'date-fns';
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BenchmarksService {
|
||||||
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getMarketDataForUser({
|
||||||
|
dataSource,
|
||||||
|
dateRange,
|
||||||
|
endDate = new Date(),
|
||||||
|
filters,
|
||||||
|
impersonationId,
|
||||||
|
startDate,
|
||||||
|
symbol,
|
||||||
|
user,
|
||||||
|
withExcludedAccounts
|
||||||
|
}: {
|
||||||
|
dateRange: DateRange;
|
||||||
|
endDate?: Date;
|
||||||
|
filters?: Filter[];
|
||||||
|
impersonationId: string;
|
||||||
|
startDate: Date;
|
||||||
|
user: UserWithSettings;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
|
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const marketData: { date: string; value: number }[] = [];
|
||||||
|
const userCurrency = user.Settings.settings.baseCurrency;
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
const { chart } = await this.portfolioService.getPerformance({
|
||||||
|
dateRange,
|
||||||
|
filters,
|
||||||
|
impersonationId,
|
||||||
|
userId,
|
||||||
|
withExcludedAccounts
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
|
this.symbolService.get({
|
||||||
|
dataGatheringItem: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.marketDataService.marketDataItems({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: {
|
||||||
|
in: chart.map(({ date }) => {
|
||||||
|
return resetHours(parseDate(date));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exchangeRates =
|
||||||
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||||
|
startDate,
|
||||||
|
currencies: [currentSymbolItem.currency],
|
||||||
|
targetCurrency: userCurrency
|
||||||
|
});
|
||||||
|
|
||||||
|
const exchangeRateAtStartDate =
|
||||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
|
format(startDate, DATE_FORMAT)
|
||||||
|
];
|
||||||
|
|
||||||
|
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
|
||||||
|
return isSameDay(date, startDate);
|
||||||
|
})?.marketPrice;
|
||||||
|
|
||||||
|
if (!marketPriceAtStartDate) {
|
||||||
|
Logger.error(
|
||||||
|
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
|
||||||
|
startDate,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}`,
|
||||||
|
'BenchmarkService'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { marketData };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const marketDataItem of marketDataItems) {
|
||||||
|
const exchangeRate =
|
||||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
|
format(marketDataItem.date, DATE_FORMAT)
|
||||||
|
];
|
||||||
|
|
||||||
|
const exchangeRateFactor =
|
||||||
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||||
|
? exchangeRate / exchangeRateAtStartDate
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
marketData.push({
|
||||||
|
date: format(marketDataItem.date, DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
marketPriceAtStartDate === 0
|
||||||
|
? 0
|
||||||
|
: this.benchmarkService.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
marketDataItem.marketPrice * exchangeRateFactor
|
||||||
|
) * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const includesEndDate = isSameDay(
|
||||||
|
parseDate(marketData.at(-1).date),
|
||||||
|
endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSymbolItem?.marketPrice && !includesEndDate) {
|
||||||
|
const exchangeRate =
|
||||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
|
format(endDate, DATE_FORMAT)
|
||||||
|
];
|
||||||
|
|
||||||
|
const exchangeRateFactor =
|
||||||
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||||
|
? exchangeRate / exchangeRateAtStartDate
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
marketData.push({
|
||||||
|
date: format(endDate, DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
this.benchmarkService.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
currentSymbolItem.marketPrice * exchangeRateFactor
|
||||||
|
) * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketData
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetDividendsDto {
|
||||||
|
@IsISO8601()
|
||||||
|
from: string;
|
||||||
|
|
||||||
|
@IsIn(['day', 'month'] as Granularity[])
|
||||||
|
@IsOptional()
|
||||||
|
granularity: Granularity;
|
||||||
|
|
||||||
|
@IsISO8601()
|
||||||
|
to: string;
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetHistoricalDto {
|
||||||
|
@IsISO8601()
|
||||||
|
from: string;
|
||||||
|
|
||||||
|
@IsIn(['day', 'month'] as Granularity[])
|
||||||
|
@IsOptional()
|
||||||
|
granularity: Granularity;
|
||||||
|
|
||||||
|
@IsISO8601()
|
||||||
|
to: string;
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetQuotesDto {
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.split(',') : value
|
||||||
|
)
|
||||||
|
symbols: string[];
|
||||||
|
}
|
@ -0,0 +1,414 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@Get('dividends/:symbol')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async getDividendsV1(
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Query() query: GetDividendsDto
|
||||||
|
): Promise<DividendsResponse> {
|
||||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dividends = await this.ghostfolioService.getDividends({
|
||||||
|
symbol,
|
||||||
|
from: parseDate(query.from),
|
||||||
|
granularity: query.granularity,
|
||||||
|
to: parseDate(query.to)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ghostfolioService.incrementDailyRequests({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return dividends;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('dividends/:symbol')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||||
|
@Version('2')
|
||||||
|
public async getDividends(
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Query() query: GetDividendsDto
|
||||||
|
): Promise<DividendsResponse> {
|
||||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dividends = await this.ghostfolioService.getDividends({
|
||||||
|
symbol,
|
||||||
|
from: parseDate(query.from),
|
||||||
|
granularity: query.granularity,
|
||||||
|
to: parseDate(query.to)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ghostfolioService.incrementDailyRequests({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return dividends;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@Get('historical/:symbol')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async getHistoricalV1(
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Query() query: GetHistoricalDto
|
||||||
|
): Promise<HistoricalResponse> {
|
||||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const historicalData = await this.ghostfolioService.getHistorical({
|
||||||
|
symbol,
|
||||||
|
from: parseDate(query.from),
|
||||||
|
granularity: query.granularity,
|
||||||
|
to: parseDate(query.to)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ghostfolioService.incrementDailyRequests({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return historicalData;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('historical/:symbol')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||||
|
@Version('2')
|
||||||
|
public async getHistorical(
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Query() query: GetHistoricalDto
|
||||||
|
): Promise<HistoricalResponse> {
|
||||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const historicalData = await this.ghostfolioService.getHistorical({
|
||||||
|
symbol,
|
||||||
|
from: parseDate(query.from),
|
||||||
|
granularity: query.granularity,
|
||||||
|
to: parseDate(query.to)
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ghostfolioService.incrementDailyRequests({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return historicalData;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@Get('lookup')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async lookupSymbolV1(
|
||||||
|
@Query('includeIndices') includeIndicesParam = 'false',
|
||||||
|
@Query('query') query = ''
|
||||||
|
): Promise<LookupResponse> {
|
||||||
|
const includeIndices = includeIndicesParam === 'true';
|
||||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ghostfolioService.lookup({
|
||||||
|
includeIndices,
|
||||||
|
query: query.toLowerCase()
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ghostfolioService.incrementDailyRequests({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('lookup')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||||
|
@Version('2')
|
||||||
|
public async lookupSymbol(
|
||||||
|
@Query('includeIndices') includeIndicesParam = 'false',
|
||||||
|
@Query('query') query = ''
|
||||||
|
): Promise<LookupResponse> {
|
||||||
|
const includeIndices = includeIndicesParam === 'true';
|
||||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ghostfolioService.lookup({
|
||||||
|
includeIndices,
|
||||||
|
query: 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@Get('quotes')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async getQuotesV1(
|
||||||
|
@Query() query: GetQuotesDto
|
||||||
|
): Promise<QuotesResponse> {
|
||||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quotes = await this.ghostfolioService.getQuotes({
|
||||||
|
symbols: query.symbols
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ghostfolioService.incrementDailyRequests({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return quotes;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('quotes')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||||
|
@Version('2')
|
||||||
|
public async getQuotes(
|
||||||
|
@Query() query: GetQuotesDto
|
||||||
|
): Promise<QuotesResponse> {
|
||||||
|
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||||
|
StatusCodes.TOO_MANY_REQUESTS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quotes = await this.ghostfolioService.getQuotes({
|
||||||
|
symbols: query.symbols
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ghostfolioService.incrementDailyRequests({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return quotes;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@Get('status')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
|
||||||
|
return this.ghostfolioService.getStatus({ user: this.request.user });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||||
|
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
||||||
|
@Version('2')
|
||||||
|
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
|
||||||
|
return this.ghostfolioService.getStatus({ user: this.request.user });
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,83 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
|
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
|
||||||
|
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
||||||
|
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
|
||||||
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||||
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
|
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
||||||
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { GhostfolioController } from './ghostfolio.controller';
|
||||||
|
import { GhostfolioService } from './ghostfolio.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [GhostfolioController],
|
||||||
|
imports: [
|
||||||
|
CryptocurrencyModule,
|
||||||
|
DataProviderModule,
|
||||||
|
MarketDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AlphaVantageService,
|
||||||
|
CoinGeckoService,
|
||||||
|
ConfigurationService,
|
||||||
|
DataProviderService,
|
||||||
|
EodHistoricalDataService,
|
||||||
|
FinancialModelingPrepService,
|
||||||
|
GhostfolioService,
|
||||||
|
GoogleSheetsService,
|
||||||
|
ManualService,
|
||||||
|
RapidApiService,
|
||||||
|
YahooFinanceService,
|
||||||
|
YahooFinanceDataEnhancerService,
|
||||||
|
{
|
||||||
|
inject: [
|
||||||
|
AlphaVantageService,
|
||||||
|
CoinGeckoService,
|
||||||
|
EodHistoricalDataService,
|
||||||
|
FinancialModelingPrepService,
|
||||||
|
GoogleSheetsService,
|
||||||
|
ManualService,
|
||||||
|
RapidApiService,
|
||||||
|
YahooFinanceService
|
||||||
|
],
|
||||||
|
provide: 'DataProviderInterfaces',
|
||||||
|
useFactory: (
|
||||||
|
alphaVantageService,
|
||||||
|
coinGeckoService,
|
||||||
|
eodHistoricalDataService,
|
||||||
|
financialModelingPrepService,
|
||||||
|
googleSheetsService,
|
||||||
|
manualService,
|
||||||
|
rapidApiService,
|
||||||
|
yahooFinanceService
|
||||||
|
) => [
|
||||||
|
alphaVantageService,
|
||||||
|
coinGeckoService,
|
||||||
|
eodHistoricalDataService,
|
||||||
|
financialModelingPrepService,
|
||||||
|
googleSheetsService,
|
||||||
|
manualService,
|
||||||
|
rapidApiService,
|
||||||
|
yahooFinanceService
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class GhostfolioModule {}
|
@ -0,0 +1,344 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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(
|
||||||
|
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
|
||||||
|
)) as string) || '0',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) {
|
||||||
|
const results: QuotesResponse = { quotes: {} };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises: Promise<any>[] = [];
|
||||||
|
|
||||||
|
for (const dataProvider of this.getDataProviderServices()) {
|
||||||
|
const maximumNumberOfSymbolsPerRequest =
|
||||||
|
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
||||||
|
Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < symbols.length;
|
||||||
|
i += maximumNumberOfSymbolsPerRequest
|
||||||
|
) {
|
||||||
|
const symbolsChunk = symbols.slice(
|
||||||
|
i,
|
||||||
|
i + maximumNumberOfSymbolsPerRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
const promise = Promise.resolve(
|
||||||
|
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
|
||||||
|
);
|
||||||
|
|
||||||
|
promises.push(
|
||||||
|
promise.then(async (result) => {
|
||||||
|
for (const [symbol, dataProviderResponse] of Object.entries(
|
||||||
|
result
|
||||||
|
)) {
|
||||||
|
dataProviderResponse.dataSource = 'GHOSTFOLIO';
|
||||||
|
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
...DERIVED_CURRENCIES.map(({ currency }) => {
|
||||||
|
return `${DEFAULT_CURRENCY}${currency}`;
|
||||||
|
}),
|
||||||
|
`${DEFAULT_CURRENCY}USX`
|
||||||
|
].includes(symbol)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.quotes[symbol] = dataProviderResponse;
|
||||||
|
|
||||||
|
for (const {
|
||||||
|
currency,
|
||||||
|
factor,
|
||||||
|
rootCurrency
|
||||||
|
} of DERIVED_CURRENCIES) {
|
||||||
|
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) {
|
||||||
|
results.quotes[`${DEFAULT_CURRENCY}${currency}`] = {
|
||||||
|
...dataProviderResponse,
|
||||||
|
currency,
|
||||||
|
marketPrice: new Big(
|
||||||
|
result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice
|
||||||
|
)
|
||||||
|
.mul(factor)
|
||||||
|
.toNumber(),
|
||||||
|
marketState: 'open'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'GhostfolioService');
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getStatus({ user }: { user: UserWithSettings }) {
|
||||||
|
return {
|
||||||
|
dailyRequests: user.dataProviderGhostfolioDailyRequests,
|
||||||
|
dailyRequestsMax: await this.getMaxDailyRequests(),
|
||||||
|
subscription: user.subscription
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async incrementDailyRequests({ userId }: { userId: string }) {
|
||||||
|
await this.prismaService.analytics.update({
|
||||||
|
data: {
|
||||||
|
dataProviderGhostfolioDailyRequests: { increment: 1 }
|
||||||
|
},
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async lookup({
|
||||||
|
includeIndices = false,
|
||||||
|
query
|
||||||
|
}: GetSearchParams): Promise<LookupResponse> {
|
||||||
|
const results: LookupResponse = { items: [] };
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let lookupItems: LookupItem[] = [];
|
||||||
|
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||||
|
|
||||||
|
if (query?.length < 2) {
|
||||||
|
return { items: lookupItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dataProviderService of this.getDataProviderServices()) {
|
||||||
|
promises.push(
|
||||||
|
dataProviderService.search({
|
||||||
|
includeIndices,
|
||||||
|
query
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResults = await Promise.all(promises);
|
||||||
|
|
||||||
|
for (const { items } of searchResults) {
|
||||||
|
if (items?.length > 0) {
|
||||||
|
lookupItems = lookupItems.concat(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredItems = lookupItems
|
||||||
|
.filter(({ currency }) => {
|
||||||
|
// Only allow symbols with supported currency
|
||||||
|
return currency ? true : false;
|
||||||
|
})
|
||||||
|
.sort(({ name: name1 }, { name: name2 }) => {
|
||||||
|
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
||||||
|
})
|
||||||
|
.map((lookupItem) => {
|
||||||
|
lookupItem.dataProviderInfo = this.getDataProviderInfo();
|
||||||
|
lookupItem.dataSource = 'GHOSTFOLIO';
|
||||||
|
|
||||||
|
return lookupItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
results.items = filteredItems;
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'GhostfolioService');
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDataProviderInfo(): DataProviderInfo {
|
||||||
|
return {
|
||||||
|
isPremium: false,
|
||||||
|
name: 'Ghostfolio Premium',
|
||||||
|
url: 'https://ghostfol.io'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDataProviderServices() {
|
||||||
|
return this.configurationService
|
||||||
|
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
|
||||||
|
.map((dataSource) => {
|
||||||
|
return this.dataProviderService.getDataProvider(DataSource[dataSource]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
137
apps/api/src/app/endpoints/market-data/market-data.controller.ts
Normal file
137
apps/api/src/app/endpoints/market-data/market-data.controller.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
|
||||||
|
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource, Prisma } from '@prisma/client';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||||
|
|
||||||
|
@Controller('market-data')
|
||||||
|
export class MarketDataController {
|
||||||
|
public constructor(
|
||||||
|
private readonly adminService: AdminService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getMarketDataBySymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<MarketDataDetailsResponse> {
|
||||||
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canReadAllAssetProfiles = hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.readMarketData
|
||||||
|
);
|
||||||
|
|
||||||
|
const canReadOwnAssetProfile =
|
||||||
|
assetProfile?.userId === this.request.user.id &&
|
||||||
|
hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.readMarketDataOfOwnAssetProfile
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
|
||||||
|
throw new HttpException(
|
||||||
|
assetProfile.userId
|
||||||
|
? getReasonPhrase(StatusCodes.NOT_FOUND)
|
||||||
|
: getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updateMarketData(
|
||||||
|
@Body() data: UpdateBulkMarketDataDto,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canUpsertAllAssetProfiles =
|
||||||
|
hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.createMarketData
|
||||||
|
) &&
|
||||||
|
hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.updateMarketData
|
||||||
|
);
|
||||||
|
|
||||||
|
const canUpsertOwnAssetProfile =
|
||||||
|
assetProfile.userId === this.request.user.id &&
|
||||||
|
hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.createMarketDataOfOwnAssetProfile
|
||||||
|
) &&
|
||||||
|
hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.updateMarketDataOfOwnAssetProfile
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||||
|
({ date, marketPrice }) => ({
|
||||||
|
dataSource,
|
||||||
|
marketPrice,
|
||||||
|
symbol,
|
||||||
|
date: parseISO(date),
|
||||||
|
state: 'CLOSE'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.marketDataService.updateMany({
|
||||||
|
data: dataBulkUpdate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/endpoints/market-data/market-data.module.ts
Normal file
13
apps/api/src/app/endpoints/market-data/market-data.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
||||||
|
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MarketDataController } from './market-data.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [MarketDataController],
|
||||||
|
imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule]
|
||||||
|
})
|
||||||
|
export class MarketDataModule {}
|
@ -0,0 +1,24 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateBulkMarketDataDto {
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsArray()
|
||||||
|
@Type(() => UpdateMarketDataDto)
|
||||||
|
marketData: UpdateMarketDataDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateMarketDataDto {
|
||||||
|
@IsISO8601()
|
||||||
|
@IsOptional()
|
||||||
|
date?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
marketPrice: number;
|
||||||
|
}
|
140
apps/api/src/app/endpoints/public/public.controller.ts
Normal file
140
apps/api/src/app/endpoints/public/public.controller.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||||
|
import { getSum } from '@ghostfolio/common/helper';
|
||||||
|
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { Big } from 'big.js';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
@Controller('public')
|
||||||
|
export class PublicController {
|
||||||
|
public constructor(
|
||||||
|
private readonly accessService: AccessService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly userService: UserService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':accessId/portfolio')
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getPublicPortfolio(
|
||||||
|
@Param('accessId') accessId
|
||||||
|
): Promise<PublicPortfolioResponse> {
|
||||||
|
const access = await this.accessService.access({ id: accessId });
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasDetails = true;
|
||||||
|
|
||||||
|
const user = await this.userService.user({
|
||||||
|
id: access.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
{ createdAt, holdings, markets },
|
||||||
|
{ performance: performance1d },
|
||||||
|
{ performance: performanceMax },
|
||||||
|
{ performance: performanceYtd }
|
||||||
|
] = await Promise.all([
|
||||||
|
this.portfolioService.getDetails({
|
||||||
|
impersonationId: access.userId,
|
||||||
|
userId: user.id,
|
||||||
|
withMarkets: true
|
||||||
|
}),
|
||||||
|
...['1d', 'max', 'ytd'].map((dateRange) => {
|
||||||
|
return this.portfolioService.getPerformance({
|
||||||
|
dateRange,
|
||||||
|
impersonationId: undefined,
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
Object.values(markets ?? {}).forEach((market) => {
|
||||||
|
delete market.valueInBaseCurrency;
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicPortfolioResponse: PublicPortfolioResponse = {
|
||||||
|
createdAt,
|
||||||
|
hasDetails,
|
||||||
|
markets,
|
||||||
|
alias: access.alias,
|
||||||
|
holdings: {},
|
||||||
|
performance: {
|
||||||
|
'1d': {
|
||||||
|
relativeChange:
|
||||||
|
performance1d.netPerformancePercentageWithCurrencyEffect
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
relativeChange:
|
||||||
|
performanceMax.netPerformancePercentageWithCurrencyEffect
|
||||||
|
},
|
||||||
|
ytd: {
|
||||||
|
relativeChange:
|
||||||
|
performanceYtd.netPerformancePercentageWithCurrencyEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalValue = getSum(
|
||||||
|
Object.values(holdings).map(({ currency, marketPrice, quantity }) => {
|
||||||
|
return new Big(
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
quantity * marketPrice,
|
||||||
|
currency,
|
||||||
|
this.request.user?.Settings?.settings.baseCurrency ??
|
||||||
|
DEFAULT_CURRENCY
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
).toNumber();
|
||||||
|
|
||||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
|
publicPortfolioResponse.holdings[symbol] = {
|
||||||
|
allocationInPercentage:
|
||||||
|
portfolioPosition.valueInBaseCurrency / totalValue,
|
||||||
|
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
||||||
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
dataSource: portfolioPosition.dataSource,
|
||||||
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||||
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
|
name: portfolioPosition.name,
|
||||||
|
netPerformancePercentWithCurrencyEffect:
|
||||||
|
portfolioPosition.netPerformancePercentWithCurrencyEffect,
|
||||||
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
|
symbol: portfolioPosition.symbol,
|
||||||
|
url: portfolioPosition.url,
|
||||||
|
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicPortfolioResponse;
|
||||||
|
}
|
||||||
|
}
|
49
apps/api/src/app/endpoints/public/public.module.ts
Normal file
49
apps/api/src/app/endpoints/public/public.module.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PublicController } from './public.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PublicController],
|
||||||
|
imports: [
|
||||||
|
AccessModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
ImpersonationModule,
|
||||||
|
MarketDataModule,
|
||||||
|
OrderModule,
|
||||||
|
PortfolioSnapshotQueueModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
UserModule
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AccountBalanceService,
|
||||||
|
AccountService,
|
||||||
|
CurrentRateService,
|
||||||
|
PortfolioCalculatorFactory,
|
||||||
|
PortfolioService,
|
||||||
|
RulesService
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class PublicModule {}
|
10
apps/api/src/app/endpoints/tags/create-tag.dto.ts
Normal file
10
apps/api/src/app/endpoints/tags/create-tag.dto.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTagDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
}
|
@ -1,6 +1,8 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
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 {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -8,36 +10,84 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Tag } from '@prisma/client';
|
import { Tag } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { CreateTagDto } from './create-tag.dto';
|
import { CreateTagDto } from './create-tag.dto';
|
||||||
import { TagService } from './tag.service';
|
|
||||||
import { UpdateTagDto } from './update-tag.dto';
|
import { UpdateTagDto } from './update-tag.dto';
|
||||||
|
|
||||||
@Controller('tag')
|
@Controller('tags')
|
||||||
export class TagController {
|
export class TagsController {
|
||||||
public constructor(private readonly tagService: TagService) {}
|
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()
|
@Get()
|
||||||
|
@HasPermission(permissions.readTags)
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getTags() {
|
public async getTags() {
|
||||||
return this.tagService.getTagsWithActivityCount();
|
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)
|
@HasPermission(permissions.updateTag)
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@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;
|
||||||
|
}
|
85
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
Normal file
85
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
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 { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
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(
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly watchlistService: WatchlistService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HasPermission(permissions.createWatchlistItem)
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@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 watchlistItem = await this.watchlistService
|
||||||
|
.getWatchlistItems(this.request.user.id)
|
||||||
|
.then((items) => {
|
||||||
|
return items.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(): Promise<AssetProfileIdentifier[]> {
|
||||||
|
return this.watchlistService.getWatchlistItems(this.request.user.id);
|
||||||
|
}
|
||||||
|
}
|
19
apps/api/src/app/endpoints/watchlist/watchlist.module.ts
Normal file
19
apps/api/src/app/endpoints/watchlist/watchlist.module.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { WatchlistController } from './watchlist.controller';
|
||||||
|
import { WatchlistService } from './watchlist.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WatchlistController],
|
||||||
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule
|
||||||
|
],
|
||||||
|
providers: [WatchlistService]
|
||||||
|
})
|
||||||
|
export class WatchlistModule {}
|
79
apps/api/src/app/endpoints/watchlist/watchlist.service.ts
Normal file
79
apps/api/src/app/endpoints/watchlist/watchlist.service.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WatchlistService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Asset profile not found for ${symbol} (${dataSource})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AssetProfileIdentifier[]> {
|
||||||
|
const user = await this.prismaService.user.findUnique({
|
||||||
|
select: {
|
||||||
|
watchlist: {
|
||||||
|
select: { dataSource: true, symbol: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return user.watchlist ?? [];
|
||||||
|
}
|
||||||
|
}
|
@ -21,10 +21,11 @@ export class ExportController {
|
|||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async export(
|
public async export(
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('activityIds') activityIds?: string[],
|
@Query('activityIds') filterByActivityIds?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<Export> {
|
): Promise<Export> {
|
||||||
|
const activityIds = filterByActivityIds?.split(',') ?? [];
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -12,15 +9,7 @@ import { ExportController } from './export.controller';
|
|||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [AccountModule, ApiModule, OrderModule, TagModule],
|
||||||
AccountModule,
|
|
||||||
ApiModule,
|
|
||||||
ConfigurationModule,
|
|
||||||
DataGatheringModule,
|
|
||||||
DataProviderModule,
|
|
||||||
OrderModule,
|
|
||||||
RedisCacheModule
|
|
||||||
],
|
|
||||||
controllers: [ExportController],
|
controllers: [ExportController],
|
||||||
providers: [ExportService]
|
providers: [ExportService]
|
||||||
})
|
})
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import { Filter, Export } from '@ghostfolio/common/interfaces';
|
import { Filter, Export } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Platform } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly orderService: OrderService
|
private readonly orderService: OrderService,
|
||||||
|
private readonly tagService: TagService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async export({
|
public async export({
|
||||||
@ -23,26 +26,7 @@ export class ExportService {
|
|||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
const accounts = (
|
const platformsMap: { [platformId: string]: Platform } = {};
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let { activities } = await this.orderService.getOrders({
|
let { activities } = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
@ -54,24 +38,95 @@ export class ExportService {
|
|||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activityIds) {
|
if (activityIds?.length > 0) {
|
||||||
activities = activities.filter((activity) => {
|
activities = activities.filter(({ id }) => {
|
||||||
return activityIds.includes(activity.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: 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 {
|
return {
|
||||||
meta: { date: new Date().toISOString(), version: environment.version },
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
accounts,
|
accounts,
|
||||||
|
platforms: Object.values(platformsMap),
|
||||||
|
tags,
|
||||||
activities: activities.map(
|
activities: activities.map(
|
||||||
({
|
({
|
||||||
accountId,
|
accountId,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
id,
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
|
tags: currentTags,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
}) => {
|
}) => {
|
||||||
@ -83,19 +138,21 @@ export class ExportService {
|
|||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
currency: SymbolProfile.currency,
|
currency: currency ?? SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
date: date.toISOString(),
|
date: date.toISOString(),
|
||||||
symbol:
|
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)
|
||||||
type === 'FEE' ||
|
|
||||||
type === 'INTEREST' ||
|
|
||||||
type === 'ITEM' ||
|
|
||||||
type === 'LIABILITY'
|
|
||||||
? SymbolProfile.name
|
? SymbolProfile.name
|
||||||
: SymbolProfile.symbol
|
: SymbolProfile.symbol,
|
||||||
|
tags: currentTags.map(({ id: tagId }) => {
|
||||||
|
return tagId;
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
|
user: {
|
||||||
|
settings: { currency: userCurrency }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
|
Res,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { HealthService } from './health.service';
|
import { HealthService } from './health.service';
|
||||||
@ -17,7 +20,21 @@ export class HealthController {
|
|||||||
public constructor(private readonly healthService: HealthService) {}
|
public constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
@Get()
|
@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')
|
@Get('data-enhancer/:name')
|
||||||
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
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 { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -9,7 +11,13 @@ import { HealthService } from './health.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
|
imports: [
|
||||||
|
DataEnhancerModule,
|
||||||
|
DataProviderModule,
|
||||||
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
TransformDataSourceInRequestModule
|
||||||
|
],
|
||||||
providers: [HealthService]
|
providers: [HealthService]
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
@ -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 { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
@ -8,7 +11,9 @@ import { DataSource } from '@prisma/client';
|
|||||||
export class HealthService {
|
export class HealthService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataEnhancerService: DataEnhancerService,
|
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) {
|
public async hasResponseFromDataEnhancer(aName: string) {
|
||||||
@ -18,4 +23,24 @@ export class HealthService {
|
|||||||
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||||
return this.dataProviderService.checkQuote(aDataSource);
|
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 { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
|
||||||
|
|
||||||
export class ImportDataDto {
|
export class ImportDataDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@Type(() => CreateAccountDto)
|
@Type(() => CreateAccountWithBalancesDto)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
accounts: CreateAccountDto[];
|
accounts: CreateAccountWithBalancesDto[];
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@Type(() => CreateOrderDto)
|
@Type(() => CreateOrderDto)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.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.interceptor';
|
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -98,12 +98,9 @@ export class ImportController {
|
|||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<ImportResponse> {
|
): Promise<ImportResponse> {
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
|
||||||
|
|
||||||
const activities = await this.importService.getDividends({
|
const activities = await this.importService.getDividends({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol
|
||||||
userCurrency
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { activities };
|
return { activities };
|
||||||
|
@ -4,11 +4,13 @@ import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
|||||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
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 { 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 { 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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -30,7 +32,9 @@ import { ImportService } from './import.service';
|
|||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule,
|
||||||
|
TransformDataSourceInResponseModule
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -9,16 +9,15 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
|||||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
|
||||||
getAssetProfileIdentifier,
|
getAssetProfileIdentifier,
|
||||||
parseDate
|
parseDate
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
AccountWithPlatform,
|
AccountWithPlatform,
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
@ -27,8 +26,8 @@ import {
|
|||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import { Big } from 'big.js';
|
||||||
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@ -39,7 +38,6 @@ export class ImportService {
|
|||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
private readonly platformService: PlatformService,
|
private readonly platformService: PlatformService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -48,9 +46,8 @@ export class ImportService {
|
|||||||
|
|
||||||
public async getDividends({
|
public async getDividends({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol
|
||||||
userCurrency
|
}: AssetProfileIdentifier): Promise<Activity[]> {
|
||||||
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
|
||||||
try {
|
try {
|
||||||
const { firstBuyDate, historicalData, orders } =
|
const { firstBuyDate, historicalData, orders } =
|
||||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||||
@ -71,13 +68,18 @@ export class ImportService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = orders.map((order) => {
|
const accounts = orders
|
||||||
return order.Account;
|
.filter(({ 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 }]) => {
|
return await Promise.all(
|
||||||
|
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
|
||||||
const quantity =
|
const quantity =
|
||||||
historicalData.find((historicalDataItem) => {
|
historicalData.find((historicalDataItem) => {
|
||||||
return historicalDataItem.date === dateString;
|
return historicalDataItem.date === dateString;
|
||||||
@ -112,24 +114,22 @@ export class ImportService {
|
|||||||
accountId: Account?.id,
|
accountId: Account?.id,
|
||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
|
currency: undefined,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
fee: 0,
|
fee: 0,
|
||||||
feeInBaseCurrency: 0,
|
feeInAssetProfileCurrency: 0,
|
||||||
id: assetProfile.id,
|
id: assetProfile.id,
|
||||||
isDraft: false,
|
isDraft: false,
|
||||||
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
SymbolProfile: assetProfile,
|
||||||
symbolProfileId: assetProfile.id,
|
symbolProfileId: assetProfile.id,
|
||||||
type: 'DIVIDEND',
|
type: 'DIVIDEND',
|
||||||
unitPrice: marketPrice,
|
unitPrice: marketPrice,
|
||||||
|
unitPriceInAssetProfileCurrency: marketPrice,
|
||||||
updatedAt: undefined,
|
updatedAt: undefined,
|
||||||
userId: Account?.userId,
|
userId: Account?.userId
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
value,
|
|
||||||
assetProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -214,7 +214,7 @@ export class ImportService {
|
|||||||
|
|
||||||
for (const activity of activitiesDto) {
|
for (const activity of activitiesDto) {
|
||||||
if (!activity.dataSource) {
|
if (!activity.dataSource) {
|
||||||
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(activity.type)) {
|
||||||
activity.dataSource = DataSource.MANUAL;
|
activity.dataSource = DataSource.MANUAL;
|
||||||
} else {
|
} else {
|
||||||
activity.dataSource =
|
activity.dataSource =
|
||||||
@ -256,27 +256,24 @@ export class ImportService {
|
|||||||
|
|
||||||
const activities: Activity[] = [];
|
const activities: Activity[] = [];
|
||||||
|
|
||||||
for (let [
|
for (const activity of activitiesExtendedWithErrors) {
|
||||||
index,
|
const accountId = activity.accountId;
|
||||||
{
|
const comment = activity.comment;
|
||||||
accountId,
|
const currency = activity.currency;
|
||||||
comment,
|
const date = activity.date;
|
||||||
date,
|
const error = activity.error;
|
||||||
error,
|
const fee = activity.fee;
|
||||||
fee,
|
const quantity = activity.quantity;
|
||||||
quantity,
|
const SymbolProfile = activity.SymbolProfile;
|
||||||
SymbolProfile,
|
const type = activity.type;
|
||||||
type,
|
const unitPrice = activity.unitPrice;
|
||||||
unitPrice
|
|
||||||
}
|
|
||||||
] of activitiesExtendedWithErrors.entries()) {
|
|
||||||
const assetProfile = assetProfiles[
|
const assetProfile = assetProfiles[
|
||||||
getAssetProfileIdentifier({
|
getAssetProfileIdentifier({
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
symbol: SymbolProfile.symbol
|
symbol: SymbolProfile.symbol
|
||||||
})
|
})
|
||||||
] ?? {
|
] ?? {
|
||||||
currency: SymbolProfile.currency,
|
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
symbol: SymbolProfile.symbol
|
symbol: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
@ -285,12 +282,14 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
currency,
|
cusip,
|
||||||
dataSource,
|
dataSource,
|
||||||
figi,
|
figi,
|
||||||
figiComposite,
|
figiComposite,
|
||||||
figiShareClass,
|
figiShareClass,
|
||||||
|
holdings,
|
||||||
id,
|
id,
|
||||||
|
isActive,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
@ -310,43 +309,16 @@ export class ImportService {
|
|||||||
Account?: { id: string; name: string };
|
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) {
|
if (isDryRun) {
|
||||||
order = {
|
order = {
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
|
Account: validatedAccount,
|
||||||
accountId: validatedAccount?.id,
|
accountId: validatedAccount?.id,
|
||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@ -357,12 +329,14 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
currency,
|
cusip,
|
||||||
dataSource,
|
dataSource,
|
||||||
figi,
|
figi,
|
||||||
figiComposite,
|
figiComposite,
|
||||||
figiShareClass,
|
figiShareClass,
|
||||||
|
holdings,
|
||||||
id,
|
id,
|
||||||
|
isActive,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
@ -371,9 +345,10 @@ export class ImportService {
|
|||||||
symbolMapping,
|
symbolMapping,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
url,
|
url,
|
||||||
comment: assetProfile.comment
|
comment: assetProfile.comment,
|
||||||
|
currency: assetProfile.currency,
|
||||||
|
userId: dataSource === 'MANUAL' ? user.id : undefined
|
||||||
},
|
},
|
||||||
Account: validatedAccount,
|
|
||||||
symbolProfileId: undefined,
|
symbolProfileId: undefined,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
userId: user.id
|
userId: user.id
|
||||||
@ -385,6 +360,7 @@ export class ImportService {
|
|||||||
|
|
||||||
order = await this.orderService.createOrder({
|
order = await this.orderService.createOrder({
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
@ -394,9 +370,10 @@ export class ImportService {
|
|||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol,
|
||||||
|
currency: assetProfile.currency,
|
||||||
|
userId: dataSource === 'MANUAL' ? user.id : undefined
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
@ -410,6 +387,11 @@ export class ImportService {
|
|||||||
User: { connect: { id: user.id } },
|
User: { connect: { id: user.id } },
|
||||||
userId: user.id
|
userId: user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (order.SymbolProfile?.symbol) {
|
||||||
|
// Update symbol that may have been assigned in createOrder()
|
||||||
|
assetProfile.symbol = order.SymbolProfile.symbol;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
@ -418,18 +400,8 @@ export class ImportService {
|
|||||||
...order,
|
...order,
|
||||||
error,
|
error,
|
||||||
value,
|
value,
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
fee,
|
|
||||||
currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
SymbolProfile: assetProfile,
|
SymbolProfile: assetProfile
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
value,
|
|
||||||
currency,
|
|
||||||
userCurrency
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -446,15 +418,16 @@ export class ImportService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dataGatheringService.gatherSymbols(
|
this.dataGatheringService.gatherSymbols({
|
||||||
uniqueActivities.map(({ date, SymbolProfile }) => {
|
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
symbol: SymbolProfile.symbol
|
symbol: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
})
|
}),
|
||||||
);
|
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
@ -469,7 +442,8 @@ export class ImportService {
|
|||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Partial<Activity>[]> {
|
}): Promise<Partial<Activity>[]> {
|
||||||
let { activities: existingActivities } = await this.orderService.getOrders({
|
const { activities: existingActivities } =
|
||||||
|
await this.orderService.getOrders({
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
@ -493,7 +467,9 @@ export class ImportService {
|
|||||||
const isDuplicate = existingActivities.some((activity) => {
|
const isDuplicate = existingActivities.some((activity) => {
|
||||||
return (
|
return (
|
||||||
activity.accountId === accountId &&
|
activity.accountId === accountId &&
|
||||||
activity.SymbolProfile.currency === currency &&
|
activity.comment === comment &&
|
||||||
|
(activity.currency === currency ||
|
||||||
|
activity.SymbolProfile.currency === currency) &&
|
||||||
activity.SymbolProfile.dataSource === dataSource &&
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
isSameSecond(activity.date, date) &&
|
isSameSecond(activity.date, date) &&
|
||||||
activity.fee === fee &&
|
activity.fee === fee &&
|
||||||
@ -511,6 +487,7 @@ export class ImportService {
|
|||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
date,
|
date,
|
||||||
error,
|
error,
|
||||||
fee,
|
fee,
|
||||||
@ -518,25 +495,19 @@ export class ImportService {
|
|||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
assetClass: null,
|
activitiesCount: undefined,
|
||||||
assetSubClass: null,
|
assetClass: undefined,
|
||||||
comment: null,
|
assetSubClass: undefined,
|
||||||
countries: null,
|
countries: undefined,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
figi: null,
|
currency: undefined,
|
||||||
figiComposite: null,
|
holdings: undefined,
|
||||||
figiShareClass: null,
|
|
||||||
id: undefined,
|
id: undefined,
|
||||||
isin: null,
|
isActive: true,
|
||||||
name: null,
|
sectors: undefined,
|
||||||
scraperConfiguration: null,
|
updatedAt: undefined
|
||||||
sectors: null,
|
|
||||||
symbolMapping: null,
|
|
||||||
updatedAt: undefined,
|
|
||||||
url: null
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -569,12 +540,13 @@ export class ImportService {
|
|||||||
const assetProfiles: {
|
const assetProfiles: {
|
||||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
|
const dataSources = await this.dataProviderService.getDataSources({ user });
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, symbol, type }
|
{ currency, dataSource, symbol, type }
|
||||||
] of activitiesDto.entries()) {
|
] of activitiesDto.entries()) {
|
||||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
if (!dataSources.includes(dataSource)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||||
);
|
);
|
||||||
@ -611,12 +583,6 @@ export class ImportService {
|
|||||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
`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 })] =
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
|
||||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { 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 { 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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { 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 { 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 { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
@ -32,8 +32,9 @@ import { InfoService } from './info.service';
|
|||||||
PlatformModule,
|
PlatformModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
SubscriptionModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
TagModule,
|
TransformDataSourceInResponseModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [InfoService]
|
providers: [InfoService]
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
|
||||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { 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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY,
|
DEFAULT_CURRENCY,
|
||||||
|
HEADER_KEY_TOKEN,
|
||||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
PROPERTY_DEMO_USER_ID,
|
PROPERTY_DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
@ -21,19 +21,13 @@ import {
|
|||||||
encodeDataSource,
|
encodeDataSource,
|
||||||
extractNumberFromString
|
extractNumberFromString
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
|
||||||
InfoItem,
|
|
||||||
Statistics,
|
|
||||||
Subscription
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
import got from 'got';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
@ -47,16 +41,13 @@ export class InfoService {
|
|||||||
private readonly platformService: PlatformService,
|
private readonly platformService: PlatformService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private readonly tagService: TagService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = await this.platformService.getPlatforms({
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const globalPermissions: string[] = [];
|
const globalPermissions: string[] = [];
|
||||||
|
|
||||||
@ -100,22 +91,28 @@ export class InfoService {
|
|||||||
globalPermissions.push(permissions.enableSystemMessage);
|
globalPermissions.push(permissions.enableSystemMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUserSignupEnabled =
|
const [
|
||||||
await this.propertyService.isUserSignupEnabled();
|
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) {
|
if (isUserSignupEnabled) {
|
||||||
globalPermissions.push(permissions.createUserAccount);
|
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 {
|
return {
|
||||||
...info,
|
...info,
|
||||||
benchmarks,
|
benchmarks,
|
||||||
@ -124,8 +121,7 @@ export class InfoService {
|
|||||||
isReadOnlyMode,
|
isReadOnlyMode,
|
||||||
platforms,
|
platforms,
|
||||||
statistics,
|
statistics,
|
||||||
subscriptions,
|
subscriptionOffer,
|
||||||
tags,
|
|
||||||
baseCurrency: DEFAULT_CURRENCY,
|
baseCurrency: DEFAULT_CURRENCY,
|
||||||
currencies: this.exchangeRateDataService.getCurrencies()
|
currencies: this.exchangeRateDataService.getCurrencies()
|
||||||
};
|
};
|
||||||
@ -142,7 +138,7 @@ export class InfoService {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Analytics: {
|
Analytics: {
|
||||||
updatedAt: {
|
lastRequestAt: {
|
||||||
gt: subDays(new Date(), aDays)
|
gt: subDays(new Date(), aDays)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -154,20 +150,15 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countDockerHubPulls(): Promise<number> {
|
private async countDockerHubPulls(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const abortController = new AbortController();
|
const { pull_count } = (await fetch(
|
||||||
|
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
|
||||||
setTimeout(() => {
|
|
||||||
abortController.abort();
|
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
|
||||||
|
|
||||||
const { pull_count } = await got(
|
|
||||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
|
||||||
{
|
{
|
||||||
headers: { 'User-Agent': 'request' },
|
headers: { 'User-Agent': 'request' },
|
||||||
// @ts-ignore
|
signal: AbortSignal.timeout(
|
||||||
signal: abortController.signal
|
this.configurationService.get('REQUEST_TIMEOUT')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
).json<any>();
|
).then((res) => res.json())) as { pull_count: number };
|
||||||
|
|
||||||
return pull_count;
|
return pull_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -179,22 +170,17 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubContributors(): Promise<number> {
|
private async countGitHubContributors(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const abortController = new AbortController();
|
const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
|
||||||
|
signal: AbortSignal.timeout(
|
||||||
setTimeout(() => {
|
this.configurationService.get('REQUEST_TIMEOUT')
|
||||||
abortController.abort();
|
)
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
}).then((res) => res.text());
|
||||||
|
|
||||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
|
||||||
// @ts-ignore
|
|
||||||
signal: abortController.signal
|
|
||||||
});
|
|
||||||
|
|
||||||
const $ = cheerio.load(body);
|
const $ = cheerio.load(body);
|
||||||
|
|
||||||
return extractNumberFromString({
|
return extractNumberFromString({
|
||||||
value: $(
|
value: $(
|
||||||
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
|
||||||
).text()
|
).text()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -206,20 +192,15 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubStargazers(): Promise<number> {
|
private async countGitHubStargazers(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const abortController = new AbortController();
|
const { stargazers_count } = (await fetch(
|
||||||
|
'https://api.github.com/repos/ghostfolio/ghostfolio',
|
||||||
setTimeout(() => {
|
|
||||||
abortController.abort();
|
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
|
||||||
|
|
||||||
const { stargazers_count } = await got(
|
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
|
||||||
{
|
{
|
||||||
headers: { 'User-Agent': 'request' },
|
headers: { 'User-Agent': 'request' },
|
||||||
// @ts-ignore
|
signal: AbortSignal.timeout(
|
||||||
signal: abortController.signal
|
this.configurationService.get('REQUEST_TIMEOUT')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
).json<any>();
|
).then((res) => res.json())) as { stargazers_count: number };
|
||||||
|
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -314,19 +295,6 @@ export class InfoService {
|
|||||||
return statistics;
|
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> {
|
private async getUptime(): Promise<number> {
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@ -334,27 +302,22 @@ export class InfoService {
|
|||||||
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||||
)) as string;
|
)) as string;
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const { data } = await fetch(
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
abortController.abort();
|
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
|
||||||
|
|
||||||
const { data } = await got(
|
|
||||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
subDays(new Date(), 90),
|
subDays(new Date(), 90),
|
||||||
DATE_FORMAT
|
DATE_FORMAT
|
||||||
)}&to${format(new Date(), DATE_FORMAT)}`,
|
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.configurationService.get(
|
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
|
||||||
'API_KEY_BETTER_UPTIME'
|
'API_KEY_BETTER_UPTIME'
|
||||||
)}`
|
)}`
|
||||||
},
|
},
|
||||||
// @ts-ignore
|
signal: AbortSignal.timeout(
|
||||||
signal: abortController.signal
|
this.configurationService.get('REQUEST_TIMEOUT')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
).json<any>();
|
).then((res) => res.json());
|
||||||
|
|
||||||
return data.attributes.availability / 100;
|
return data.attributes.availability / 100;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -26,12 +26,13 @@ export class LogoController {
|
|||||||
@Res() response: Response
|
@Res() response: Response
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
|
const { buffer, type } =
|
||||||
|
await this.logoService.getLogoByDataSourceAndSymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
});
|
});
|
||||||
|
|
||||||
response.contentType('image/png');
|
response.contentType(type);
|
||||||
response.send(buffer);
|
response.send(buffer);
|
||||||
} catch {
|
} catch {
|
||||||
response.status(HttpStatus.NOT_FOUND).send();
|
response.status(HttpStatus.NOT_FOUND).send();
|
||||||
@ -44,9 +45,9 @@ export class LogoController {
|
|||||||
@Res() response: Response
|
@Res() response: Response
|
||||||
) {
|
) {
|
||||||
try {
|
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);
|
response.send(buffer);
|
||||||
} catch {
|
} catch {
|
||||||
response.status(HttpStatus.NOT_FOUND).send();
|
response.status(HttpStatus.NOT_FOUND).send();
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
@ -8,7 +9,11 @@ import { LogoService } from './logo.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [LogoController],
|
controllers: [LogoController],
|
||||||
imports: [ConfigurationModule, SymbolProfileModule],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
SymbolProfileModule,
|
||||||
|
TransformDataSourceInRequestModule
|
||||||
|
],
|
||||||
providers: [LogoService]
|
providers: [LogoService]
|
||||||
})
|
})
|
||||||
export class LogoModule {}
|
export class LogoModule {}
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.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 { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import got from 'got';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -17,7 +16,7 @@ export class LogoService {
|
|||||||
public async getLogoByDataSourceAndSymbol({
|
public async getLogoByDataSourceAndSymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset) {
|
}: AssetProfileIdentifier) {
|
||||||
if (!DataSource[dataSource]) {
|
if (!DataSource[dataSource]) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
@ -29,7 +28,7 @@ export class LogoService {
|
|||||||
{ dataSource, symbol }
|
{ dataSource, symbol }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!assetProfile) {
|
if (!assetProfile?.url) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
StatusCodes.NOT_FOUND
|
StatusCodes.NOT_FOUND
|
||||||
@ -39,24 +38,26 @@ export class LogoService {
|
|||||||
return this.getBuffer(assetProfile.url);
|
return this.getBuffer(assetProfile.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLogoByUrl(aUrl: string) {
|
public getLogoByUrl(aUrl: string) {
|
||||||
return this.getBuffer(aUrl);
|
return this.getBuffer(aUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBuffer(aUrl: string) {
|
private async getBuffer(aUrl: string) {
|
||||||
const abortController = new AbortController();
|
const blob = await fetch(
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
abortController.abort();
|
|
||||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
|
||||||
|
|
||||||
return got(
|
|
||||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
{
|
{
|
||||||
headers: { 'User-Agent': 'request' },
|
headers: { 'User-Agent': 'request' },
|
||||||
// @ts-ignore
|
signal: AbortSignal.timeout(
|
||||||
signal: abortController.signal
|
this.configurationService.get('REQUEST_TIMEOUT')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
).buffer();
|
).then((res) => res.blob());
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: await blob.arrayBuffer().then((arrayBuffer) => {
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
}),
|
||||||
|
type: blob.type
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||||
|
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
@ -10,12 +13,12 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO4217CurrencyCode,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min
|
Min,
|
||||||
|
Validate
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -24,12 +27,12 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(AssetClass, { each: true })
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
assetClass?: AssetClass;
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(AssetSubClass, { each: true })
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
assetSubClass?: AssetSubClass;
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -39,14 +42,19 @@ export class CreateOrderDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsISO4217CurrencyCode()
|
@IsCurrencyCode()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsCurrencyCode()
|
||||||
|
@IsOptional()
|
||||||
|
customCurrency?: string;
|
||||||
|
|
||||||
|
@IsEnum(DataSource)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(DataSource, { each: true })
|
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
|
@Validate(IsAfter1970Constraint)
|
||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user