Compare commits
1191 Commits
Author | SHA1 | Date | |
---|---|---|---|
32fe3e195f | |||
805f4b05be | |||
5b51a6840a | |||
36bd6164e6 | |||
eac52a215b | |||
9ff8cd5471 | |||
33cc7e4e7e | |||
47f84dab06 | |||
384d18b2a6 | |||
2363983bdc | |||
4af76764be | |||
a65424aafa | |||
f9cd629470 | |||
ccb8c86596 | |||
246de7aa86 | |||
a323313c71 | |||
538c8947cd | |||
1ec5fd12fe | |||
4376b8903e | |||
a8e096f9ac | |||
8e577592f6 | |||
c896bf9199 | |||
16145f18d9 | |||
5398da0dc8 | |||
2466f4ff5d | |||
8f3a9bdfbb | |||
44dfd2bd48 | |||
3fc2228f1d | |||
b018819a1f | |||
ac9311d783 | |||
e23ce0f35d | |||
f4b52aa41c | |||
655b040d4d | |||
0f637a5d0f | |||
3f85c327f5 | |||
c2df99072d | |||
e8afbcad9c | |||
e6d8de781b | |||
6e1935899f | |||
169cb85b66 | |||
fe6658d0ac | |||
1f0381228e | |||
f4b63b5de5 | |||
e45a0ad068 | |||
81c6cc021d | |||
859b24aa5b | |||
2bc325f182 | |||
a6186c23e2 | |||
cf234003ec | |||
8d3954304e | |||
9562139fa6 | |||
c857ea9a8f | |||
5c9fa71d95 | |||
fefbfa31d1 | |||
93a1fae51c | |||
3715edd9ba | |||
e3916e1ba3 | |||
76ceac4edc | |||
333b63bfe2 | |||
3006c21b12 | |||
f01a3f893d | |||
72974e888f | |||
0cee7a0b35 | |||
f3d337b044 | |||
7667af059c | |||
1095b47f45 | |||
dacd7271eb | |||
e093041184 | |||
8f2caa508a | |||
862f670ccf | |||
54bf4c7a43 | |||
c0ace51ee9 | |||
b1b5689242 | |||
b68cdaf8ea | |||
b387a80a0d | |||
6e4660295a | |||
d4c3a9d1e8 | |||
263f6b32f2 | |||
637f31ae3b | |||
547e27c7a1 | |||
f10dc176f2 | |||
0a966e46cd | |||
4f281d25e1 | |||
aaba8c35c2 | |||
7d27cb3398 | |||
91678028b5 | |||
5e3cac8ac9 | |||
33f20b6b48 | |||
e4fd255dd7 | |||
e320aa91f7 | |||
0fcfa6c1bd | |||
42d32ed652 | |||
21b4b0ef24 | |||
4f8fe83a16 | |||
980ad1028c | |||
0d5bc3f51b | |||
aece76d98f | |||
fc4bb71184 | |||
20bc7ef99c | |||
7a733ae49b | |||
376ce88492 | |||
c4d83aabe7 | |||
d4e2cec77e | |||
75db7bf79a | |||
3ad99c9991 | |||
00e402d286 | |||
4ac0484025 | |||
75d61bff6d | |||
0de28d733e | |||
3b2f13850c | |||
0cc42ffd7c | |||
3ccb812ac3 | |||
0a8549db3e | |||
c95e90ff31 | |||
b59af0d864 | |||
408bdbd187 | |||
a3bfa46fb0 | |||
8cb1b3f925 | |||
15c650f951 | |||
c198bd78da | |||
35963580bc | |||
cf2c5bad02 | |||
f332aea9b4 | |||
7a9fd18407 | |||
ca08d3154a | |||
01d4ae8757 | |||
43ce2786c1 | |||
de2092c4d2 | |||
435a180e54 | |||
0ad30ffabe | |||
0cc5e558f1 | |||
63b183cc6f | |||
10bae24c5c | |||
0e29278e96 | |||
2db46e5bbf | |||
e757e90e5a | |||
184ddc6209 | |||
e3662a143c | |||
25afd7e07b | |||
7fceaa1350 | |||
7c8530483c | |||
539d3ff754 | |||
9d28b63da6 | |||
24abbd85e6 | |||
b6f395fd3b | |||
04d894cf88 | |||
b4d2c4109e | |||
823093f4d7 | |||
56bf422407 | |||
df0e9ad03b | |||
0e3702c2be | |||
11136ae4f8 | |||
2e6a7d5a91 | |||
83845c256a | |||
34c9703716 | |||
48903238c5 | |||
57a14bd945 | |||
4fd0622114 | |||
52f0fb5ab8 | |||
20195b2b1a | |||
7fa4e6ebd2 | |||
d8531ddfcb | |||
70d670b711 | |||
27b0663a80 | |||
874dfb0235 | |||
072db0d558 | |||
12e692429a | |||
e22b8b78b8 | |||
dc5052f7dc | |||
335553e891 | |||
d480ad1023 | |||
7320751056 | |||
108c0c13c4 | |||
053a5cc5b5 | |||
c456a8bcfe | |||
6fcecb5bc6 | |||
e4e0a7d9f0 | |||
c7173761a3 | |||
185e130d9f | |||
81245635af | |||
55182ac1af | |||
0b446a30ae | |||
c5e6602102 | |||
573038f407 | |||
dbc38e705e | |||
f127e7c61a | |||
4ccabde251 | |||
86ae88f90f | |||
69bc1d67e1 | |||
03942aecda | |||
7ec9170c0d | |||
51431a7fb2 | |||
4adda6783d | |||
d5cd4c0dea | |||
34be10d755 | |||
51f586e160 | |||
ff64a00196 | |||
148f6f8762 | |||
bf2c4d1e9e | |||
eee1f1c722 | |||
9f2a49a1c7 | |||
44058b2d7a | |||
23634f3404 | |||
f93dab6086 | |||
207859cc22 | |||
77181aaaff | |||
412039badf | |||
7619442895 | |||
61ecd66e0f | |||
81217b35ef | |||
678f1f0051 | |||
71c7e37b5a | |||
80459371f3 | |||
35f1f348a8 | |||
0bb0b12991 | |||
d887de50d2 | |||
2571e5b8c0 | |||
e444d717e5 | |||
1866e26c1d | |||
9923074e04 | |||
c367e61b85 | |||
364f1ad9b9 | |||
2394cbd6fe | |||
a74d5cce20 | |||
95bcc3f32d | |||
e9dbd4a55d | |||
d440b09dc9 | |||
cc16ba5dc8 | |||
d10227bc39 | |||
4e214c32e8 | |||
49e2862e03 | |||
34e33a2400 | |||
ec9bc984af | |||
2388c494df | |||
d71ab10eed | |||
0e0592180f | |||
60e2aff488 | |||
7b5454e7de | |||
30835ced88 | |||
8897f32bc5 | |||
abaa6b5f27 | |||
2060fcaf0b | |||
fd2408dd62 | |||
31cca024f1 | |||
b535122945 | |||
5113e4e3ad | |||
35e039748f | |||
c6b9e0aa5b | |||
b250491ca5 | |||
61e501c659 | |||
c0f19d56ec | |||
8e2b235b1f | |||
c3407e9b34 | |||
74193e4ee2 | |||
3fe8f9c882 | |||
d130efad47 | |||
109f0ebd70 | |||
069ddcc6b2 | |||
f7bf6e652b | |||
eb059a024a | |||
ad88acff1c | |||
1ff736537c | |||
1fa65e1efd | |||
df6bb489c2 | |||
928a13310d | |||
2384861953 | |||
fe90bda6fb | |||
d4b29ff11c | |||
a0a26cfa58 | |||
1610150427 | |||
cff8acd7b1 | |||
0f36d6cbdb | |||
046e28b521 | |||
aba562cb35 | |||
03f2f33344 | |||
a996dd7ed5 | |||
002b883668 | |||
0b06823893 | |||
2dfd779444 | |||
1824413379 | |||
3332ade3d3 | |||
8d2e110e3d | |||
a8fcf09380 | |||
1071f446a8 | |||
03b050d1ac | |||
58eeff7001 | |||
76fb8825e4 | |||
0f9d142afe | |||
bd33855a27 | |||
5329e45e2c | |||
e990ecd12c | |||
a4fcf64f13 | |||
557e3a0676 | |||
2abe399ebd | |||
74fe90906a | |||
4cb9a3b142 | |||
0da9368e0c | |||
d2f8e3d645 | |||
5263fba64e | |||
e3689c48f8 | |||
787efdb33b | |||
e63578d8ce | |||
7cf0cdc4ce | |||
14a0eeab29 | |||
6774c48dff | |||
565947e752 | |||
2cc7c6fa1c | |||
023a7147e2 | |||
a96e89a86e | |||
b9c9443899 | |||
f1e06347d3 | |||
697e92f818 | |||
b678998801 | |||
de53cf1884 | |||
bbe30218bd | |||
15dda886a0 | |||
34d4212f55 | |||
f7060230b7 | |||
0fdafcb7e4 | |||
e79be9f2d6 | |||
69088b93a6 | |||
c3768a882d | |||
3498ed8549 | |||
c07c300fef | |||
c62a5af9eb | |||
0c04f10e19 | |||
2c4c16ec99 | |||
4711b0d1ed | |||
a8521e0ecf | |||
424748ae90 | |||
9c4d8bdf4b | |||
332203b9e2 | |||
f48832c671 | |||
ae8a203526 | |||
d0c1506ded | |||
af0863d193 | |||
f5819cc399 | |||
977c5a9544 | |||
b9cd42cd53 | |||
379977008d | |||
38f9d54705 | |||
5cb6e5dec6 | |||
4a123c38f2 | |||
160335302a | |||
f1483569a2 | |||
5391b88c42 | |||
2b63f7e707 | |||
d5c96d1cb7 | |||
1a4dc51825 | |||
d094bae7de | |||
57bf10e7e7 | |||
c1d460cead | |||
dfa67b275c | |||
80862e5c2a | |||
904d4db219 | |||
10f13eec48 | |||
ea3a9d3b79 | |||
e55b05fe3d | |||
32dd76be5f | |||
ff9b6bb4df | |||
5be95b7b63 | |||
b3e07c8446 | |||
eb9cece4e4 | |||
b331f5f04d | |||
34cbdd7c2a | |||
57314d62ee | |||
40380346e6 | |||
5622c4cf7e | |||
21173bed21 | |||
16dd8f7652 | |||
ce6b5fb7cb | |||
f6f62db830 | |||
01103f3db4 | |||
e9e9f1a124 | |||
751256f158 | |||
c2a1cbd20f | |||
04044f8720 | |||
4dc76817ce | |||
1f0bd5a7db | |||
b6cd007ad4 | |||
b4bc72c6f9 | |||
899fa0370e | |||
da27504aa1 | |||
b7bbc029ac | |||
c61a415fb2 | |||
8ff811ed28 | |||
9a2ea0a4ed | |||
bad9d17c44 | |||
ea89ca5734 | |||
8f61f7c169 | |||
edca05f542 | |||
283f054ee2 | |||
e9a46cb224 | |||
4a75c6d483 | |||
bbe9183fb0 | |||
1b03ddc586 | |||
beb12637ce | |||
20358d9105 | |||
0e4c39d145 | |||
83ebacbb06 | |||
7c58c5fb7f | |||
f3271ab1ff | |||
9f597cbff1 | |||
90efc2ac51 | |||
056b318d86 | |||
82ede2fe32 | |||
8ae041faa0 | |||
bd4608e521 | |||
0d8362ca8f | |||
638ae3f7fa | |||
6e7cf0380b | |||
ec2ecab751 | |||
598fe41b8c | |||
ba7c98d325 | |||
65e062ad26 | |||
8526b5a027 | |||
f1feb04f29 | |||
500e09d95a | |||
aef91d3e30 | |||
70723f8d5f | |||
6cfd052781 | |||
23f2ac472e | |||
d5ba624403 | |||
9b49ed77f7 | |||
08405d14d5 | |||
56b169e1c4 | |||
67f2b326f3 | |||
3d3a6c1204 | |||
bfc8f87d88 | |||
957200854c | |||
6575440877 | |||
255af6a6e9 | |||
795a6a6799 | |||
2a854e2574 | |||
52d113e71f | |||
204c7360c3 | |||
fa41e25c8f | |||
ba765b9de6 | |||
fa79196278 | |||
d1230ca3ad | |||
69a1316cfe | |||
a256b783bc | |||
ebbdd47fa2 | |||
3d21e2eac6 | |||
bc117fe601 | |||
65f6bcb166 | |||
b8c43ecf89 | |||
1214127ec0 | |||
e986310302 | |||
6762572658 | |||
eb77652d6a | |||
a7b59f4ec6 | |||
dd71f2be45 | |||
d530cb38fa | |||
16b79a7e60 | |||
7f0c98cae6 | |||
57e4163848 | |||
14773bf1aa | |||
1a8fc5757a | |||
b4848be914 | |||
2b4319454d | |||
e2faaf6faa | |||
86a1589834 | |||
9f67993c03 | |||
32fb3551dc | |||
30411b1502 | |||
eb0444603b | |||
6e582fe505 | |||
402d73a12c | |||
4826a51199 | |||
5356bf568e | |||
d8da574ae4 | |||
e769fabbae | |||
5a369f29d4 | |||
122ba9046f | |||
f781eb207c | |||
7b6893b5ed | |||
07799573cb | |||
9cdef6a7cb | |||
0d897bc461 | |||
e4908b51aa | |||
718b0de0a7 | |||
99655604d9 | |||
b602e7690b | |||
7745dafe48 | |||
50184284e1 | |||
f46533107d | |||
c216ab1d76 | |||
86acbf06f4 | |||
3de7d3f60e | |||
63ed227f3f | |||
5bb20f6d5f | |||
b3e58d182a | |||
93d6746739 | |||
e3f8b0cf52 | |||
c02bcd9bd8 | |||
6a4f1c0188 | |||
745ba978a3 | |||
46b91d3c3b | |||
1dd670a7c3 | |||
68d07cc8d4 | |||
02809a529e | |||
fd60569716 | |||
fed771525e | |||
a5771f601d | |||
2a2a5f4da5 | |||
06d5ec9182 | |||
122107c8a1 | |||
ca46a9827a | |||
4ec351369b | |||
dced06ebb5 | |||
baa6a3d0f0 | |||
d3382f0809 | |||
1eb4041837 | |||
5a869a90da | |||
280030ae7f | |||
52e4504de9 | |||
20356f6931 | |||
e0bb2b1c78 | |||
ec806be45f | |||
809ee97f6f | |||
893ca83d3a | |||
23da1bd293 | |||
fa66cd5bce | |||
9344dcd26e | |||
90ad22cccf | |||
dcc7ef89fe | |||
e355847f40 | |||
76f70598e2 | |||
7af5cd244a | |||
86943a5f5b | |||
6eb4eae4a9 | |||
6ac693dd39 | |||
e29f7f8976 | |||
82069da4e2 | |||
07656c6a95 | |||
16f0743353 | |||
9b5ec0c56d | |||
8d2fcc6b42 | |||
e625e55784 | |||
bed3e5aae2 | |||
65bfe52db4 | |||
48b524de5a | |||
67d40333f6 | |||
48f6b8d353 | |||
f369996912 | |||
dc424a86ec | |||
5d8bde5a70 | |||
16360c0c67 | |||
526a6b2030 | |||
5000e9c79b | |||
161cb82820 | |||
fed28f29d1 | |||
8bd9330acc | |||
155c08d665 | |||
b8ad6d6662 | |||
9d6977e3f7 | |||
919b20197f | |||
62885ea890 | |||
035d8ad9eb | |||
9676f96e97 | |||
65e151151b | |||
5d3bbb8f30 | |||
b464fefc57 | |||
bcb7f5f522 | |||
f15b33e950 | |||
ca64492e77 | |||
761376d72d | |||
9c086edffe | |||
585f99e4df | |||
9d907b5eb5 | |||
ba05f5ba30 | |||
3261e3ee59 | |||
5607c6bb52 | |||
1c6050d3e3 | |||
38f2930ec6 | |||
556be61fff | |||
651b4bcff7 | |||
0a8d159f78 | |||
1a4109ebaa | |||
92e502e1c2 | |||
e344c43a5a | |||
d6b78f3457 | |||
9bbb856f66 | |||
d3707bbb87 | |||
7df53896f3 | |||
b2b3fde80e | |||
a83441b3ba | |||
075431d868 | |||
0168c1c4e8 | |||
07de8f87fc | |||
3e16041c16 | |||
5882b7914d | |||
69c9e259b1 | |||
aca37a27f9 | |||
313d2a2f79 | |||
9ac67b0af2 | |||
1e526852a7 | |||
e54638a684 | |||
0179823ad9 | |||
029b7bed9a | |||
635f10e2d0 | |||
cebf879d67 | |||
124bdc028d | |||
d69a69ce18 | |||
15344513ce | |||
b291d9e031 | |||
bee702302f | |||
bb56e09a13 | |||
0873f539c5 | |||
6dcd801d05 | |||
77065dac50 | |||
438484879d | |||
e37a650c70 | |||
6e8c90b3fc | |||
9e1a7fc981 | |||
ff638adf03 | |||
fa44cee781 | |||
db1d474ddf | |||
994275e093 | |||
ee397c8047 | |||
7203939c42 | |||
9725f16c81 | |||
bb8b1e4f43 | |||
9d3610331a | |||
0043b44670 | |||
bbc4e64cb4 | |||
c7f4825499 | |||
8f583709ef | |||
4c30212a72 | |||
cade2f6a5e | |||
3b9a8fabb5 | |||
3435b3a348 | |||
5d39b267ab | |||
ffaaa14dba | |||
c65746d119 | |||
1a6840f1f6 | |||
fb7fb886f6 | |||
127abb8f4e | |||
ed1136999a | |||
9f545e3e2b | |||
1602f976f0 | |||
4bf4c1a8a3 | |||
e78755c280 | |||
7772684413 | |||
955302666e | |||
ddce8cc7f9 | |||
aca0d77e91 | |||
8b9379f5ce | |||
0806d0dc92 | |||
e518bc3779 | |||
eff807dd9a | |||
155bf67f60 | |||
9aefe3747e | |||
0878febded | |||
f953c6ea64 | |||
76dbf78279 | |||
f12866b9ec | |||
83ba5f3d9f | |||
7439c1bf54 | |||
e255b76053 | |||
ed7209fb53 | |||
008a2ab123 | |||
2aedd74480 | |||
11076592d1 | |||
ebee851b23 | |||
7d3f1832b4 | |||
39e6abfc8c | |||
78e0fdb0ca | |||
606350b2ff | |||
d09cad4e05 | |||
069660afe4 | |||
4d9a223491 | |||
aed8f5cf04 | |||
1beb4de62f | |||
9bc3505ded | |||
3e82de6b21 | |||
563f354e7e | |||
e96e6c717c | |||
961774ce9f | |||
49f46e1a1e | |||
050c0a4da7 | |||
4908e6d35d | |||
fe4013830d | |||
11be6f630f | |||
85d123e1b1 | |||
c5e9804c25 | |||
1f042ee791 | |||
da6eaa0d77 | |||
3f31cec859 | |||
6c07759eb7 | |||
fcf07a0fd1 | |||
2f402c0c8e | |||
a24a094407 | |||
dc9b2ce194 | |||
72067459d6 | |||
705441ecf8 | |||
fbd1475402 | |||
4dc4f13f40 | |||
3b857aa8bb | |||
1c2ca5b96b | |||
572bfc59b8 | |||
147f0162b7 | |||
f6acf5207b | |||
80782f1098 | |||
bc58ee86ca | |||
0cb632b165 | |||
fca3a659d0 | |||
904dec040e | |||
fc6c81fe02 | |||
634171e4e3 | |||
f8f36e4f4e | |||
5e7cf9d0b6 | |||
e1932eb5a1 | |||
dba47d59e3 | |||
3032126508 | |||
a50b55da75 | |||
5422df05b3 | |||
d2fabe7ce4 | |||
a42700b9fe | |||
9df8541145 | |||
0b2252755c | |||
239bd09cbd | |||
cd76f89902 | |||
7425ba94f1 | |||
b9522307c4 | |||
e01e039a00 | |||
6d3513c17f | |||
d60b444324 | |||
2873130259 | |||
d999a27159 | |||
b6902e10ea | |||
7f3f75386d | |||
678544748a | |||
632f3e3872 | |||
87301ddbd5 | |||
7d03c373ac | |||
edb66bb166 | |||
54bbc8446b | |||
9933967e42 | |||
5618513d07 | |||
1397cd62a8 | |||
e7fb31d1a6 | |||
51e7b94ad0 | |||
9133ea38f3 | |||
a864c617b9 | |||
442df9d6f8 | |||
2de0e75cb8 | |||
1296f95602 | |||
e98dff877a | |||
964b37af30 | |||
2d89d12d31 | |||
19e2d54791 | |||
e24e5e1c44 | |||
a0d4ff7920 | |||
099ad18aaf | |||
e3cd99f5d2 | |||
6dea9093ba | |||
43104f81d0 | |||
6d2e3b6e40 | |||
1d9a31dbb8 | |||
ea0e92220c | |||
b57301ef50 | |||
67dbc6b014 | |||
2e5176bacf | |||
060846023f | |||
f06a0fbbee | |||
4ab6a1a071 | |||
93dcbeb6c7 | |||
b9f0a57522 | |||
174c1d1a62 | |||
f308ae7a13 | |||
a7a6b0608b | |||
15a61b7a20 | |||
d1eedf9726 | |||
30a592b524 | |||
de94494aa0 | |||
d3c6788ad5 | |||
3ec4a73b35 | |||
1050bfa098 | |||
595ec1d7b4 | |||
c8389599b6 | |||
8769fe4c90 | |||
4219e1121e | |||
f558eb8de8 | |||
fe2bd6eea8 | |||
035052be99 | |||
bcdd2780b3 | |||
22d1ed7920 | |||
39d9828f9f | |||
6333aa972d | |||
554f2f861f | |||
dcee651098 | |||
508a48f4c3 | |||
8466e3d73f | |||
9ae9904389 | |||
af022ae316 | |||
5cd6edaf3a | |||
98be8745d9 | |||
861dff9210 | |||
f2364eed10 | |||
d5392de7c9 | |||
0f72673ef4 | |||
641fe4e8f4 | |||
18e06bb6e6 | |||
5b588c2000 | |||
162d19fa44 | |||
4a815d2031 | |||
d2aeeb3e88 | |||
ba926ffcf2 | |||
5ea455b98b | |||
39f315aba0 | |||
df2dfc20a1 | |||
81e83d4cea | |||
5d4156ecec | |||
4693a8baa2 | |||
773444b1e2 | |||
3c46bde8d5 | |||
63ee33b685 | |||
bc87c0a3e1 | |||
caa9fc3efa | |||
9ed82ac82b | |||
9c9ca4ab1e | |||
b0b0942162 | |||
9cbf789c22 | |||
ee5ab05d8a | |||
20731c67cb | |||
bf8856ad19 | |||
a31d79821d | |||
48ab862bb6 | |||
ba234a470e | |||
ccae660104 | |||
21ed91d184 | |||
5fd413e57e | |||
4c194c938a | |||
a4d049e53d | |||
f9c4408126 | |||
d046f1d498 | |||
ad96d6e53e | |||
747e5b63fa | |||
b1187cf880 | |||
ba9e6eab58 | |||
01feead017 | |||
6a0cfb8f77 | |||
6386786ac0 | |||
d3be6577c8 | |||
73a967a7e5 | |||
836ff6ec13 | |||
c5bb3023d3 | |||
695c378b48 | |||
fe975945d1 | |||
d8782b0d4c | |||
e14f08a8fb | |||
72c065a59d | |||
98dac4052a | |||
2083d28d02 | |||
addd5c36d9 | |||
aad8f77093 | |||
a904208d06 | |||
2733b78044 | |||
b43b515df1 | |||
70e14b4d3c | |||
0f7d1b7d59 | |||
c2ab6a6c44 | |||
c71a4c078e | |||
e17b217032 | |||
408e08d43c | |||
2fa324702f | |||
05b0efef82 | |||
7c91727eb1 | |||
0ee2258af8 | |||
308b218487 | |||
26694adb8e | |||
77936e3bf3 | |||
13090bf6b5 | |||
b898c0678d | |||
3330ae70b6 | |||
96a615dc5d | |||
98f44323da | |||
8adacd9760 | |||
908aba170d | |||
d599797a65 | |||
8ac1272a9d | |||
0a85a56c67 | |||
4ad5590838 | |||
5b8af68e71 | |||
80d043729d | |||
178166d86b | |||
37358fb480 | |||
616d601cf6 | |||
e88b889fdd | |||
f6cdc4ff47 | |||
818c40fc61 | |||
3589e72aea | |||
e68aa1fa68 | |||
bb76ace95d | |||
2bd9309827 | |||
f743c03e17 | |||
dfcf826b4f | |||
218efbb5bd | |||
ba3b4564cd | |||
3723a5c725 | |||
bf256ae50c | |||
c4b6273886 | |||
7f047362cc | |||
66900ffa24 | |||
33e05e949a | |||
94d2310217 | |||
b7d950f3f9 | |||
b7943889da | |||
84aa542560 | |||
4bd41ffa41 | |||
56c332c59a | |||
423ceec317 | |||
5b4a1785ae | |||
56a5664c87 | |||
823501f43e | |||
331ac7ded2 | |||
92b44ba658 | |||
982ba7377a | |||
4bbd17a37a | |||
6a7def6c48 | |||
ea219f0b88 | |||
23b2e03923 | |||
04e6518226 | |||
72dbe00091 | |||
9834c52739 | |||
c47578bd3e | |||
9e4a49d811 | |||
a3a98c68a5 | |||
21570cca19 | |||
71a3115fc6 | |||
de83dc7b84 | |||
4a0695613e | |||
328d814922 | |||
d23addb673 | |||
fb15cebb64 | |||
9c51a257ae | |||
f9b9dc32cb | |||
e7194ef3ce | |||
ec5523b459 | |||
c8c21a016a | |||
9821b7f8f0 | |||
ed731afc66 | |||
ff15d5cbc4 | |||
3c4949de35 | |||
bd0e53525b | |||
cbdb68e2f8 | |||
8571709014 | |||
e7ef1d426e | |||
39cba0a8eb | |||
a90c314e30 | |||
47d71405e1 | |||
5e9cecc6c1 | |||
fb9e66318f | |||
b8194eb64f | |||
cbb81916ee | |||
9b1e9397a8 | |||
b779964adb | |||
409afac2a9 | |||
e0a4e16ea1 | |||
dc84abdc0a | |||
b031b028f1 | |||
3b7e0a0106 | |||
ea66081073 | |||
602a770a09 | |||
e522722aa6 | |||
03ca5d7663 | |||
136563c949 | |||
948c45c602 | |||
e0be792e46 | |||
c3d010135f | |||
d6a16a6093 | |||
34c13c80ec | |||
f65a108436 | |||
993f066e08 | |||
852902d1ab | |||
ee89822bfe | |||
e0435e5cad | |||
e2c23703dc | |||
1226c26a9d | |||
fdc89f7182 | |||
1e368d6e2d | |||
04e03bd080 | |||
66e7ad3fd2 | |||
b4dc21dd61 | |||
8a482e63b9 | |||
aabfb39e8f | |||
cdc8faff7f | |||
7b696e39de | |||
c88ad2c225 | |||
fbc9269abf | |||
cbe079ae66 | |||
8e4ee7feea | |||
f1b3c61675 | |||
24dc312367 | |||
7ac7442f73 | |||
099571437e | |||
7dac059a55 | |||
48fbeda72d | |||
19007cdc34 | |||
5037393866 | |||
ddf24163b4 | |||
b26521c4bd | |||
cfee6c1ddd | |||
19bcd601d1 | |||
836df69e68 | |||
dd86adcea1 | |||
4f7628921d | |||
88f0cb095d | |||
7538133d09 | |||
50b280c5a6 | |||
67606e4026 | |||
9de56c32ac | |||
5c0f710563 | |||
1c65599a16 | |||
61e667213e | |||
ba47212057 | |||
f0c6517019 | |||
80ba112bc0 | |||
40696b425e | |||
6dbdf23a68 | |||
cdcbe3ab71 | |||
6996e5a140 | |||
be8d60968d | |||
d53e5c4da5 | |||
a3a9957196 | |||
9072cbdba1 | |||
120b691336 | |||
bd4ad76953 | |||
94d56f553f | |||
ecdd325228 | |||
51fbc538ca | |||
39a76f7f40 | |||
e4d325daab | |||
b765df65d6 | |||
c7b7efae3b | |||
be5b58f49a | |||
91c748c7ad | |||
ecfe694f0b | |||
1491bf7f76 | |||
b3b9a051c3 | |||
bf1146bfd6 | |||
0774ca91a1 | |||
f403807f2d | |||
f22991b090 | |||
1135a5b335 | |||
d9ea255c17 | |||
2c19d8c8e7 | |||
db090229ce | |||
fbe590ddb9 | |||
0d65136a9e | |||
dea87cc3cf | |||
a062a3cee4 | |||
5b1b207a6f | |||
63cc7b2871 | |||
3986e8f879 | |||
290e93bbd7 | |||
b08ecd1b18 | |||
92d321a001 | |||
ce2d8d519d | |||
f32bef071e | |||
4aa7365d9b | |||
367f25a975 | |||
9832334da1 | |||
e126f9ec54 | |||
09bbda3502 | |||
ee9a521813 | |||
169c151547 | |||
3a95ec0f81 | |||
ad00cd9d81 | |||
373a2015c0 | |||
66c955ad6c | |||
a2440fc067 | |||
3d7624d997 | |||
0264b592b9 | |||
198eaf57d3 | |||
6783ea2ebb | |||
a35701fe24 | |||
5db90f1787 | |||
81fe538484 | |||
51884913be | |||
8886082dfa | |||
3b12e5b85b | |||
6c1119caec | |||
698d5ec3b7 | |||
e87c942cb8 | |||
f7860a9799 | |||
c519eb0e99 | |||
8314b98f81 | |||
194cf1ddcc | |||
7da6478699 | |||
4f2bbba782 | |||
9eb25f6c9e | |||
f74b00446c | |||
beb7e6ec34 | |||
2eafc042ad | |||
74954bc51d | |||
6a03120225 | |||
21504573b4 | |||
fabd912fba | |||
00b42855b6 | |||
ef272360fb | |||
026a5011d4 | |||
aa4206af0e | |||
7788465272 | |||
3066dfd805 | |||
34303163bc | |||
e7fbcd4fa0 | |||
7c22969de1 | |||
6623bc0113 | |||
146b5201b5 | |||
b021fbde59 | |||
ec046b81a7 | |||
aea497154a | |||
dc736d53b4 | |||
5957b33779 | |||
bafdce56ad | |||
42a2d404e4 | |||
11b2379d98 | |||
c0657a2e9e | |||
646dcb91c5 | |||
ad961f3039 | |||
c16f743b07 | |||
8e13f6ef9b | |||
95bcdea69b | |||
0d6fe4a232 | |||
ced4519412 | |||
ef8b7718b1 | |||
b4762dc463 | |||
9851cce382 | |||
a1460a98fd | |||
1a553a296f | |||
f5bd6b0d58 | |||
78a4946e8b | |||
702ee956a2 | |||
200a7d2d65 | |||
79edc09710 | |||
77255df4be | |||
277133fa1a | |||
abd0e08566 | |||
561d8dbc70 | |||
c973ffd3ba | |||
368de7dedc | |||
e56514629f | |||
7a8a25c4c0 | |||
5d36d3a6bb | |||
0ef35fd31f | |||
c1c22c195d | |||
111d8d8e3c | |||
b0a24e4fc0 | |||
694b9b8991 | |||
fada347aa5 | |||
f37ea9f0e7 | |||
59911925c2 | |||
58fd59beb1 | |||
eb09d77251 | |||
42b9178d96 | |||
45516311f5 | |||
04cfa7366f | |||
4234ab84a9 | |||
b8c05d1014 | |||
91ec9aa0a4 | |||
565e920f1b | |||
5d24adfa75 | |||
1dc94c0027 | |||
ebae2f4ec9 | |||
7099edc591 | |||
de973d6bda | |||
993a491d24 | |||
631efff7ae | |||
a3d1ac2ce4 | |||
4484c21757 | |||
87cd3ef33f | |||
163f4a3d3f | |||
a84256dc03 | |||
cf82066976 | |||
e248c9cedd | |||
90a2fea7d6 | |||
d17b02092e | |||
c70eb7793e | |||
e3a1d2b9cf | |||
e68ebdf76d | |||
949cf58eef | |||
0816defb95 | |||
a076a1c933 | |||
40c95a541d | |||
2d04d7b8d5 | |||
94e0feac68 | |||
cd9e974c40 | |||
11700f75d9 |
12
.env
12
.env
@ -3,14 +3,14 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||
# CACHE
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||
|
||||
# POSTGRES
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=ghostfolio-db
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
ALPHA_VANTAGE_API_KEY=
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/ghostfolio-db?sslmode=prefer
|
||||
JWT_SECRET_KEY=123456
|
||||
PORT=3333
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
|
@ -44,7 +44,7 @@
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/member-ordering": "error",
|
||||
"@typescript-eslint/naming-convention": "error",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-interface": "error",
|
||||
"@typescript-eslint/no-inferrable-types": [
|
||||
|
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
custom: ['https://www.buymeacoffee.com/ghostfolio']
|
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: '[BUG]'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
|
||||
**Bug Description**
|
||||
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Screenshots**
|
||||
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
|
||||
**Logs**
|
||||
|
||||
<!-- If applicable, add logs to help explain your problem. -->
|
||||
|
||||
**Environment**
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Browser
|
||||
- OS
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
36
.github/workflows/build-code.yml
vendored
Normal file
36
.github/workflows/build-code.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
name: Build code
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 16
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node_version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Check formatting
|
||||
run: yarn format:check
|
||||
|
||||
- name: Execute tests
|
||||
run: yarn test
|
||||
|
||||
- name: Build application
|
||||
run: yarn build:all
|
49
.github/workflows/docker-image.yml
vendored
Normal file
49
.github/workflows/docker-image.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
name: Docker image CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
build_and_push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ghostfolio/ghostfolio
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.output.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -23,15 +23,17 @@
|
||||
!.vscode/settings.json
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
.env.prod
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/dist
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
|
@ -1 +1,2 @@
|
||||
/dist
|
||||
/test/import
|
||||
|
@ -1,4 +1,13 @@
|
||||
{
|
||||
"attributeGroups": [
|
||||
"$ANGULAR_ELEMENT_REF",
|
||||
"$ANGULAR_STRUCTURAL_DIRECTIVE",
|
||||
"$DEFAULT",
|
||||
"$ANGULAR_INPUT",
|
||||
"$ANGULAR_TWO_WAY_BINDING",
|
||||
"$ANGULAR_OUTPUT"
|
||||
],
|
||||
"attributeSort": "ASC",
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
|
11
.storybook/main.js
Normal file
11
.storybook/main.js
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
stories: [],
|
||||
addons: ['@storybook/addon-essentials']
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
||||
// webpackFinal: async (config, { configType }) => {
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||
|
||||
// // Return the altered config
|
||||
// return config;
|
||||
// },
|
||||
};
|
10
.storybook/tsconfig.json
Normal file
10
.storybook/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"exclude": [
|
||||
"../**/*.spec.js",
|
||||
"../**/*.spec.ts",
|
||||
"../**/*.spec.tsx",
|
||||
"../**/*.spec.jsx"
|
||||
],
|
||||
"include": ["../**/*"]
|
||||
}
|
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -26,7 +26,8 @@
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/node_modules/**/*.js",
|
||||
"<node_internals>/**/*.js"
|
||||
]
|
||||
],
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
2632
CHANGELOG.md
2632
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
62
Dockerfile
Normal file
62
Dockerfile
Normal file
@ -0,0 +1,62 @@
|
||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
|
||||
# Only add basic files without the application itself to avoid rebuilding
|
||||
# layers when files (package.json etc.) have not changed
|
||||
COPY ./CHANGELOG.md CHANGELOG.md
|
||||
COPY ./LICENSE LICENSE
|
||||
COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./.yarnrc .yarnrc
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
git \
|
||||
g++ \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN yarn install
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
RUN node decorate-angular-cli.js
|
||||
|
||||
COPY ./angular.json angular.json
|
||||
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
|
||||
|
||||
RUN yarn build:all
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
# package.json was generated by the build process, however the original
|
||||
# yarn.lock needs to be used to ensure the same versions
|
||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||
|
||||
RUN yarn
|
||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||
|
||||
# Overwrite the generated package.json with the original one to ensure having
|
||||
# all the scripts
|
||||
COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:16-slim
|
||||
RUN apt update && apt install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE 3333
|
||||
CMD [ "yarn", "start:prod" ]
|
241
README.md
241
README.md
@ -1,49 +1,69 @@
|
||||
<div align="center">
|
||||
<a href="https://ghostfol.io">
|
||||
<img
|
||||
alt="Ghostfolio Logo"
|
||||
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
||||
width="100"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<h1>Ghostfolio</h1>
|
||||
<p>
|
||||
<strong>Open Source Portfolio Tracker</strong>
|
||||
<strong>Open Source Wealth Management Software</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
|
||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#contributing">
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3">
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source portfolio tracker. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
||||
</div>
|
||||
|
||||
## Ghostfolio Premium
|
||||
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
Ghostfolio is for you if you are...
|
||||
|
||||
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
|
||||
|
||||
- 🏦 pursuing a buy & hold strategy
|
||||
|
||||
- 🎯 interested in getting insights of your portfolio composition
|
||||
|
||||
- 👻 valuing privacy and data ownership
|
||||
|
||||
- 🧘 into minimalism
|
||||
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
|
||||
- 🆓 interested in financial independence
|
||||
|
||||
- 🙅 saying no to spreadsheets in 2021
|
||||
|
||||
- 🙅 saying no to spreadsheets in 2022
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
- ✅ Dark Mode
|
||||
- ✅ Zen Mode
|
||||
- ✅ Mobile-first design
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||
</div>
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@ -51,52 +71,199 @@ Ghostfolio is a modern web application written in [TypeScript](https://www.types
|
||||
|
||||
### Backend
|
||||
|
||||
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database and [Redis](https://redis.io) for caching.
|
||||
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database together with [Prisma](https://www.prisma.io) and [Redis](https://redis.io) for caching.
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Angular](https://angular.io).
|
||||
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).
|
||||
|
||||
## Getting Started
|
||||
## Self-hosting
|
||||
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Basic knowledge of Docker
|
||||
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- Local copy of this Git repository (clone)
|
||||
|
||||
#### a. Run environment
|
||||
|
||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
#### b. Build and run environment
|
||||
|
||||
Run the following commands to build and start the Docker images:
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
#### Fetch Historical Data
|
||||
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
#### Upgrade Version
|
||||
|
||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||
At each start, the container will automatically apply the database schema migrations if needed.
|
||||
|
||||
### Run with _Unraid_ (Community)
|
||||
|
||||
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- A local copy of this Git repository (clone)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
2. Run `cd docker`
|
||||
3. Run `docker compose build`
|
||||
4. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
5. Run `cd -` to go back to the project root directory
|
||||
6. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
||||
7. Start server and client (see _Development_)
|
||||
8. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
9. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
|
||||
10. Press _Sign out_ and check out the _Live Demo_
|
||||
1. Run `yarn build:dev` to build the source code including the assets
|
||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
## Development
|
||||
### Start Server
|
||||
|
||||
Please make sure you have completed the instructions from _Setup_
|
||||
<ol type="a">
|
||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
||||
<li>Serve: Run <code>yarn start:server</code></li>
|
||||
</ol>
|
||||
|
||||
### Start server
|
||||
### Start Client
|
||||
|
||||
- Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_
|
||||
- Serve: Run `yarn start:server`
|
||||
Run `yarn start:client`
|
||||
|
||||
### Start client
|
||||
### Start _Storybook_
|
||||
|
||||
- Run `yarn start:client`
|
||||
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
|
||||
|
||||
### Import Activities
|
||||
|
||||
#### Request
|
||||
|
||||
`POST http://localhost:3333/api/v1/import`
|
||||
|
||||
#### Authorization: Bearer Token
|
||||
|
||||
Set the header as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
#### Body
|
||||
|
||||
```
|
||||
{
|
||||
"activities": [
|
||||
{
|
||||
"currency": "USD",
|
||||
"dataSource": "YAHOO",
|
||||
"date": "2021-09-15T00:00:00.000Z",
|
||||
"fee": 19,
|
||||
"quantity": 5,
|
||||
"symbol": "MSFT"
|
||||
"type": "BUY",
|
||||
"unitPrice": 298.58
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------------------- | -------------------------------------------------- |
|
||||
| accountId | string (`optional`) | Id of the account |
|
||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| date | string | Date in the format `ISO-8601` |
|
||||
| fee | number | Fee of the activity |
|
||||
| quantity | number | Quantity of the activity |
|
||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
|
||||
| unitPrice | number | Price per unit of the activity |
|
||||
|
||||
#### Response
|
||||
|
||||
##### Success
|
||||
|
||||
`201 Created`
|
||||
|
||||
##### Error
|
||||
|
||||
`400 Bad Request`
|
||||
|
||||
```
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": [
|
||||
"activities.1 is a duplicate activity"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. 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)**.
|
||||
|
||||
## License
|
||||
|
||||
© 2021 [Ghostfolio](https://ghostfol.io)
|
||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
242
angular.json
242
angular.json
@ -1,21 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"defaultCollection": "@nrwl/nest"
|
||||
},
|
||||
"defaultProject": "api",
|
||||
"schematics": {
|
||||
"@nrwl/angular:application": {
|
||||
"unitTestRunner": "jest",
|
||||
"e2eTestRunner": "cypress"
|
||||
},
|
||||
"@nrwl/angular:library": {
|
||||
"unitTestRunner": "jest"
|
||||
},
|
||||
"@nrwl/nest": {}
|
||||
},
|
||||
"projects": {
|
||||
"api": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/api",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
@ -23,7 +10,7 @@
|
||||
"schematics": {},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@nrwl/node:build",
|
||||
"builder": "@nrwl/node:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
@ -32,6 +19,7 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
@ -46,7 +34,7 @@
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@nrwl/node:execute",
|
||||
"builder": "@nrwl/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
@ -60,14 +48,16 @@
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.js",
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/api"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
@ -86,44 +76,78 @@
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"aot": true,
|
||||
"assets": [
|
||||
"apps/client/src/assets",
|
||||
{
|
||||
"glob": "assetlinks.json",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./.well-known"
|
||||
"output": "./../.well-known"
|
||||
},
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./"
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./"
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./"
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ionicons/dist/ionicons",
|
||||
"output": "./ionicons"
|
||||
"output": "./../ionicons"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.js",
|
||||
"input": "node_modules/ionicons/dist/",
|
||||
"output": "./"
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../assets/"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/lib/marked.js"]
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
},
|
||||
"development-en": {
|
||||
"baseHref": "/en/",
|
||||
"localize": ["en"]
|
||||
},
|
||||
"development-es": {
|
||||
"baseHref": "/es/",
|
||||
"localize": ["es"]
|
||||
},
|
||||
"development-it": {
|
||||
"baseHref": "/it/",
|
||||
"localize": ["it"]
|
||||
},
|
||||
"development-nl": {
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
@ -152,7 +176,8 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
@ -161,15 +186,38 @@
|
||||
"proxyConfig": "apps/client/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"browserTarget": "client:build:development-de"
|
||||
},
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
},
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
"options": {
|
||||
"browserTarget": "client:build"
|
||||
"browserTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
@ -181,14 +229,37 @@
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/client/jest.config.js",
|
||||
"jestConfig": "apps/client/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/client"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
},
|
||||
"es": {
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
},
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/client-e2e",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
@ -206,28 +277,119 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
},
|
||||
"helper": {
|
||||
"root": "libs/helper",
|
||||
"sourceRoot": "libs/helper/src",
|
||||
"common": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "libs/common",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"projectType": "library",
|
||||
"architect": {
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/helper/**/*.ts"]
|
||||
"lintFilePatterns": ["libs/common/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/helper"],
|
||||
"outputs": ["coverage/libs/common"],
|
||||
"options": {
|
||||
"jestConfig": "libs/helper/jest.config.js",
|
||||
"jestConfig": "libs/common/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "libs/ui",
|
||||
"sourceRoot": "libs/ui/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/ui"],
|
||||
"options": {
|
||||
"jestConfig": "libs/ui/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"port": 4400,
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputDir": "dist/storybook/ui",
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/ui-e2e",
|
||||
"sourceRoot": "apps/ui-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/ui-e2e/cypress.json",
|
||||
"devServerTarget": "ui:storybook",
|
||||
"tsConfig": "apps/ui-e2e/tsconfig.json"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"devServerTarget": "ui:storybook:ci"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["ui"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
module.exports = {
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'api',
|
||||
preset: '../../jest.preset.js',
|
||||
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
@ -11,5 +12,7 @@ module.exports = {
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
testTimeout: 10000
|
||||
testTimeout: 10000,
|
||||
testEnvironment: 'node',
|
||||
preset: '../../jest.preset.js'
|
||||
};
|
@ -1,10 +1,25 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Access as AccessModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccessModule } from './access.module';
|
||||
import { AccessService } from './access.service';
|
||||
import { Access } from './interfaces/access.interface';
|
||||
import { CreateAccessDto } from './create-access.dto';
|
||||
|
||||
@Controller('access')
|
||||
export class AccessController {
|
||||
@ -20,13 +35,70 @@ export class AccessController {
|
||||
include: {
|
||||
GranteeUser: true
|
||||
},
|
||||
orderBy: { granteeUserId: 'asc' },
|
||||
where: { userId: this.request.user.id }
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map((access) => {
|
||||
if (access.GranteeUser) {
|
||||
return {
|
||||
alias: access.alias,
|
||||
grantee: access.GranteeUser?.id,
|
||||
id: access.id,
|
||||
type: 'RESTRICTED_VIEW'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
granteeAlias: access.GranteeUser.alias
|
||||
alias: access.alias,
|
||||
grantee: 'Public',
|
||||
id: access.id,
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createAccess(
|
||||
@Body() data: CreateAccessDto
|
||||
): Promise<AccessModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||
const access = await this.accessService.access({ id });
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
||||
!access ||
|
||||
access.userId !== this.request.user.id
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accessService.deleteAccess({
|
||||
id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessController } from './access.controller';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AccessController],
|
||||
providers: [AccessService, PrismaService]
|
||||
exports: [AccessService],
|
||||
imports: [PrismaModule],
|
||||
providers: [AccessService]
|
||||
})
|
||||
export class AccessModule {}
|
||||
|
@ -1,12 +1,22 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type';
|
||||
import { Access, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async access(
|
||||
accessWhereInput: Prisma.AccessWhereInput
|
||||
): Promise<AccessWithGranteeUser | null> {
|
||||
return this.prismaService.access.findFirst({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
},
|
||||
where: accessWhereInput
|
||||
});
|
||||
}
|
||||
|
||||
public async accesses(params: {
|
||||
include?: Prisma.AccessInclude;
|
||||
@ -14,11 +24,11 @@ export class AccessService {
|
||||
take?: number;
|
||||
cursor?: Prisma.AccessWhereUniqueInput;
|
||||
where?: Prisma.AccessWhereInput;
|
||||
orderBy?: Prisma.AccessOrderByInput;
|
||||
orderBy?: Prisma.AccessOrderByWithRelationInput;
|
||||
}): Promise<AccessWithGranteeUser[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prisma.access.findMany({
|
||||
return this.prismaService.access.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
@ -27,4 +37,18 @@ export class AccessService {
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
|
||||
return this.prismaService.access.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAccess(
|
||||
where: Prisma.AccessWhereUniqueInput
|
||||
): Promise<Access> {
|
||||
return this.prismaService.access.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
||||
|
11
apps/api/src/app/access/create-access.dto.ts
Normal file
11
apps/api/src/app/access/create-access.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateAccessDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
alias?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
granteeUserId?: string;
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export interface Access {
|
||||
granteeAlias: string;
|
||||
}
|
281
apps/api/src/app/account/account.controller.ts
Normal file
281
apps/api/src/app/account/account.controller.ts
Normal file
@ -0,0 +1,281 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
nullifyValuesInObject,
|
||||
nullifyValuesInObjects
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
AccountWithValue,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Account as AccountModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountService } from './account.service';
|
||||
import { CreateAccountDto } from './create-account.dto';
|
||||
import { UpdateAccountDto } from './update-account.dto';
|
||||
|
||||
@Controller('account')
|
||||
export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const account = await this.accountService.accountWithOrders(
|
||||
{
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
},
|
||||
{ Order: true }
|
||||
);
|
||||
|
||||
if (account?.isDefault || account?.Order.length > 0) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountService.deleteAccount(
|
||||
{
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<Accounts> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
accountsWithAggregations = {
|
||||
...nullifyValuesInObject(accountsWithAggregations, [
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency'
|
||||
]),
|
||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||
'balance',
|
||||
'balanceInBaseCurrency',
|
||||
'convertedBalance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
return accountsWithAggregations;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getAccountById(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Param('id') id: string
|
||||
): Promise<AccountWithValue> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
filters: [{ id, type: 'ACCOUNT' }],
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
accountsWithAggregations = {
|
||||
...nullifyValuesInObject(accountsWithAggregations, [
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency'
|
||||
]),
|
||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||
'balance',
|
||||
'balanceInBaseCurrency',
|
||||
'convertedBalance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
return accountsWithAggregations.accounts[0];
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createAccount(
|
||||
@Body() data: CreateAccountDto
|
||||
): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
} else {
|
||||
delete data.platformId;
|
||||
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalAccount = await this.accountService.account({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!originalAccount) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
|
||||
return this.accountService.updateAccount(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
} else {
|
||||
// platformId is null, remove it
|
||||
delete data.platformId;
|
||||
|
||||
return this.accountService.updateAccount(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: originalAccount.platformId
|
||||
? { disconnect: true }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
29
apps/api/src/app/account/account.module.ts
Normal file
29
apps/api/src/app/account/account.module.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountController],
|
||||
exports: [AccountService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [AccountService]
|
||||
})
|
||||
export class AccountModule {}
|
175
apps/api/src/app/account/account.service.ts
Normal file
175
apps/api/src/app/account/account.service.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
public constructor(
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async account(
|
||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
||||
): Promise<Account | null> {
|
||||
return this.prismaService.account.findUnique({
|
||||
where: accountWhereUniqueInput
|
||||
});
|
||||
}
|
||||
|
||||
public async accountWithOrders(
|
||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
|
||||
accountInclude: Prisma.AccountInclude
|
||||
): Promise<
|
||||
Account & {
|
||||
Order?: Order[];
|
||||
}
|
||||
> {
|
||||
return this.prismaService.account.findUnique({
|
||||
include: accountInclude,
|
||||
where: accountWhereUniqueInput
|
||||
});
|
||||
}
|
||||
|
||||
public async accounts(params: {
|
||||
include?: Prisma.AccountInclude;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.AccountWhereUniqueInput;
|
||||
where?: Prisma.AccountWhereInput;
|
||||
orderBy?: Prisma.AccountOrderByWithRelationInput;
|
||||
}): Promise<
|
||||
(Account & {
|
||||
Order?: Order[];
|
||||
Platform?: Platform;
|
||||
})[]
|
||||
> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.account.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccount(
|
||||
data: Prisma.AccountCreateInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
return this.prismaService.account.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAccount(
|
||||
where: Prisma.AccountWhereUniqueInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
return this.prismaService.account.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccounts(aUserId: string) {
|
||||
const accounts = await this.accounts({
|
||||
include: { Order: true, Platform: true },
|
||||
orderBy: { name: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
});
|
||||
|
||||
return accounts.map((account) => {
|
||||
let transactionCount = 0;
|
||||
|
||||
for (const order of account.Order) {
|
||||
if (!order.isDraft) {
|
||||
transactionCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result = { ...account, transactionCount };
|
||||
|
||||
delete result.Order;
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public async getCashDetails({
|
||||
currency,
|
||||
filters = [],
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
currency: string;
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<CashDetails> {
|
||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||
|
||||
const where: Prisma.AccountWhereInput = {
|
||||
userId
|
||||
};
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.isExcluded = false;
|
||||
}
|
||||
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
});
|
||||
|
||||
if (filtersByAccount?.length > 0) {
|
||||
where.id = {
|
||||
in: filtersByAccount.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const accounts = await this.accounts({ where });
|
||||
|
||||
for (const account of accounts) {
|
||||
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
currency
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
|
||||
};
|
||||
}
|
||||
|
||||
public async updateAccount(
|
||||
params: {
|
||||
where: Prisma.AccountWhereUniqueInput;
|
||||
data: Prisma.AccountUpdateInput;
|
||||
},
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
const { data, where } = params;
|
||||
return this.prismaService.account.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
30
apps/api/src/app/account/create-account.dto.ts
Normal file
30
apps/api/src/app/account/create-account.dto.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { Account } from '@prisma/client';
|
||||
|
||||
export interface CashDetails {
|
||||
accounts: Account[];
|
||||
balanceInBaseCurrency: number;
|
||||
}
|
33
apps/api/src/app/account/update-account.dto.ts
Normal file
33
apps/api/src/app/account/update-account.dto.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
}
|
@ -1,26 +1,46 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { isDate } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminData } from './interfaces/admin-data.interface';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
public constructor(
|
||||
private readonly adminService: AdminService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@ -29,7 +49,7 @@ export class AdminController {
|
||||
public async getAdminData(): Promise<AdminData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -42,12 +62,12 @@ export class AdminController {
|
||||
return this.adminService.get();
|
||||
}
|
||||
|
||||
@Post('gather/max')
|
||||
@Post('gather')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherMax(): Promise<void> {
|
||||
public async gather7Days(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
@ -57,8 +77,279 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gather7Days();
|
||||
}
|
||||
|
||||
@Post('gather/max')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherMax(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherMax();
|
||||
}
|
||||
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('gather/profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherProfileDataForSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherSymbolForDate(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<MarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
|
||||
if (!isDate(date)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return this.dataGatheringService.gatherSymbolForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketData(
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||
): Promise<AdminMarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||
|
||||
const filters: Filter[] = [
|
||||
...assetSubClasses.map((assetSubClass) => {
|
||||
return <Filter>{
|
||||
id: assetSubClass,
|
||||
type: 'ASSET_SUB_CLASS'
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
return this.adminService.getMarketData(filters);
|
||||
}
|
||||
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async update(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string,
|
||||
@Body() data: UpdateMarketDataDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
data: { ...data, dataSource },
|
||||
where: {
|
||||
date_symbol: {
|
||||
date,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateProperty(
|
||||
@Param('key') key: string,
|
||||
@Body() data: PropertyDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return await this.adminService.putSetting(key, data.value);
|
||||
}
|
||||
}
|
||||
|
@ -1,31 +1,33 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { QueueModule } from './queue/queue.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
QueueModule,
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
AdminService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [AdminService],
|
||||
exports: [AdminService]
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
@ -1,95 +1,201 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { AdminData } from './interfaces/admin-data.interface';
|
||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private exchangeRateDataService: ExchangeRateDataService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
}
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
return {
|
||||
exchangeRates: [
|
||||
{
|
||||
label1: Currency.EUR,
|
||||
label2: Currency.CHF,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.EUR,
|
||||
Currency.CHF
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.GBP,
|
||||
label2: Currency.CHF,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.GBP,
|
||||
Currency.CHF
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.USD,
|
||||
label2: Currency.CHF,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.USD,
|
||||
Currency.CHF
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.USD,
|
||||
label2: Currency.EUR,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.USD,
|
||||
Currency.EUR
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.USD,
|
||||
label2: Currency.GBP,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.USD,
|
||||
Currency.GBP
|
||||
)
|
||||
}
|
||||
],
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
transactionCount: await this.prisma.order.count(),
|
||||
userCount: await this.prisma.user.count(),
|
||||
exchangeRates: this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
return currency !== this.baseCurrency;
|
||||
})
|
||||
.map((currency) => {
|
||||
return {
|
||||
label1: this.baseCurrency,
|
||||
label2: currency,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
this.baseCurrency,
|
||||
currency
|
||||
)
|
||||
};
|
||||
}),
|
||||
settings: await this.propertyService.get(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
users: await this.getUsersWithAnalytics()
|
||||
};
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prisma.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
||||
const where: Prisma.SymbolProfileWhereInput = {};
|
||||
|
||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||
filters,
|
||||
(filter) => {
|
||||
return filter.type;
|
||||
}
|
||||
);
|
||||
|
||||
const marketData = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
|
||||
if (lastDataGathering?.value) {
|
||||
return new Date(lastDataGathering.value);
|
||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||
|
||||
if (filtersByAssetSubClass) {
|
||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||
} else {
|
||||
currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
countriesCount: 0,
|
||||
sectorsCount: 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const dataGatheringInProgress = await this.prisma.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
where,
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
countries: true,
|
||||
dataSource: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
const countriesCount = symbolProfile.countries
|
||||
? Object.keys(symbolProfile.countries).length
|
||||
: 0;
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||
marketDataItem.symbol === symbolProfile.symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = symbolProfile.sectors
|
||||
? Object.keys(symbolProfile.sectors).length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
countriesCount,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activityCount: symbolProfile._count.Order,
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
};
|
||||
});
|
||||
|
||||
if (dataGatheringInProgress) {
|
||||
return 'IN_PROGRESS';
|
||||
}
|
||||
|
||||
return null;
|
||||
return {
|
||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||
};
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics() {
|
||||
return await this.prisma.user.findMany({
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||
return {
|
||||
marketData: await this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
where: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
public async putSetting(key: string, value: string) {
|
||||
let response: Property;
|
||||
|
||||
if (value) {
|
||||
response = await this.propertyService.put({ key, value });
|
||||
} else {
|
||||
response = await this.propertyService.delete({ key });
|
||||
}
|
||||
|
||||
if (key === PROPERTY_CURRENCIES) {
|
||||
await this.exchangeRateDataService.initialize();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy: {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
@ -99,7 +205,6 @@ export class AdminService {
|
||||
_count: {
|
||||
select: { Account: true, Order: true }
|
||||
},
|
||||
alias: true,
|
||||
Analytics: {
|
||||
select: {
|
||||
activityCount: true,
|
||||
@ -107,14 +212,39 @@ export class AdminService {
|
||||
}
|
||||
},
|
||||
createdAt: true,
|
||||
id: true
|
||||
id: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 20,
|
||||
take: 30,
|
||||
where: {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.subscriptionService.getSubscription(Subscription)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
engagement,
|
||||
id,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
lastActivity: Analytics.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { JobStatus } from 'bull';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { QueueService } from './queue.service';
|
||||
|
||||
@Controller('admin/queue')
|
||||
export class QueueController {
|
||||
public constructor(
|
||||
private readonly queueService: QueueService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete('job')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
return this.queueService.deleteJobs({ status });
|
||||
}
|
||||
|
||||
@Get('job')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<AdminJobs> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
return this.queueService.getJobs({ status });
|
||||
}
|
||||
|
||||
@Delete('job/:id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.queueService.deleteJob(id);
|
||||
}
|
||||
}
|
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { QueueController } from './queue.controller';
|
||||
import { QueueService } from './queue.service';
|
||||
|
||||
@Module({
|
||||
controllers: [QueueController],
|
||||
imports: [DataGatheringModule],
|
||||
providers: [QueueService]
|
||||
})
|
||||
export class QueueModule {}
|
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import {
|
||||
DATA_GATHERING_QUEUE,
|
||||
QUEUE_JOB_STATUS_LIST
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JobStatus, Queue } from 'bull';
|
||||
|
||||
@Injectable()
|
||||
export class QueueService {
|
||||
public constructor(
|
||||
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||
private readonly dataGatheringQueue: Queue
|
||||
) {}
|
||||
|
||||
public async deleteJob(aId: string) {
|
||||
return (await this.dataGatheringQueue.getJob(aId))?.remove();
|
||||
}
|
||||
|
||||
public async deleteJobs({
|
||||
status = QUEUE_JOB_STATUS_LIST
|
||||
}: {
|
||||
status?: JobStatus[];
|
||||
}) {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
Logger.warn(error, 'QueueService');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getJobs({
|
||||
limit = 1000,
|
||||
status = QUEUE_JOB_STATUS_LIST
|
||||
}: {
|
||||
limit?: number;
|
||||
status?: JobStatus[];
|
||||
}): Promise<AdminJobs> {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
|
||||
const jobsWithState = await Promise.all(
|
||||
jobs.slice(0, limit).map(async (job) => {
|
||||
return {
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
data: job.data,
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
jobs: jobsWithState
|
||||
};
|
||||
}
|
||||
}
|
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsNumber } from 'class-validator';
|
||||
|
||||
export class UpdateMarketDataDto {
|
||||
@IsNumber()
|
||||
marketPrice: number;
|
||||
}
|
@ -1,31 +1,17 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../services/prisma.service';
|
||||
import { RedisCacheService } from './redis-cache/redis-cache.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
public constructor(
|
||||
private prisma: PrismaService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||
) {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private async initialize() {
|
||||
this.redisCacheService.reset();
|
||||
|
||||
const isDataGatheringLocked = await this.prisma.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
// Prepare for automatical data gather if not locked
|
||||
await this.prisma.property.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.exchangeRateDataService.initialize();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
@ -1,30 +1,34 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
|
||||
import { ConfigurationService } from '../services/configuration.service';
|
||||
import { CronService } from '../services/cron.service';
|
||||
import { DataGatheringService } from '../services/data-gathering.service';
|
||||
import { DataProviderService } from '../services/data-provider.service';
|
||||
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { PrismaService } from '../services/prisma.service';
|
||||
import { AccessModule } from './access/access.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { ExperimentalModule } from './experimental/experimental.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { FrontendMiddleware } from './frontend.middleware';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@ -32,13 +36,29 @@ import { UserModule } from './user/user.module';
|
||||
imports: [
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
AccountModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
BenchmarkModule,
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT, 10),
|
||||
password: process.env.REDIS_PASSWORD
|
||||
}
|
||||
}),
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
ExperimentalModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
@ -55,21 +75,18 @@ import { UserModule } from './user/user.module';
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
exclude: ['/api*']
|
||||
}),
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
CronService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [CronService]
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(FrontendMiddleware)
|
||||
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
||||
}
|
||||
}
|
||||
|
40
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
40
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('auth-device')
|
||||
export class AuthDeviceController {
|
||||
public constructor(
|
||||
private readonly authDeviceService: AuthDeviceService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.deleteAuthDevice
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
await this.authDeviceService.deleteAuthDevice({ id });
|
||||
}
|
||||
}
|
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface AuthDeviceDto {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
}
|
20
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
20
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthDeviceController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
}),
|
||||
PrismaModule
|
||||
],
|
||||
providers: [AuthDeviceService]
|
||||
})
|
||||
export class AuthDeviceModule {}
|
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthDevice, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthDeviceService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async authDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
): Promise<AuthDevice | null> {
|
||||
return this.prismaService.authDevice.findUnique({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async authDevices(params: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.AuthDeviceWhereUniqueInput;
|
||||
where?: Prisma.AuthDeviceWhereInput;
|
||||
orderBy?: Prisma.AuthDeviceOrderByWithRelationInput;
|
||||
}): Promise<AuthDevice[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prismaService.authDevice.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy
|
||||
});
|
||||
}
|
||||
|
||||
public async createAuthDevice(
|
||||
data: Prisma.AuthDeviceCreateInput
|
||||
): Promise<AuthDevice> {
|
||||
return this.prismaService.authDevice.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async updateAuthDevice(params: {
|
||||
data: Prisma.AuthDeviceUpdateInput;
|
||||
where: Prisma.AuthDeviceWhereUniqueInput;
|
||||
}): Promise<AuthDevice> {
|
||||
const { data, where } = params;
|
||||
|
||||
return this.prismaService.authDevice.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAuthDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
): Promise<AuthDevice> {
|
||||
return this.prismaService.authDevice.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
@ -1,27 +1,41 @@
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
VERSION_NEUTRAL,
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
AttestationCredentialJSON
|
||||
} from './interfaces/simplewebauthn';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
public constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly webAuthService: WebAuthService
|
||||
) {}
|
||||
|
||||
@Get('anonymous/:accessToken')
|
||||
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
|
||||
public async accessTokenLogin(
|
||||
@Param('accessToken') accessToken: string
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateAnonymousLogin(
|
||||
accessToken
|
||||
@ -43,14 +57,80 @@ export class AuthController {
|
||||
|
||||
@Get('google/callback')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public googleLoginCallback(@Req() req, @Res() res) {
|
||||
// Handles the Google OAuth2 callback
|
||||
const jwt: string = req.user.jwt;
|
||||
|
||||
if (jwt) {
|
||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
|
||||
res.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||
);
|
||||
} else {
|
||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
||||
res.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('internet-identity/:principalId')
|
||||
public async internetIdentityLogin(
|
||||
@Param('principalId') principalId: string
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||
principalId
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('webauthn/generate-registration-options')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async generateRegistrationOptions() {
|
||||
return this.webAuthService.generateRegistrationOptions();
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-attestation')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async verifyAttestation(
|
||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||
) {
|
||||
return this.webAuthService.verifyAttestation(
|
||||
body.deviceName,
|
||||
body.credential
|
||||
);
|
||||
}
|
||||
|
||||
@Post('webauthn/generate-assertion-options')
|
||||
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
|
||||
return this.webAuthService.generateAssertionOptions(body.deviceId);
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-assertion')
|
||||
public async verifyAssertion(
|
||||
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
|
||||
) {
|
||||
try {
|
||||
const authToken = await this.webAuthService.verifyAssertion(
|
||||
body.deviceId,
|
||||
body.credential
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
@ -12,18 +15,21 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
})
|
||||
}),
|
||||
PrismaModule,
|
||||
SubscriptionModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AuthDeviceService,
|
||||
AuthService,
|
||||
ConfigurationService,
|
||||
GoogleStrategy,
|
||||
JwtStrategy,
|
||||
PrismaService,
|
||||
UserService
|
||||
WebAuthService
|
||||
]
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||
|
||||
@Injectable()
|
||||
@ -13,7 +14,7 @@ export class AuthService {
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
public async validateAnonymousLogin(accessToken: string) {
|
||||
public async validateAnonymousLogin(accessToken: string): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const hashedAccessToken = this.userService.createAccessToken(
|
||||
@ -26,7 +27,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const jwt: string = this.jwtService.sign({
|
||||
const jwt = this.jwtService.sign({
|
||||
id: user.id
|
||||
});
|
||||
|
||||
@ -40,6 +41,33 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
public async validateInternetIdentityLogin(principalId: string) {
|
||||
try {
|
||||
const provider: Provider = 'INTERNET_IDENTITY';
|
||||
|
||||
let [user] = await this.userService.users({
|
||||
where: { provider, thirdPartyId: principalId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
thirdPartyId: principalId
|
||||
});
|
||||
}
|
||||
|
||||
return this.jwtService.sign({
|
||||
id: user.id
|
||||
});
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
'validateInternetIdentityLogin',
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async validateOAuthLogin({
|
||||
provider,
|
||||
thirdPartyId
|
||||
@ -57,13 +85,14 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
const jwt: string = this.jwtService.sign({
|
||||
return this.jwtService.sign({
|
||||
id: user.id
|
||||
});
|
||||
|
||||
return jwt;
|
||||
} catch (err) {
|
||||
throw new InternalServerErrorException('validateOAuthLogin', err.message);
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
'validateOAuthLogin',
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { Strategy } from 'passport-google-oauth20';
|
||||
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
done(err, false);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GoogleStrategy');
|
||||
done(error, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,10 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
||||
export interface AuthDeviceDialogParams {
|
||||
authDevice: AuthDeviceDto;
|
||||
}
|
||||
|
||||
export interface ValidateOAuthLoginParams {
|
||||
provider: Provider;
|
||||
thirdPartyId: string;
|
||||
|
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
@ -0,0 +1,226 @@
|
||||
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
|
||||
readonly authenticatorData: ArrayBuffer;
|
||||
readonly signature: ArrayBuffer;
|
||||
readonly userHandle: ArrayBuffer | null;
|
||||
}
|
||||
export interface AuthenticatorAttestationResponse
|
||||
extends AuthenticatorResponse {
|
||||
readonly attestationObject: ArrayBuffer;
|
||||
}
|
||||
export interface AuthenticationExtensionsClientInputs {
|
||||
appid?: string;
|
||||
appidExclude?: string;
|
||||
credProps?: boolean;
|
||||
uvm?: boolean;
|
||||
}
|
||||
export interface AuthenticationExtensionsClientOutputs {
|
||||
appid?: boolean;
|
||||
credProps?: CredentialPropertiesOutput;
|
||||
uvm?: UvmEntries;
|
||||
}
|
||||
export interface AuthenticatorSelectionCriteria {
|
||||
authenticatorAttachment?: AuthenticatorAttachment;
|
||||
requireResidentKey?: boolean;
|
||||
residentKey?: ResidentKeyRequirement;
|
||||
userVerification?: UserVerificationRequirement;
|
||||
}
|
||||
export interface PublicKeyCredential extends Credential {
|
||||
readonly rawId: ArrayBuffer;
|
||||
readonly response: AuthenticatorResponse;
|
||||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
|
||||
}
|
||||
export interface PublicKeyCredentialCreationOptions {
|
||||
attestation?: AttestationConveyancePreference;
|
||||
authenticatorSelection?: AuthenticatorSelectionCriteria;
|
||||
challenge: BufferSource;
|
||||
excludeCredentials?: PublicKeyCredentialDescriptor[];
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
pubKeyCredParams: PublicKeyCredentialParameters[];
|
||||
rp: PublicKeyCredentialRpEntity;
|
||||
timeout?: number;
|
||||
user: PublicKeyCredentialUserEntity;
|
||||
}
|
||||
export interface PublicKeyCredentialDescriptor {
|
||||
id: BufferSource;
|
||||
transports?: AuthenticatorTransport[];
|
||||
type: PublicKeyCredentialType;
|
||||
}
|
||||
export interface PublicKeyCredentialParameters {
|
||||
alg: COSEAlgorithmIdentifier;
|
||||
type: PublicKeyCredentialType;
|
||||
}
|
||||
export interface PublicKeyCredentialRequestOptions {
|
||||
allowCredentials?: PublicKeyCredentialDescriptor[];
|
||||
challenge: BufferSource;
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
rpId?: string;
|
||||
timeout?: number;
|
||||
userVerification?: UserVerificationRequirement;
|
||||
}
|
||||
export interface PublicKeyCredentialUserEntity
|
||||
extends PublicKeyCredentialEntity {
|
||||
displayName: string;
|
||||
id: BufferSource;
|
||||
}
|
||||
export interface AuthenticatorResponse {
|
||||
readonly clientDataJSON: ArrayBuffer;
|
||||
}
|
||||
export interface CredentialPropertiesOutput {
|
||||
rk?: boolean;
|
||||
}
|
||||
export interface Credential {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
}
|
||||
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
|
||||
id?: string;
|
||||
}
|
||||
export interface PublicKeyCredentialEntity {
|
||||
name: string;
|
||||
}
|
||||
export declare type AttestationConveyancePreference =
|
||||
| 'direct'
|
||||
| 'enterprise'
|
||||
| 'indirect'
|
||||
| 'none';
|
||||
export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb';
|
||||
export declare type COSEAlgorithmIdentifier = number;
|
||||
export declare type UserVerificationRequirement =
|
||||
| 'discouraged'
|
||||
| 'preferred'
|
||||
| 'required';
|
||||
export declare type UvmEntries = UvmEntry[];
|
||||
export declare type AuthenticatorAttachment = 'cross-platform' | 'platform';
|
||||
export declare type ResidentKeyRequirement =
|
||||
| 'discouraged'
|
||||
| 'preferred'
|
||||
| 'required';
|
||||
export declare type BufferSource = ArrayBufferView | ArrayBuffer;
|
||||
export declare type PublicKeyCredentialType = 'public-key';
|
||||
export declare type UvmEntry = number[];
|
||||
|
||||
export interface PublicKeyCredentialCreationOptionsJSON
|
||||
extends Omit<
|
||||
PublicKeyCredentialCreationOptions,
|
||||
'challenge' | 'user' | 'excludeCredentials'
|
||||
> {
|
||||
user: PublicKeyCredentialUserEntityJSON;
|
||||
challenge: Base64URLString;
|
||||
excludeCredentials: PublicKeyCredentialDescriptorJSON[];
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
}
|
||||
/**
|
||||
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
|
||||
* (eventually) get passed into navigator.credentials.get(...) in the browser.
|
||||
*/
|
||||
export interface PublicKeyCredentialRequestOptionsJSON
|
||||
extends Omit<
|
||||
PublicKeyCredentialRequestOptions,
|
||||
'challenge' | 'allowCredentials'
|
||||
> {
|
||||
challenge: Base64URLString;
|
||||
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
}
|
||||
export interface PublicKeyCredentialDescriptorJSON
|
||||
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
|
||||
id: Base64URLString;
|
||||
}
|
||||
export interface PublicKeyCredentialUserEntityJSON
|
||||
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
|
||||
id: string;
|
||||
}
|
||||
/**
|
||||
* The value returned from navigator.credentials.create()
|
||||
*/
|
||||
export interface AttestationCredential extends PublicKeyCredential {
|
||||
response: AuthenticatorAttestationResponseFuture;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AttestationCredentialJSON
|
||||
extends Omit<
|
||||
AttestationCredential,
|
||||
'response' | 'rawId' | 'getClientExtensionResults'
|
||||
> {
|
||||
rawId: Base64URLString;
|
||||
response: AuthenticatorAttestationResponseJSON;
|
||||
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||
transports?: AuthenticatorTransport[];
|
||||
}
|
||||
/**
|
||||
* The value returned from navigator.credentials.get()
|
||||
*/
|
||||
export interface AssertionCredential extends PublicKeyCredential {
|
||||
response: AuthenticatorAssertionResponse;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AssertionCredentialJSON
|
||||
extends Omit<
|
||||
AssertionCredential,
|
||||
'response' | 'rawId' | 'getClientExtensionResults'
|
||||
> {
|
||||
rawId: Base64URLString;
|
||||
response: AuthenticatorAssertionResponseJSON;
|
||||
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AuthenticatorAttestationResponseJSON
|
||||
extends Omit<
|
||||
AuthenticatorAttestationResponseFuture,
|
||||
'clientDataJSON' | 'attestationObject'
|
||||
> {
|
||||
clientDataJSON: Base64URLString;
|
||||
attestationObject: Base64URLString;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AuthenticatorAssertionResponseJSON
|
||||
extends Omit<
|
||||
AuthenticatorAssertionResponse,
|
||||
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
|
||||
> {
|
||||
authenticatorData: Base64URLString;
|
||||
clientDataJSON: Base64URLString;
|
||||
signature: Base64URLString;
|
||||
userHandle?: string;
|
||||
}
|
||||
/**
|
||||
* A WebAuthn-compatible device and the information needed to verify assertions by it
|
||||
*/
|
||||
export declare type AuthenticatorDevice = {
|
||||
credentialPublicKey: Buffer;
|
||||
credentialID: Buffer;
|
||||
counter: number;
|
||||
transports?: AuthenticatorTransport[];
|
||||
};
|
||||
/**
|
||||
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
|
||||
*/
|
||||
export declare type Base64URLString = string;
|
||||
/**
|
||||
* AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7).
|
||||
* Maintain an augmented version here so we can implement additional properties as the WebAuthn
|
||||
* spec evolves.
|
||||
*
|
||||
* See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse
|
||||
*
|
||||
* Properties marked optional are not supported in all browsers.
|
||||
*/
|
||||
export interface AuthenticatorAttestationResponseFuture
|
||||
extends AuthenticatorAttestationResponse {
|
||||
getTransports?: () => AuthenticatorTransport[];
|
||||
getAuthenticatorData?: () => ArrayBuffer;
|
||||
getPublicKey?: () => ArrayBuffer;
|
||||
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
|
||||
}
|
@ -1,16 +1,15 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
public constructor(
|
||||
readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
super({
|
||||
@ -24,7 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
const user = await this.userService.user({ id });
|
||||
|
||||
if (user) {
|
||||
await this.prisma.analytics.upsert({
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { User: { connect: { id: user.id } } },
|
||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
||||
where: { userId: user.id }
|
||||
|
217
apps/api/src/app/auth/web-auth.service.ts
Normal file
217
apps/api/src/app/auth/web-auth.service.ts
Normal file
@ -0,0 +1,217 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
GenerateRegistrationOptionsOpts,
|
||||
VerifiedAuthenticationResponse,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
AttestationCredentialJSON
|
||||
} from './interfaces/simplewebauthn';
|
||||
|
||||
@Injectable()
|
||||
export class WebAuthService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly deviceService: AuthDeviceService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly userService: UserService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
get rpID() {
|
||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||
}
|
||||
|
||||
get expectedOrigin() {
|
||||
return this.configurationService.get('ROOT_URL');
|
||||
}
|
||||
|
||||
public async generateRegistrationOptions() {
|
||||
const user = this.request.user;
|
||||
|
||||
const opts: GenerateRegistrationOptionsOpts = {
|
||||
rpName: 'Ghostfolio',
|
||||
rpID: this.rpID,
|
||||
userID: user.id,
|
||||
userName: '',
|
||||
timeout: 60000,
|
||||
attestationType: 'indirect',
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: 'platform',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'required'
|
||||
}
|
||||
};
|
||||
|
||||
const options = generateRegistrationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
authChallenge: options.challenge
|
||||
},
|
||||
where: {
|
||||
id: user.id
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public async verifyAttestation(
|
||||
deviceName: string,
|
||||
credential: AttestationCredentialJSON
|
||||
): Promise<AuthDeviceDto> {
|
||||
const user = this.request.user;
|
||||
const expectedChallenge = user.authChallenge;
|
||||
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
const opts: VerifyRegistrationResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
};
|
||||
verification = await verifyRegistrationResponse(opts);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'WebAuthService');
|
||||
throw new InternalServerErrorException(error.message);
|
||||
}
|
||||
|
||||
const { registrationInfo, verified } = verification;
|
||||
|
||||
const devices = await this.deviceService.authDevices({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (registrationInfo && verified) {
|
||||
const { counter, credentialID, credentialPublicKey } = registrationInfo;
|
||||
|
||||
let existingDevice = devices.find(
|
||||
(device) => device.credentialId === credentialID
|
||||
);
|
||||
|
||||
if (!existingDevice) {
|
||||
/**
|
||||
* Add the returned device to the user's list of devices
|
||||
*/
|
||||
existingDevice = await this.deviceService.createAuthDevice({
|
||||
counter,
|
||||
credentialPublicKey,
|
||||
credentialId: credentialID,
|
||||
User: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
createdAt: existingDevice.createdAt.toISOString(),
|
||||
id: existingDevice.id
|
||||
};
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException('An unknown error occurred');
|
||||
}
|
||||
|
||||
public async generateAssertionOptions(deviceId: string) {
|
||||
const device = await this.deviceService.authDevice({ id: deviceId });
|
||||
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const opts: GenerateAuthenticationOptionsOpts = {
|
||||
allowCredentials: [
|
||||
{
|
||||
id: device.credentialId,
|
||||
transports: ['internal'],
|
||||
type: 'public-key'
|
||||
}
|
||||
],
|
||||
rpID: this.rpID,
|
||||
timeout: 60000,
|
||||
userVerification: 'preferred'
|
||||
};
|
||||
|
||||
const options = generateAuthenticationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
authChallenge: options.challenge
|
||||
},
|
||||
where: {
|
||||
id: device.userId
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public async verifyAssertion(
|
||||
deviceId: string,
|
||||
credential: AssertionCredentialJSON
|
||||
) {
|
||||
const device = await this.deviceService.authDevice({ id: deviceId });
|
||||
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const user = await this.userService.user({ id: device.userId });
|
||||
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
const opts: VerifyAuthenticationResponseOpts = {
|
||||
credential,
|
||||
authenticator: {
|
||||
credentialID: device.credentialId,
|
||||
credentialPublicKey: device.credentialPublicKey,
|
||||
counter: device.counter
|
||||
},
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
};
|
||||
verification = verifyAuthenticationResponse(opts);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'WebAuthService');
|
||||
throw new InternalServerErrorException({ error: error.message });
|
||||
}
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (verified) {
|
||||
device.counter = authenticationInfo.newCounter;
|
||||
|
||||
await this.deviceService.updateAuthDevice({
|
||||
data: device,
|
||||
where: { id: device.id }
|
||||
});
|
||||
|
||||
return this.jwtService.sign({
|
||||
id: user.id
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
}
|
48
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
48
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
|
||||
@Controller('benchmark')
|
||||
export class BenchmarkController {
|
||||
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||
return {
|
||||
benchmarks: await this.benchmarkService.getBenchmarks()
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:startDateString')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getBenchmarkMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('startDateString') startDateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<BenchmarkMarketDataDetails> {
|
||||
const startDate = new Date(startDateString);
|
||||
|
||||
return this.benchmarkService.getMarketDataBySymbol({
|
||||
dataSource,
|
||||
startDate,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
27
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
27
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BenchmarkController } from './benchmark.controller';
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BenchmarkController],
|
||||
exports: [BenchmarkService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [BenchmarkService]
|
||||
})
|
||||
export class BenchmarkModule {}
|
15
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal file
15
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
|
||||
describe('BenchmarkService', () => {
|
||||
let benchmarkService: BenchmarkService;
|
||||
|
||||
beforeAll(async () => {
|
||||
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
||||
});
|
||||
|
||||
it('calculateChangeInPercentage', async () => {
|
||||
expect(benchmarkService.calculateChangeInPercentage(1, 2)).toEqual(1);
|
||||
expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0);
|
||||
expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5);
|
||||
});
|
||||
});
|
210
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
210
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
MAX_CHART_ITEMS,
|
||||
PROPERTY_BENCHMARKS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
export class BenchmarkService {
|
||||
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
||||
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly symbolProfileService: SymbolProfileService,
|
||||
private readonly symbolService: SymbolService
|
||||
) {}
|
||||
|
||||
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
||||
if (baseValue && currentValue) {
|
||||
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||
BenchmarkResponse['benchmarks']
|
||||
> {
|
||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||
|
||||
if (useCache) {
|
||||
try {
|
||||
benchmarks = JSON.parse(
|
||||
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||
);
|
||||
|
||||
if (benchmarks) {
|
||||
return benchmarks;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||
|
||||
const promises: Promise<number>[] = [];
|
||||
|
||||
const quotes = await this.dataProviderService.getQuotes(
|
||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||
}
|
||||
|
||||
const allTimeHighs = await Promise.all(promises);
|
||||
let storeInCache = true;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } =
|
||||
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||
|
||||
let performancePercentFromAllTimeHigh = 0;
|
||||
|
||||
if (allTimeHigh && marketPrice) {
|
||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||
allTimeHigh,
|
||||
marketPrice
|
||||
);
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
),
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent: performancePercentFromAllTimeHigh
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (storeInCache) {
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
const symbolProfileIds: string[] = (
|
||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
||||
symbolProfileId: string;
|
||||
}[]) ?? []
|
||||
).map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
|
||||
const assetProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||
|
||||
return assetProfiles
|
||||
.map(({ dataSource, id, name, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
symbol
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
startDate,
|
||||
symbol
|
||||
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||
this.symbolService.get({
|
||||
dataGatheringItem: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}),
|
||||
this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
where: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: {
|
||||
gte: startDate
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const step = Math.round(
|
||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||
);
|
||||
|
||||
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||
const response = {
|
||||
marketData: [
|
||||
...marketDataItems
|
||||
.filter((marketDataItem, index) => {
|
||||
return index % step === 0;
|
||||
})
|
||||
.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice
|
||||
) * 100
|
||||
};
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
if (currentSymbolItem?.marketPrice) {
|
||||
response.marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
currentSymbolItem.marketPrice
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: number) {
|
||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
}
|
||||
}
|
35
apps/api/src/app/cache/cache.controller.ts
vendored
35
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,26 +1,39 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
import { CacheService } from './cache.service';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('cache')
|
||||
export class CacheController {
|
||||
public constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {
|
||||
this.redisCacheService.reset();
|
||||
}
|
||||
) {}
|
||||
|
||||
@Post('flush')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async flushCache(): Promise<void> {
|
||||
this.redisCacheService.reset();
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.cacheService.flush(this.request.user.id);
|
||||
return this.redisCacheService.reset();
|
||||
}
|
||||
}
|
||||
|
21
apps/api/src/app/cache/cache.module.ts
vendored
21
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,13 +1,24 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { CacheController } from './cache.controller';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
controllers: [CacheController],
|
||||
providers: [CacheService, PrismaService]
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
]
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
17
apps/api/src/app/cache/cache.service.ts
vendored
17
apps/api/src/app/cache/cache.service.ts
vendored
@ -1,17 +0,0 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
|
||||
public async flush(aUserId: string): Promise<void> {
|
||||
await this.prisma.property.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
import { Currency, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsString()
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
unitPrice: number;
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import {
|
||||
baseCurrency,
|
||||
benchmarks,
|
||||
isApiTokenAuthorized
|
||||
} from '@ghostfolio/helper';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { parse } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { ExperimentalService } from './experimental.service';
|
||||
import { Data } from './interfaces/data.interface';
|
||||
|
||||
@Controller('experimental')
|
||||
export class ExperimentalController {
|
||||
public constructor(
|
||||
private readonly experimentalService: ExperimentalService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get('benchmarks')
|
||||
public async getBenchmarks(
|
||||
@Headers('Authorization') apiToken: string
|
||||
): Promise<string[]> {
|
||||
if (!isApiTokenAuthorized(apiToken)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
@Get('benchmarks/:symbol')
|
||||
public async getBenchmark(
|
||||
@Headers('Authorization') apiToken: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<{ date: Date; marketPrice: number }[]> {
|
||||
if (!isApiTokenAuthorized(apiToken)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const marketData = await this.experimentalService.getBenchmark(symbol);
|
||||
|
||||
if (marketData?.length === 0) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return marketData;
|
||||
}
|
||||
|
||||
@Post('value/:dateString?')
|
||||
public async getValue(
|
||||
@Body() orders: CreateOrderDto[],
|
||||
@Headers('Authorization') apiToken: string,
|
||||
@Param('dateString') dateString: string
|
||||
): Promise<Data> {
|
||||
if (!isApiTokenAuthorized(apiToken)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let date = new Date();
|
||||
|
||||
if (dateString) {
|
||||
date = parse(dateString, 'yyyy-MM-dd', new Date());
|
||||
}
|
||||
|
||||
return this.experimentalService.getValue(orders, date, baseCurrency);
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExperimentalController } from './experimental.controller';
|
||||
import { ExperimentalService } from './experimental.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [ExperimentalController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
ExperimentalService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
RulesService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class ExperimentalModule {}
|
@ -1,64 +0,0 @@
|
||||
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, Type } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
import { OrderWithPlatform } from '../order/interfaces/order-with-platform.type';
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Data } from './interfaces/data.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ExperimentalService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private prisma: PrismaService,
|
||||
private readonly rulesService: RulesService
|
||||
) {}
|
||||
|
||||
public async getBenchmark(aSymbol: string) {
|
||||
return this.prisma.marketData.findMany({
|
||||
orderBy: { date: 'asc' },
|
||||
select: { date: true, marketPrice: true },
|
||||
where: { symbol: aSymbol }
|
||||
});
|
||||
}
|
||||
|
||||
public async getValue(
|
||||
aOrders: CreateOrderDto[],
|
||||
aDate: Date,
|
||||
aBaseCurrency: Currency
|
||||
): Promise<Data> {
|
||||
const ordersWithPlatform: OrderWithPlatform[] = aOrders.map((order) => {
|
||||
return {
|
||||
...order,
|
||||
accountId: undefined,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
date: parseISO(order.date),
|
||||
fee: 0,
|
||||
id: undefined,
|
||||
platformId: undefined,
|
||||
type: Type.BUY,
|
||||
updatedAt: undefined,
|
||||
userId: undefined
|
||||
};
|
||||
});
|
||||
|
||||
const portfolio = new Portfolio(
|
||||
this.dataProviderService,
|
||||
this.exchangeRateDataService,
|
||||
this.rulesService
|
||||
);
|
||||
await portfolio.setOrders(ordersWithPlatform);
|
||||
|
||||
return {
|
||||
currency: aBaseCurrency,
|
||||
value: portfolio.getValue(aDate)
|
||||
};
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface Data {
|
||||
currency: Currency;
|
||||
value: number;
|
||||
}
|
26
apps/api/src/app/export/export.controller.ts
Normal file
26
apps/api/src/app/export/export.controller.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Controller('export')
|
||||
export class ExportController {
|
||||
public constructor(
|
||||
private readonly exportService: ExportService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async export(
|
||||
@Query('activityIds') activityIds?: string[]
|
||||
): Promise<Export> {
|
||||
return this.exportService.export({
|
||||
activityIds,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
}
|
22
apps/api/src/app/export/export.module.ts
Normal file
22
apps/api/src/app/export/export.module.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ExportController],
|
||||
providers: [ExportService]
|
||||
})
|
||||
export class ExportModule {}
|
70
apps/api/src/app/export/export.service.ts
Normal file
70
apps/api/src/app/export/export.service.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async export({
|
||||
activityIds,
|
||||
userId
|
||||
}: {
|
||||
activityIds?: string[];
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
let activities = await this.prismaService.order.findMany({
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
accountId: true,
|
||||
comment: true,
|
||||
date: true,
|
||||
fee: true,
|
||||
id: true,
|
||||
quantity: true,
|
||||
SymbolProfile: true,
|
||||
type: true,
|
||||
unitPrice: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
activities = activities.filter((activity) => {
|
||||
return activityIds.includes(activity.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
meta: { date: new Date().toISOString(), version: environment.version },
|
||||
activities: activities.map(
|
||||
({
|
||||
accountId,
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
id,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
return {
|
||||
accountId,
|
||||
comment,
|
||||
fee,
|
||||
id,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
currency: SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
date: date.toISOString(),
|
||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
||||
};
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
146
apps/api/src/app/frontend.middleware.ts
Normal file
146
apps/api/src/app/frontend.middleware.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlDe = '';
|
||||
public indexHtmlEn = '';
|
||||
public indexHtmlEs = '';
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public isProduction: boolean;
|
||||
|
||||
public constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
const NODE_ENV =
|
||||
this.configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||
'development';
|
||||
|
||||
this.isProduction = NODE_ENV === 'production';
|
||||
|
||||
try {
|
||||
this.indexHtmlDe = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('de'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEn = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEs = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('es'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlIt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('it'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlNl = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('nl'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public use(req: Request, res: Response, next: NextFunction) {
|
||||
let featureGraphicPath = 'assets/cover.png';
|
||||
|
||||
if (
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github' ||
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||
} else if (
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022' ||
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022/'
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||
}
|
||||
|
||||
if (
|
||||
req.path.startsWith('/api/') ||
|
||||
this.isFileRequest(req.url) ||
|
||||
!this.isProduction
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlDe, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'de',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/es' || req.path.startsWith('/es/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlEs, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'es',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/it' || req.path.startsWith('/it/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlIt, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'it',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlNl, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'nl',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlEn, {
|
||||
featureGraphicPath,
|
||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getPathOfIndexHtmlFile(aLocale: string) {
|
||||
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
|
||||
}
|
||||
|
||||
private interpolate(template: string, context: any) {
|
||||
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
|
||||
const properties = objectPath.split('.');
|
||||
return properties.reduce(
|
||||
(previous, current) => previous?.[current],
|
||||
context
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private isFileRequest(filename: string) {
|
||||
if (filename === '/assets/LICENSE') {
|
||||
return true;
|
||||
} else if (filename.includes('auth/ey')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filename.split('.').pop() !== filename;
|
||||
}
|
||||
}
|
10
apps/api/src/app/import/import-data.dto.ts
Normal file
10
apps/api/src/app/import/import-data.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsArray()
|
||||
@Type(() => CreateOrderDto)
|
||||
@ValidateNested({ each: true })
|
||||
activities: CreateOrderDto[];
|
||||
}
|
66
apps/api/src/app/import/import.controller.ts
Normal file
66
apps/api/src/app/import/import.controller.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Controller('import')
|
||||
export class ImportController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly importService: ImportService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let maxActivitiesToImport = this.configurationService.get(
|
||||
'MAX_ACTIVITIES_TO_IMPORT'
|
||||
);
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Premium'
|
||||
) {
|
||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
maxActivitiesToImport,
|
||||
activities: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error, ImportController);
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
message: [error.message]
|
||||
},
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
28
apps/api/src/app/import/import.module.ts
Normal file
28
apps/api/src/app/import/import.module.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ImportController],
|
||||
imports: [
|
||||
AccountModule,
|
||||
CacheModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
providers: [ImportService]
|
||||
})
|
||||
export class ImportModule {}
|
149
apps/api/src/app/import/import.service.ts
Normal file
149
apps/api/src/app/import/import.service.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isSameDay, parseISO } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly orderService: OrderService
|
||||
) {}
|
||||
|
||||
public async import({
|
||||
activities,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const activity of activities) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
activity.dataSource = 'MANUAL';
|
||||
} else {
|
||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.validateActivities({
|
||||
activities,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
});
|
||||
|
||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
||||
(account) => {
|
||||
return account.id;
|
||||
}
|
||||
);
|
||||
|
||||
for (const {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of activities) {
|
||||
await this.orderService.createOrder({
|
||||
comment,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateActivities({
|
||||
activities,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
userId: string;
|
||||
}) {
|
||||
if (activities?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
}
|
||||
|
||||
const existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of activities.entries()) {
|
||||
const duplicateActivity = existingActivities.find((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
|
||||
activity.fee === fee &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === symbol &&
|
||||
activity.type === type &&
|
||||
activity.unitPrice === unitPrice
|
||||
);
|
||||
});
|
||||
|
||||
if (duplicateActivity) {
|
||||
throw new Error(`activities.${index} is a duplicate activity`);
|
||||
}
|
||||
|
||||
if (dataSource !== 'MANUAL') {
|
||||
const quotes = await this.dataProviderService.getQuotes([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (quotes[symbol] === undefined) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (quotes[symbol].currency !== currency) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,13 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||
|
||||
import { InfoService } from './info.service';
|
||||
import { InfoItem } from './interfaces/info-item.interface';
|
||||
|
||||
@Controller('info')
|
||||
export class InfoController {
|
||||
public constructor(private readonly infoService: InfoService) {}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getInfo(): Promise<InfoItem> {
|
||||
return this.infoService.get();
|
||||
}
|
||||
|
@ -1,5 +1,13 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -7,13 +15,23 @@ import { InfoController } from './info.controller';
|
||||
import { InfoService } from './info.service';
|
||||
|
||||
@Module({
|
||||
controllers: [InfoController],
|
||||
imports: [
|
||||
BenchmarkModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
})
|
||||
}),
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
TagModule
|
||||
],
|
||||
controllers: [InfoController],
|
||||
providers: [ConfigurationService, InfoService, PrismaService]
|
||||
providers: [InfoService]
|
||||
})
|
||||
export class InfoModule {}
|
||||
|
@ -1,58 +1,308 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { permissions } from '@ghostfolio/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
PROPERTY_SYSTEM_MESSAGE,
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
encodeDataSource,
|
||||
extractNumberFromString
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { InfoItem } from './interfaces/info-item.interface';
|
||||
import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private jwtService: JwtService,
|
||||
private prisma: PrismaService
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly tagService: TagService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<InfoItem> {
|
||||
const platforms = await this.prisma.platform.findMany({
|
||||
const info: Partial<InfoItem> = {};
|
||||
let isReadOnlyMode: boolean;
|
||||
const platforms = await this.prismaService.platform.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
let systemMessage: string;
|
||||
|
||||
const globalPermissions: string[] = [];
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
|
||||
globalPermissions.push(permissions.enableBlog);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
);
|
||||
} else {
|
||||
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
|
||||
}
|
||||
|
||||
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
globalPermissions.push(permissions.enableImport);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||
globalPermissions.push(permissions.enableSocialLogin);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||
globalPermissions.push(permissions.enableStatistics);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
globalPermissions.push(permissions.enableSubscription);
|
||||
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
||||
globalPermissions.push(permissions.enableSystemMessage);
|
||||
|
||||
systemMessage = (await this.propertyService.getByKey(
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
)) as string;
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
globalPermissions,
|
||||
isReadOnlyMode,
|
||||
platforms,
|
||||
currencies: Object.values(Currency),
|
||||
systemMessage,
|
||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering()
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions(),
|
||||
tags: await this.tagService.get()
|
||||
};
|
||||
}
|
||||
|
||||
private async countActiveUsers(aDays: number) {
|
||||
return await this.prismaService.user.count({
|
||||
orderBy: {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
Analytics: {
|
||||
updatedAt: {
|
||||
gt: subDays(new Date(), aDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async countDockerHubPulls(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
|
||||
const { pull_count } = await get();
|
||||
return pull_count;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async countGitHubContributors(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
'https://github.com/ghostfolio/ghostfolio',
|
||||
'GET',
|
||||
'string',
|
||||
200,
|
||||
{}
|
||||
);
|
||||
|
||||
const html = await get();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
return extractNumberFromString(
|
||||
$(
|
||||
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||
).text()
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async countGitHubStargazers(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
|
||||
const { stargazers_count } = await get();
|
||||
return stargazers_count;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async countNewUsers(aDays: number) {
|
||||
return await this.prismaService.user.count({
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
createdAt: {
|
||||
gt: subDays(new Date(), aDays)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async countSlackCommunityUsers() {
|
||||
return (await this.propertyService.getByKey(
|
||||
PROPERTY_SLACK_COMMUNITY_USERS
|
||||
)) as string;
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
id: DEMO_USER_ID
|
||||
});
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prisma.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
private async getStatistics() {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let statistics: Statistics;
|
||||
|
||||
try {
|
||||
statistics = JSON.parse(
|
||||
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS)
|
||||
);
|
||||
|
||||
if (statistics) {
|
||||
return statistics;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const newUsers30d = await this.countNewUsers(30);
|
||||
|
||||
const dockerHubPulls = await this.countDockerHubPulls();
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
activeUsers30d,
|
||||
dockerHubPulls,
|
||||
gitHubContributors,
|
||||
gitHubStargazers,
|
||||
newUsers30d,
|
||||
slackCommunityUsers
|
||||
};
|
||||
|
||||
await this.redisCacheService.set(
|
||||
InfoService.CACHE_KEY_STATISTICS,
|
||||
JSON.stringify(statistics)
|
||||
);
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
private async getSubscriptions(): Promise<Subscription[]> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripeConfig = await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
});
|
||||
|
||||
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
|
||||
if (stripeConfig) {
|
||||
return [JSON.parse(stripeConfig.value)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +0,0 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface InfoItem {
|
||||
currencies: Currency[];
|
||||
demoAuthToken: string;
|
||||
globalPermissions: string[];
|
||||
lastDataGathering?: Date;
|
||||
message?: {
|
||||
text: string;
|
||||
type: string;
|
||||
};
|
||||
platforms: { id: string; name: string }[];
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { Account, Settings, User } from '@prisma/client';
|
||||
|
||||
export type UserWithSettings = User & {
|
||||
Account: Account[];
|
||||
Settings: Settings;
|
||||
};
|
@ -1,12 +1,47 @@
|
||||
import { Currency, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Tag,
|
||||
Type
|
||||
} from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountId: string;
|
||||
accountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
assetClass?: AssetClass;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetSubClass, { each: true })
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(DataSource, { each: true })
|
||||
dataSource?: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
@ -14,17 +49,17 @@ export class CreateOrderDto {
|
||||
@IsNumber()
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
|
||||
@IsNumber()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsString()
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
tags?: Tag[];
|
||||
|
||||
@IsEnum(Type, { each: true })
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
|
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
export interface Activities {
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
feeInBaseCurrency: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
import { Order, Platform } from '@prisma/client';
|
||||
|
||||
export type OrderWithPlatform = Order & { Platform?: Platform };
|
@ -1,7 +1,12 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -13,7 +18,9 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -22,25 +29,29 @@ import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
import { OrderService } from './order.service';
|
||||
import { UpdateOrderDto } from './update-order.dto';
|
||||
|
||||
@Controller('order')
|
||||
export class OrderController {
|
||||
public constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
const order = await this.orderService.order({ id });
|
||||
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteOrder
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
|
||||
!order ||
|
||||
order.userId !== this.request.user.id
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -48,67 +59,65 @@ export class OrderController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.orderService.deleteOrder(
|
||||
{
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
return this.orderService.deleteOrder({
|
||||
id
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<OrderModel[]> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Activities> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let orders = await this.orderService.orders({
|
||||
include: {
|
||||
Platform: true
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId: impersonationUserId || this.request.user.id }
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
let activities = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
||||
activities = nullifyValuesInObjects(activities, [
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getOrderById(@Param('id') id: string): Promise<OrderModel> {
|
||||
return this.orderService.order({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.createOrder
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -116,56 +125,41 @@ export class OrderController {
|
||||
);
|
||||
}
|
||||
|
||||
const date = parseISO(data.date);
|
||||
|
||||
const accountId = data.accountId;
|
||||
delete data.accountId;
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
return this.orderService.createOrder({
|
||||
...data,
|
||||
date: parseISO(data.date),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency: data.currency,
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
},
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
} else {
|
||||
delete data.platformId;
|
||||
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } },
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||
const originalOrder = await this.orderService.order({
|
||||
id
|
||||
});
|
||||
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateOrder
|
||||
)
|
||||
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
||||
!originalOrder ||
|
||||
originalOrder.userId !== this.request.user.id
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -173,72 +167,38 @@ export class OrderController {
|
||||
);
|
||||
}
|
||||
|
||||
const originalOrder = await this.orderService.order({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
|
||||
const date = parseISO(data.date);
|
||||
|
||||
const accountId = data.accountId;
|
||||
delete data.accountId;
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
|
||||
return this.orderService.updateOrder(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
return this.orderService.updateOrder({
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
} else {
|
||||
// platformId is null, remove it
|
||||
delete data.platformId;
|
||||
|
||||
return this.orderService.updateOrder(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
Platform: originalOrder.platformId
|
||||
? { disconnect: true }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
SymbolProfile: {
|
||||
connect: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
},
|
||||
update: {
|
||||
assetClass: data.assetClass,
|
||||
assetSubClass: data.assetSubClass,
|
||||
name: data.symbol
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
}
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,34 +1,36 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
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.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { OrderController } from './order.controller';
|
||||
import { OrderService } from './order.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
controllers: [OrderController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
OrderService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
exports: [OrderService],
|
||||
imports: [
|
||||
ApiModule,
|
||||
CacheModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [AccountService, OrderService]
|
||||
})
|
||||
export class OrderModule {}
|
||||
|
@ -1,25 +1,45 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order, Prisma } from '@prisma/client';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Order,
|
||||
Prisma,
|
||||
Tag,
|
||||
Type as TypeOfOrder
|
||||
} from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
import { OrderWithPlatform } from './interfaces/order-with-platform.type';
|
||||
import { Activity } from './interfaces/activities.interface';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
public constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private prisma: PrismaService
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async order(
|
||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order | null> {
|
||||
return this.prisma.order.findUnique({
|
||||
return this.prismaService.order.findUnique({
|
||||
where: orderWhereUniqueInput
|
||||
});
|
||||
}
|
||||
@ -30,11 +50,11 @@ export class OrderService {
|
||||
take?: number;
|
||||
cursor?: Prisma.OrderWhereUniqueInput;
|
||||
where?: Prisma.OrderWhereInput;
|
||||
orderBy?: Prisma.OrderOrderByInput;
|
||||
}): Promise<OrderWithPlatform[]> {
|
||||
orderBy?: Prisma.OrderOrderByWithRelationInput;
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prisma.order.findMany({
|
||||
return this.prismaService.order.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
@ -45,60 +65,313 @@ export class OrderService {
|
||||
}
|
||||
|
||||
public async createOrder(
|
||||
data: Prisma.OrderCreateInput,
|
||||
aUserId: string
|
||||
data: Prisma.OrderCreateInput & {
|
||||
accountId?: string;
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass;
|
||||
currency?: string;
|
||||
dataSource?: DataSource;
|
||||
symbol?: string;
|
||||
tags?: Tag[];
|
||||
userId: string;
|
||||
}
|
||||
): Promise<Order> {
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
const defaultAccount = (
|
||||
await this.accountService.getAccounts(data.userId)
|
||||
).find((account) => {
|
||||
return account.isDefault === true;
|
||||
});
|
||||
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
const tags = data.tags ?? [];
|
||||
|
||||
let Account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
id: data.accountId ?? defaultAccount?.id
|
||||
}
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
await this.cacheService.flush(aUserId);
|
||||
if (data.type === 'ITEM') {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
const dataSource: DataSource = 'MANUAL';
|
||||
const id = uuidv4();
|
||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||
|
||||
return this.prisma.order.create({
|
||||
data
|
||||
Account = undefined;
|
||||
data.id = id;
|
||||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
||||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
|
||||
dataSource,
|
||||
symbol: id
|
||||
};
|
||||
} else {
|
||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
delete data.accountId;
|
||||
delete data.assetClass;
|
||||
delete data.assetSubClass;
|
||||
|
||||
if (!data.comment) {
|
||||
delete data.comment;
|
||||
}
|
||||
|
||||
delete data.currency;
|
||||
delete data.dataSource;
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
delete data.userId;
|
||||
|
||||
const orderData: Prisma.OrderCreateInput = data;
|
||||
|
||||
return this.prismaService.order.create({
|
||||
data: {
|
||||
...orderData,
|
||||
Account,
|
||||
isDraft,
|
||||
tags: {
|
||||
connect: tags.map(({ id }) => {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteOrder(
|
||||
where: Prisma.OrderWhereUniqueInput,
|
||||
aUserId: string
|
||||
where: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order> {
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
|
||||
return this.prisma.order.delete({
|
||||
const order = await this.prismaService.order.delete({
|
||||
where
|
||||
});
|
||||
|
||||
if (order.type === 'ITEM') {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
public async updateOrder(
|
||||
params: {
|
||||
where: Prisma.OrderWhereUniqueInput;
|
||||
data: Prisma.OrderUpdateInput;
|
||||
},
|
||||
aUserId: string
|
||||
): Promise<Order> {
|
||||
const { data, where } = params;
|
||||
public async getOrders({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Activity[]> {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
});
|
||||
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
date: <Date>data.date,
|
||||
symbol: <string>data.symbol
|
||||
if (filtersByAccount?.length > 0) {
|
||||
where.accountId = {
|
||||
in: filtersByAccount.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (includeDrafts === false) {
|
||||
where.isDraft = false;
|
||||
}
|
||||
|
||||
if (filtersByAssetClass?.length > 0) {
|
||||
where.SymbolProfile = {
|
||||
OR: [
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
OR: filtersByAssetClass.map(({ id }) => {
|
||||
return { assetClass: AssetClass[id] };
|
||||
})
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{ SymbolProfileOverrides: { is: null } },
|
||||
{ SymbolProfileOverrides: { assetClass: null } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
SymbolProfileOverrides: {
|
||||
OR: filtersByAssetClass.map(({ id }) => {
|
||||
return { assetClass: AssetClass[id] };
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (filtersByTag?.length > 0) {
|
||||
where.tags = {
|
||||
some: {
|
||||
OR: filtersByTag.map(({ id }) => {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (types) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
type: {
|
||||
equals: type
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
await this.orders({
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true,
|
||||
tags: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
)
|
||||
.filter((order) => {
|
||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async updateOrder({
|
||||
data,
|
||||
where
|
||||
}: {
|
||||
data: Prisma.OrderUpdateInput & {
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass;
|
||||
currency?: string;
|
||||
dataSource?: DataSource;
|
||||
symbol?: string;
|
||||
tags?: Tag[];
|
||||
};
|
||||
where: Prisma.OrderWhereUniqueInput;
|
||||
}): Promise<Order> {
|
||||
if (data.Account.connect.id_userId.id === null) {
|
||||
delete data.Account;
|
||||
}
|
||||
|
||||
if (!data.comment) {
|
||||
data.comment = null;
|
||||
}
|
||||
|
||||
const tags = data.tags ?? [];
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
delete data.SymbolProfile.connect;
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
||||
isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
await this.cacheService.flush(aUserId);
|
||||
delete data.assetClass;
|
||||
delete data.assetSubClass;
|
||||
delete data.currency;
|
||||
delete data.dataSource;
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
|
||||
return this.prisma.order.update({
|
||||
data,
|
||||
return this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
isDraft,
|
||||
tags: {
|
||||
connect: tags.map(({ id }) => {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
},
|
||||
where
|
||||
});
|
||||
}
|
||||
|
@ -1,12 +1,46 @@
|
||||
import { Currency, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Tag,
|
||||
Type
|
||||
} from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class UpdateOrderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountId: string;
|
||||
accountId?: string;
|
||||
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
@IsOptional()
|
||||
assetClass?: AssetClass;
|
||||
|
||||
@IsEnum(AssetSubClass, { each: true })
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
dataSource: DataSource;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
@ -14,10 +48,6 @@ export class UpdateOrderDto {
|
||||
@IsNumber()
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
platformId: string | null;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@ -27,6 +57,10 @@ export class UpdateOrderDto {
|
||||
@IsString()
|
||||
symbol: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
tags?: Tag[];
|
||||
|
||||
@IsString()
|
||||
type: Type;
|
||||
|
||||
|
75
apps/api/src/app/portfolio/current-rate.service.mock.ts
Normal file
75
apps/api/src/app/portfolio/current-rate.service.mock.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
function mockGetValue(symbol: string, date: Date) {
|
||||
switch (symbol) {
|
||||
case 'BALN.SW':
|
||||
if (isSameDay(parseDate('2021-11-12'), date)) {
|
||||
return { marketPrice: 146 };
|
||||
} else if (isSameDay(parseDate('2021-11-22'), date)) {
|
||||
return { marketPrice: 142.9 };
|
||||
} else if (isSameDay(parseDate('2021-11-26'), date)) {
|
||||
return { marketPrice: 139.9 };
|
||||
} else if (isSameDay(parseDate('2021-11-30'), date)) {
|
||||
return { marketPrice: 136.6 };
|
||||
} else if (isSameDay(parseDate('2021-12-18'), date)) {
|
||||
return { marketPrice: 148.9 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'NOVN.SW':
|
||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||
return { marketPrice: 87.8 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
default:
|
||||
return { marketPrice: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export const CurrentRateServiceMock = {
|
||||
getValues: ({
|
||||
dataGatheringItems,
|
||||
dateQuery
|
||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
||||
const result: GetValueObject[] = [];
|
||||
if (dateQuery.lt) {
|
||||
for (
|
||||
let date = resetHours(dateQuery.gte);
|
||||
isBefore(date, endOfDay(dateQuery.lt));
|
||||
date = addDays(date, 1)
|
||||
) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
date
|
||||
).marketPrice,
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const date of dateQuery.in) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
date
|
||||
).marketPrice,
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
};
|
118
apps/api/src/app/portfolio/current-rate.service.spec.ts
Normal file
118
apps/api/src/app/portfolio/current-rate.service.spec.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
|
||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
return {
|
||||
MarketDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
get: (date: Date, symbol: string) => {
|
||||
return Promise.resolve<MarketData>({
|
||||
date,
|
||||
symbol,
|
||||
createdAt: date,
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||
marketPrice: 1847.839966
|
||||
});
|
||||
},
|
||||
getRange: ({
|
||||
dateRangeEnd,
|
||||
dateRangeStart,
|
||||
symbols
|
||||
}: {
|
||||
dateRangeEnd: Date;
|
||||
dateRangeStart: Date;
|
||||
symbols: string[];
|
||||
}) => {
|
||||
return Promise.resolve<MarketData[]>([
|
||||
{
|
||||
createdAt: dateRangeStart,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: dateRangeStart,
|
||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||
marketPrice: 1841.823902,
|
||||
symbol: symbols[0]
|
||||
},
|
||||
{
|
||||
createdAt: dateRangeEnd,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: dateRangeEnd,
|
||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||
marketPrice: 1847.839966,
|
||||
symbol: symbols[0]
|
||||
}
|
||||
]);
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
|
||||
return {
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
initialize: () => Promise.resolve(),
|
||||
toCurrency: (value: number) => {
|
||||
return 1 * value;
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('CurrentRateService', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let dataProviderService: DataProviderService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let marketDataService: MarketDataService;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataProviderService = new DataProviderService(null, [], null);
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
|
||||
currentRateService = new CurrentRateService(
|
||||
dataProviderService,
|
||||
exchangeRateDataService,
|
||||
marketDataService
|
||||
);
|
||||
});
|
||||
|
||||
it('getValues', async () => {
|
||||
expect(
|
||||
await currentRateService.getValues({
|
||||
currencies: { AMZN: 'USD' },
|
||||
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
|
||||
dateQuery: {
|
||||
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
||||
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
||||
},
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject<GetValueObject[]>([
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
95
apps/api/src/app/portfolio/current-rate.service.ts
Normal file
95
apps/api/src/app/portfolio/current-rate.service.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
@Injectable()
|
||||
export class CurrentRateService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService
|
||||
) {}
|
||||
|
||||
public async getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery,
|
||||
userCurrency
|
||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||
const includeToday =
|
||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||
|
||||
const promises: Promise<GetValueObject[]>[] = [];
|
||||
|
||||
if (includeToday) {
|
||||
const today = resetHours(new Date());
|
||||
promises.push(
|
||||
this.dataProviderService
|
||||
.getQuotes(dataGatheringItems)
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date: today,
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]
|
||||
?.marketPrice ?? 0,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
|
||||
promises.push(
|
||||
this.marketDataService
|
||||
.getRange({
|
||||
dateQuery,
|
||||
symbols
|
||||
})
|
||||
.then((data) => {
|
||||
return data.map((marketDataItem) => {
|
||||
return {
|
||||
date: marketDataItem.date,
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
marketDataItem.marketPrice,
|
||||
currencies[marketDataItem.symbol],
|
||||
userCurrency
|
||||
),
|
||||
symbol: marketDataItem.symbol
|
||||
};
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return flatten(await Promise.all(promises));
|
||||
}
|
||||
|
||||
private containsToday(dates: Date[]): boolean {
|
||||
for (const date of dates) {
|
||||
if (isToday(date)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface CurrentPositions extends ResponseError {
|
||||
positions: TimelinePosition[];
|
||||
grossPerformance: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
netAnnualizedPerformance?: Big;
|
||||
netPerformance: Big;
|
||||
netPerformancePercentage: Big;
|
||||
currentValue: Big;
|
||||
totalInvestment: Big;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export interface DateQuery {
|
||||
gte?: Date;
|
||||
in?: Date[];
|
||||
lt?: Date;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export interface GetValueObject {
|
||||
date: Date;
|
||||
marketPriceInBaseCurrency: number;
|
||||
symbol: string;
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
|
||||
import { DateQuery } from './date-query.interface';
|
||||
|
||||
export interface GetValuesParams {
|
||||
currencies: { [symbol: string]: string };
|
||||
dataGatheringItems: IDataGatheringItem[];
|
||||
dateQuery: DateQuery;
|
||||
userCurrency: string;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { PortfolioOrder } from './portfolio-order.interface';
|
||||
|
||||
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||
itemType?: '' | 'start' | 'end';
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface PortfolioItem {
|
||||
date: string;
|
||||
grossPerformancePercent: number;
|
||||
investment: number;
|
||||
positions: { [symbol: string]: Position };
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
averagePrice: number;
|
||||
currency: Currency;
|
||||
firstBuyDate: string;
|
||||
investment: number;
|
||||
investmentInOriginalCurrency?: number;
|
||||
marketPrice?: number;
|
||||
quantity: number;
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
currency: string;
|
||||
date: string;
|
||||
dataSource: DataSource;
|
||||
fee: Big;
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
type: TypeOfOrder;
|
||||
unitPrice: Big;
|
||||
}
|
@ -1,6 +1,12 @@
|
||||
import {
|
||||
EnhancedSymbolProfile,
|
||||
HistoricalDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
currency: string;
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
@ -9,13 +15,18 @@ export interface PortfolioPositionDetail {
|
||||
marketPrice: number;
|
||||
maxPrice: number;
|
||||
minPrice: number;
|
||||
netPerformance: number;
|
||||
netPerformancePercent: number;
|
||||
orders: OrderWithAccount[];
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
export interface HistoricalDataItem {
|
||||
averagePrice?: number;
|
||||
date: string;
|
||||
grossPerformancePercent?: number;
|
||||
SymbolProfile: EnhancedSymbolProfile;
|
||||
tags: Tag[];
|
||||
transactionCount: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface HistoricalDataContainer {
|
||||
isAllTimeHigh: boolean;
|
||||
isAllTimeLow: boolean;
|
||||
items: HistoricalDataItem[];
|
||||
}
|
||||
|
@ -1,26 +0,0 @@
|
||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPosition {
|
||||
currency: Currency;
|
||||
exchange?: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
industry?: string;
|
||||
investment: number;
|
||||
marketChange?: number;
|
||||
marketChangePercent?: number;
|
||||
marketPrice: number;
|
||||
marketState: MarketState;
|
||||
name: string;
|
||||
platforms: {
|
||||
[name: string]: { current: number; original: number };
|
||||
};
|
||||
quantity: number;
|
||||
sector?: string;
|
||||
shareCurrent: number;
|
||||
shareInvestment: number;
|
||||
symbol: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface PortfolioPositions {
|
||||
positions: Position[];
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TimelineInfoInterface {
|
||||
maxNetPerformance: Big;
|
||||
minNetPerformance: Big;
|
||||
timelinePeriods: TimelinePeriod[];
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TimelinePeriod {
|
||||
date: string;
|
||||
grossPerformance: Big;
|
||||
investment: Big;
|
||||
netPerformance: Big;
|
||||
value: Big;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export type Accuracy = 'day' | 'month' | 'year';
|
||||
|
||||
export interface TimelineSpecification {
|
||||
accuracy: Accuracy;
|
||||
start: string;
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
fee: Big;
|
||||
firstBuyDate: string;
|
||||
investment: Big;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
transactionCount: number;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { TransactionPointSymbol } from './transaction-point-symbol.interface';
|
||||
|
||||
export interface TransactionPoint {
|
||||
date: string;
|
||||
items: TransactionPointSymbol[];
|
||||
}
|
@ -0,0 +1,109 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-22',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.55),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(142.9)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.65),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(136.6)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-22')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('-15.8'),
|
||||
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
investment: new Big('0'),
|
||||
netPerformance: new Big('-15.8'),
|
||||
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||
marketPrice: 148.9,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'BALN.SW',
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||
{ date: '2021-11-30', investment: new Big('0') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-11-01', investment: new Big('12.6') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user