Compare commits
692 Commits
angular-ve
...
imp/settin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
136dac17fb | ||
|
|
884cb9c462 | ||
|
|
d1bd36e0a4 | ||
|
|
7c42087854 | ||
|
|
14c89dec24 | ||
|
|
b1bdf0ac11 | ||
|
|
7635676289 | ||
|
|
2bd6c19c13 | ||
|
|
374595261f | ||
|
|
b6c056dd1a | ||
|
|
81e1872c1f | ||
|
|
5cce3bc613 | ||
|
|
c53ab511bf | ||
|
|
7b9a16fd72 | ||
|
|
8830af2cbb | ||
|
|
b915de2b93 | ||
|
|
29b8c1b2af | ||
|
|
c2b231d5cc | ||
|
|
53a28cf489 | ||
|
|
e8ccc2a533 | ||
|
|
f24c0d8955 | ||
|
|
01a580d992 | ||
|
|
c2e670c9a2 | ||
|
|
25042baf71 | ||
|
|
e8d21ee187 | ||
|
|
a8d1446b0d | ||
|
|
2082934cd5 | ||
|
|
4debcd6aa5 | ||
|
|
76adb89caf | ||
|
|
703a6425fe | ||
|
|
e2c9e19b83 | ||
|
|
e2a749e0b6 | ||
|
|
2c0b0ac4c5 | ||
|
|
dd511b236f | ||
|
|
2c860b0cc8 | ||
|
|
1e6045c534 | ||
|
|
2a9e12a495 | ||
|
|
fd2fc793df | ||
|
|
8380b354cc | ||
|
|
2aaf0fc19a | ||
|
|
f3b7479770 | ||
|
|
65745e368f | ||
|
|
cabd05e0da | ||
|
|
6e71a91d6c | ||
|
|
7dc3dedda5 | ||
|
|
944acf99db | ||
|
|
a9d0244ca2 | ||
|
|
b688f8e114 | ||
|
|
e7e9cfce8c | ||
|
|
27605b4d68 | ||
|
|
ff4b0ed315 | ||
|
|
fe7c15ced1 | ||
|
|
15ff69a031 | ||
|
|
070c643105 | ||
|
|
980af8bd4f | ||
|
|
1931856d31 | ||
|
|
fe3fb5e627 | ||
|
|
ef47df804f | ||
|
|
7ea05d2982 | ||
|
|
daa65465dd | ||
|
|
de26417247 | ||
|
|
69b2fe1a90 | ||
|
|
5c327a3a21 | ||
|
|
123a912e64 | ||
|
|
78516d8d6c | ||
|
|
9946c9a00e | ||
|
|
4887383dc4 | ||
|
|
a6863d8280 | ||
|
|
edf81dbe57 | ||
|
|
300d4763f5 | ||
|
|
80f5febb51 | ||
|
|
aaaac09212 | ||
|
|
c4400d178f | ||
|
|
a6286eb2b8 | ||
|
|
ee75aead78 | ||
|
|
e3c002b088 | ||
|
|
3beed3dae6 | ||
|
|
33aace71c8 | ||
|
|
da791e2cb7 | ||
|
|
354b9422ed | ||
|
|
3373dccc58 | ||
|
|
06da0d20b9 | ||
|
|
256f1eb3a9 | ||
|
|
5f86ba6b13 | ||
|
|
5addcee0b2 | ||
|
|
3419d7e81d | ||
|
|
78d960bf01 | ||
|
|
8dc3133814 | ||
|
|
1709fad733 | ||
|
|
7f71e8952b | ||
|
|
22d2023e2a | ||
|
|
fa08463e65 | ||
|
|
7226932247 | ||
|
|
6adf40f5a6 | ||
|
|
f03f6e6f5d | ||
|
|
a112d39321 | ||
|
|
4788294bc4 | ||
|
|
d7416ff793 | ||
|
|
d89247eb02 | ||
|
|
5318f95037 | ||
|
|
c80b00ec76 | ||
|
|
f48476478a | ||
|
|
737f7cada2 | ||
|
|
833879e0e8 | ||
|
|
cb5610d99b | ||
|
|
0434bbb73b | ||
|
|
6e911d79fc | ||
|
|
0bb748cf89 | ||
|
|
ba5d4975af | ||
|
|
d4620148bd | ||
|
|
8d7d54be78 | ||
|
|
c34b94c7db | ||
|
|
55a0028e26 | ||
|
|
17371200ca | ||
|
|
83044077d3 | ||
|
|
a03d9ef6a4 | ||
|
|
fca8ace10d | ||
|
|
d970cbb626 | ||
|
|
6d8c475e67 | ||
|
|
a1c0cef149 | ||
|
|
8f098143fd | ||
|
|
407dc416ec | ||
|
|
61461bb776 | ||
|
|
2a7019c64c | ||
|
|
5b1cbb0c46 | ||
|
|
3d67145af7 | ||
|
|
1c981312d4 | ||
|
|
02d814b935 | ||
|
|
e87f33dcc8 | ||
|
|
6286d4315d | ||
|
|
a1234b8af0 | ||
|
|
bc0a62002b | ||
|
|
52eca27619 | ||
|
|
e4c9e22972 | ||
|
|
20e7d3c51a | ||
|
|
6d5aa0ccab | ||
|
|
7618ae7c6a | ||
|
|
808731387b | ||
|
|
502726cd83 | ||
|
|
a26d8d0f90 | ||
|
|
747088e7cc | ||
|
|
affbbbffbf | ||
|
|
d3023618e1 | ||
|
|
12b430a349 | ||
|
|
2f3e555b5a | ||
|
|
2498effce3 | ||
|
|
2ad3c2dcd4 | ||
|
|
6226ae35ff | ||
|
|
26de439fab | ||
|
|
295d7a92df | ||
|
|
e20ab86d6e | ||
|
|
5c938586b8 | ||
|
|
93b67fba07 | ||
|
|
e4dfae9f1d | ||
|
|
0efcbf448b | ||
|
|
f2f12a2dfa | ||
|
|
ea37b55078 | ||
|
|
cc0ff20ca1 | ||
|
|
6b58709848 | ||
|
|
f2b1262e3d | ||
|
|
7c04598264 | ||
|
|
7def564950 | ||
|
|
278e221c75 | ||
|
|
d9a5f76449 | ||
|
|
b9b707410d | ||
|
|
87675cc73c | ||
|
|
0e083868cb | ||
|
|
94977f7255 | ||
|
|
cf686ef8c5 | ||
|
|
857b48e225 | ||
|
|
f846230d59 | ||
|
|
bcfa18b1e8 | ||
|
|
bb8e6ee60f | ||
|
|
6ebdd78855 | ||
|
|
70cca5d4c0 | ||
|
|
6448d24e20 | ||
|
|
5fb2633bc5 | ||
|
|
75c55fff21 | ||
|
|
8f5de8f1a1 | ||
|
|
db9b481e8d | ||
|
|
cdd22e5f2f | ||
|
|
635b5ce8e1 | ||
|
|
1a476a0e3c | ||
|
|
80b1d6c292 | ||
|
|
deb0f3f602 | ||
|
|
71f168f8fa | ||
|
|
6f63041148 | ||
|
|
399a01904a | ||
|
|
9cc19460bd | ||
|
|
2920f131f8 | ||
|
|
04f622a7f0 | ||
|
|
fadc115412 | ||
|
|
10c53d954e | ||
|
|
29a09ec500 | ||
|
|
6dba080ade | ||
|
|
ab7ca33ac1 | ||
|
|
bc6a15de8f | ||
|
|
a47a9045e6 | ||
|
|
b6e92b4211 | ||
|
|
5222d75064 | ||
|
|
2587b8afd9 | ||
|
|
6c08f10e9d | ||
|
|
6c620d6878 | ||
|
|
072c1a6a3b | ||
|
|
78e14d6378 | ||
|
|
68e71d09ea | ||
|
|
6ac2a0c888 | ||
|
|
66e01119d2 | ||
|
|
8fb33e311d | ||
|
|
f06851fa37 | ||
|
|
e750023fdc | ||
|
|
e2e57fbf26 | ||
|
|
56d6a53a54 | ||
|
|
ee6055934c | ||
|
|
03b3f55400 | ||
|
|
2aab2a21b6 | ||
|
|
a44b276269 | ||
|
|
d150747f83 | ||
|
|
fa9e765e37 | ||
|
|
b0253135e5 | ||
|
|
8e62594eff | ||
|
|
978d9158c0 | ||
|
|
134899114d | ||
|
|
8533a440bc | ||
|
|
9ec422c6e2 | ||
|
|
6c03bf71c2 | ||
|
|
3887cc477d | ||
|
|
0b96d59285 | ||
|
|
a3f317cbeb | ||
|
|
5a9ceb4a94 | ||
|
|
bdc3050a5e | ||
|
|
bc085926a6 | ||
|
|
aa1fb1c6f5 | ||
|
|
26b47aac53 | ||
|
|
8dcd0295e5 | ||
|
|
3206af160a | ||
|
|
b500c801ee | ||
|
|
3f1b8762dd | ||
|
|
a6f9046b42 | ||
|
|
2cf91bddea | ||
|
|
e1e4187ded | ||
|
|
e02796c310 | ||
|
|
5d9e96033e | ||
|
|
cc618960e6 | ||
|
|
f9926e7a5d | ||
|
|
03fc2fb7ee | ||
|
|
b6efa3f37e | ||
|
|
85f20eaf1c | ||
|
|
411147efce | ||
|
|
48c3d58f7e | ||
|
|
746d38017f | ||
|
|
01298928c7 | ||
|
|
13ee16452b | ||
|
|
9a57413624 | ||
|
|
8d8250bc17 | ||
|
|
174c6bcedf | ||
|
|
c70f8e7b6d | ||
|
|
6ba1ff57b2 | ||
|
|
a5291483f7 | ||
|
|
e3a9618dc9 | ||
|
|
f30fde553d | ||
|
|
f9c1537ca0 | ||
|
|
208a6db1a6 | ||
|
|
9e1798cc3e | ||
|
|
9e29031703 | ||
|
|
3626192f31 | ||
|
|
d246f8e3ed | ||
|
|
3ddf6900c9 | ||
|
|
aab3ffe262 | ||
|
|
56f129d784 | ||
|
|
7fe35d646a | ||
|
|
31891fae6e | ||
|
|
33ee3a521c | ||
|
|
df581b965a | ||
|
|
6cd7500073 | ||
|
|
20b9251eab | ||
|
|
6f66367282 | ||
|
|
e566514ac0 | ||
|
|
02db84e7f2 | ||
|
|
8adeabce61 | ||
|
|
7e6d7d8580 | ||
|
|
0781f3e13d | ||
|
|
64f1e5831a | ||
|
|
551924c384 | ||
|
|
c889f8e9c8 | ||
|
|
86b5ec0afd | ||
|
|
6bf98b787e | ||
|
|
3532b0bbfb | ||
|
|
6d4d851f1d | ||
|
|
fb9e430ba0 | ||
|
|
73c78dd28f | ||
|
|
509e654123 | ||
|
|
6b7f412341 | ||
|
|
edf051adc7 | ||
|
|
aee09aeb0d | ||
|
|
d15c00c29b | ||
|
|
6c4bcbe300 | ||
|
|
2ff0555493 | ||
|
|
e84ab43b36 | ||
|
|
8134c6af35 | ||
|
|
e05169b7b4 | ||
|
|
df62f15734 | ||
|
|
e26f16bbc2 | ||
|
|
7623ea2f7f | ||
|
|
c19c1c2f34 | ||
|
|
6443a03afd | ||
|
|
bb4229a82d | ||
|
|
e41cead10b | ||
|
|
ecd4d29a38 | ||
|
|
7dfaacd28e | ||
|
|
775a91889f | ||
|
|
3159ba14b9 | ||
|
|
3bef18901a | ||
|
|
a2395f121b | ||
|
|
a1e8a4c464 | ||
|
|
11e5a6d379 | ||
|
|
365369cc31 | ||
|
|
0452dbd179 | ||
|
|
d70fb133b7 | ||
|
|
2064c0833c | ||
|
|
d0947112eb | ||
|
|
c9d9134049 | ||
|
|
91b8f4ca2b | ||
|
|
d56eaa9f02 | ||
|
|
71e1d58ec6 | ||
|
|
382283d0ce | ||
|
|
c29ba6ea69 | ||
|
|
cf5f5c1449 | ||
|
|
d5796b2cb5 | ||
|
|
dd8bfe9fce | ||
|
|
f80ec9797e | ||
|
|
eb158678d4 | ||
|
|
865502a796 | ||
|
|
7a7856bc36 | ||
|
|
756c9b892f | ||
|
|
ccde08b700 | ||
|
|
87f73ee4c2 | ||
|
|
fbbd820512 | ||
|
|
0a92d38ccf | ||
|
|
e4e6d3c74d | ||
|
|
f352d823a8 | ||
|
|
98a96b4fcc | ||
|
|
63483e01c2 | ||
|
|
b247186a0a | ||
|
|
4304ebf7b1 | ||
|
|
4d229c79d5 | ||
|
|
6e995e7fc2 | ||
|
|
eec100dfe8 | ||
|
|
10d64c88e3 | ||
|
|
165a87ce69 | ||
|
|
e5ff036d81 | ||
|
|
326f283d4e | ||
|
|
c048085c8a | ||
|
|
8fcd4d0d53 | ||
|
|
30bdaf1ed5 | ||
|
|
39e09bedd3 | ||
|
|
487fb76776 | ||
|
|
41e563297a | ||
|
|
9743adaed5 | ||
|
|
b179a0274f | ||
|
|
61574c847f | ||
|
|
2eee15be03 | ||
|
|
0ae615cc77 | ||
|
|
7f46b10a42 | ||
|
|
dee385c6db | ||
|
|
207e038315 | ||
|
|
dc3433a036 | ||
|
|
14c5c148b9 | ||
|
|
7fdea2a285 | ||
|
|
e3324f0707 | ||
|
|
0336715103 | ||
|
|
c37ffd6991 | ||
|
|
5a07bcce77 | ||
|
|
ceb962a92a | ||
|
|
4af204daec | ||
|
|
30edda1762 | ||
|
|
5bd06a12dd | ||
|
|
8b63c1cf9e | ||
|
|
1e6b1b7d96 | ||
|
|
e74668c389 | ||
|
|
cf52140bca | ||
|
|
7e44d53bb3 | ||
|
|
fdb485614f | ||
|
|
6b35ffe930 | ||
|
|
9a254105fb | ||
|
|
e73196a249 | ||
|
|
84f77940fd | ||
|
|
3d1cb29a67 | ||
|
|
345b8500cd | ||
|
|
3672d02d6f | ||
|
|
efbfe77deb | ||
|
|
09cf5d8990 | ||
|
|
1e15630708 | ||
|
|
8c02ad9291 | ||
|
|
4c34a01729 | ||
|
|
19cd0e577c | ||
|
|
e096bc66ab | ||
|
|
f22caea1e5 | ||
|
|
208d1ad5d4 | ||
|
|
44527f68cf | ||
|
|
3c7cacc46f | ||
|
|
bbd602a297 | ||
|
|
df2a40b861 | ||
|
|
e29e5ed0a4 | ||
|
|
734b5f807b | ||
|
|
85cce6e707 | ||
|
|
a4da6cdf3a | ||
|
|
f837ca6b23 | ||
|
|
7b326e8ff0 | ||
|
|
680e84d19b | ||
|
|
cf5919a3a0 | ||
|
|
9ce6cd63d1 | ||
|
|
6f5e5f5c30 | ||
|
|
a25fcf209a | ||
|
|
5d0777f67c | ||
|
|
f1d504f985 | ||
|
|
9a070ef5d3 | ||
|
|
3e5bc71535 | ||
|
|
6a4d77d904 | ||
|
|
c35d53266a | ||
|
|
ea79270bff | ||
|
|
975e5c4faf | ||
|
|
f405777463 | ||
|
|
217ec39503 | ||
|
|
e89f81152e | ||
|
|
a34b9a8fb0 | ||
|
|
dc096f5e12 | ||
|
|
a681aadcfa | ||
|
|
29618660aa | ||
|
|
d3c4fdef9d | ||
|
|
4f7cbf3527 | ||
|
|
ad76563543 | ||
|
|
4e973f3d51 | ||
|
|
17bcf8c41f | ||
|
|
a8bf4671fa | ||
|
|
95d0985f3d | ||
|
|
2dd756bbb8 | ||
|
|
3be97b1da2 | ||
|
|
b436db183f | ||
|
|
6508dc6c64 | ||
|
|
b3d39b65b0 | ||
|
|
67c26a973e | ||
|
|
687fff9c74 | ||
|
|
f15f3f5110 | ||
|
|
07ae71fd23 | ||
|
|
9c7fad790f | ||
|
|
26270b2842 | ||
|
|
05729285af | ||
|
|
d713ed5900 | ||
|
|
cfbb4534d8 | ||
|
|
67cff68581 | ||
|
|
b63df394cc | ||
|
|
2a96e61a97 | ||
|
|
be26d241c0 | ||
|
|
2670eb2925 | ||
|
|
75c8e678bf | ||
|
|
ddb3e2bc17 | ||
|
|
613d7aba71 | ||
|
|
7a7eeefe3b | ||
|
|
1c306c571b | ||
|
|
fb56a12297 | ||
|
|
26171fd846 | ||
|
|
5a475a84b5 | ||
|
|
b617d15c62 | ||
|
|
f7ba4f202b | ||
|
|
bb57280c8c | ||
|
|
bbca644b40 | ||
|
|
5221061241 | ||
|
|
0d0596b767 | ||
|
|
dfb360733e | ||
|
|
c1e6689beb | ||
|
|
4e1c6fb333 | ||
|
|
eca7af2d6f | ||
|
|
3ace14fcdb | ||
|
|
69cd40dc95 | ||
|
|
ece614941e | ||
|
|
b47b3253f6 | ||
|
|
889335c579 | ||
|
|
7b657120e9 | ||
|
|
a0cf5099f8 | ||
|
|
82aa207e0d | ||
|
|
301b58f0ba | ||
|
|
4c4a860c76 | ||
|
|
d0310ded28 | ||
|
|
c01ef4579a | ||
|
|
99bec6c7f9 | ||
|
|
c1a303e78c | ||
|
|
193288013e | ||
|
|
39e8add103 | ||
|
|
0f82c9738b | ||
|
|
a4237a6f17 | ||
|
|
20039a07ff | ||
|
|
dfc38a6829 | ||
|
|
0e0d1a5f11 | ||
|
|
ef299f1f4a | ||
|
|
4dbaab060a | ||
|
|
b8811ab5b6 | ||
|
|
5248c26b76 | ||
|
|
eed0fb6eca | ||
|
|
2a9447b506 | ||
|
|
fb94028410 | ||
|
|
25639afe1a | ||
|
|
4426b5f3ef | ||
|
|
3cae2771de | ||
|
|
81f55adb41 | ||
|
|
bd4c88833d | ||
|
|
2374d7a357 | ||
|
|
91730026fd | ||
|
|
9d10b23ba7 | ||
|
|
d0c231ee43 | ||
|
|
58ce8e40c7 | ||
|
|
66b0709e6e | ||
|
|
a2ed33214d | ||
|
|
a3dccd690d | ||
|
|
69313fba34 | ||
|
|
1889c58598 | ||
|
|
e9f0162439 | ||
|
|
2aa4fe9673 | ||
|
|
ccb50e3c62 | ||
|
|
5ce9e66fea | ||
|
|
6492a4672b | ||
|
|
46acb26c42 | ||
|
|
c9aab73a2a | ||
|
|
13a202cca4 | ||
|
|
bdb9c9ca28 | ||
|
|
5ed5a86bad | ||
|
|
520888988e | ||
|
|
de28f87c62 | ||
|
|
81a6c44090 | ||
|
|
f142046dcc | ||
|
|
c5e480af52 | ||
|
|
f89e3e8554 | ||
|
|
1442c57e18 | ||
|
|
0987fb14b2 | ||
|
|
dc22d1e6cb | ||
|
|
e9e9bffd9a | ||
|
|
11694de4e6 | ||
|
|
8f181c687b | ||
|
|
926c058d1e | ||
|
|
1583221232 | ||
|
|
585a65be31 | ||
|
|
2de9b7f6b7 | ||
|
|
323b17185c | ||
|
|
6002ab7c50 | ||
|
|
bd77733935 | ||
|
|
09f44a5685 | ||
|
|
0e67434515 | ||
|
|
cfa0af24ae | ||
|
|
69f5009579 | ||
|
|
24fa837a39 | ||
|
|
5e4d78c6f5 | ||
|
|
837692e808 | ||
|
|
6ffdbc64d0 | ||
|
|
65af5f659e | ||
|
|
f38a7b4d56 | ||
|
|
f4ab7841fb | ||
|
|
3de4f69a62 | ||
|
|
102be2c24a | ||
|
|
378dc22bb0 | ||
|
|
3a39b25e64 | ||
|
|
32248f8424 | ||
|
|
a1f8776743 | ||
|
|
80797e043c | ||
|
|
312c6b5be8 | ||
|
|
c18889a127 | ||
|
|
c1e923c703 | ||
|
|
f716971654 | ||
|
|
d7ca1d8bd2 | ||
|
|
8704b6a8c8 | ||
|
|
4687478704 | ||
|
|
2bdae400ac | ||
|
|
0cb0efe43e | ||
|
|
7e431d645a | ||
|
|
cef4bffd69 | ||
|
|
84c7428fed | ||
|
|
a568ee808f | ||
|
|
fc30c1854e | ||
|
|
c19e06d902 | ||
|
|
82155cab8d | ||
|
|
69b910f2a4 | ||
|
|
f9858fbd4b | ||
|
|
f3a7fd8be5 | ||
|
|
49bdd00dac | ||
|
|
2e985bd051 | ||
|
|
8e74f1ddb5 | ||
|
|
2a3f87cac1 | ||
|
|
217a6941a1 | ||
|
|
753e3be83f | ||
|
|
ebd0f66768 | ||
|
|
a07584b3af | ||
|
|
0d08634c78 | ||
|
|
d333104f43 | ||
|
|
a724247aec | ||
|
|
2e36a477ce | ||
|
|
6892de487f | ||
|
|
7b04821ef1 | ||
|
|
f8a216fb6e | ||
|
|
86b5d94ff8 | ||
|
|
fb3a505c22 | ||
|
|
72d372b685 | ||
|
|
536c1c37b1 | ||
|
|
40caea7d79 | ||
|
|
33c15ac138 | ||
|
|
05ab135ed2 | ||
|
|
19deef9298 | ||
|
|
c4837e7e5c | ||
|
|
b73ef12eac | ||
|
|
c52b223c59 | ||
|
|
ffc9101030 | ||
|
|
b5c5225867 | ||
|
|
407b3c5ba7 | ||
|
|
528db06cd8 | ||
|
|
0e1314d183 | ||
|
|
7ac35bfdbc | ||
|
|
cc6d647f5a | ||
|
|
fba1adda35 | ||
|
|
fe2518d53c | ||
|
|
62548e5c37 | ||
|
|
faa5d26601 | ||
|
|
ba90fa1274 | ||
|
|
51767ebbdb | ||
|
|
ad91148616 | ||
|
|
38df66044d | ||
|
|
1676fc1314 | ||
|
|
aaaaec6f36 | ||
|
|
e0b2fa2d6f | ||
|
|
4a2393881b | ||
|
|
583fec04d7 | ||
|
|
e7ff9b645b | ||
|
|
2b82ff699e | ||
|
|
d1136a549a | ||
|
|
ec4d3e738a | ||
|
|
c8380e1c30 | ||
|
|
cabc97afc0 | ||
|
|
349f0ecfec | ||
|
|
890ad5e969 | ||
|
|
0fc79d9ae5 | ||
|
|
d60ac2246d | ||
|
|
5d04718394 | ||
|
|
4bece298c1 | ||
|
|
469901ab88 | ||
|
|
13c7015b1c | ||
|
|
21ab2f8a82 | ||
|
|
75391641fd | ||
|
|
a368b979d5 | ||
|
|
a5b881c609 | ||
|
|
9dbab2c5d3 | ||
|
|
8f913b0f4e | ||
|
|
31ac184107 | ||
|
|
23558b8efc | ||
|
|
4bb3b42c76 | ||
|
|
0c5eff7121 | ||
|
|
136530adf1 | ||
|
|
6128c64c31 | ||
|
|
a2bfdb682b | ||
|
|
f7582173ed | ||
|
|
24dc99a19a | ||
|
|
907075f51d | ||
|
|
b48ac45085 | ||
|
|
b115d0a772 | ||
|
|
ad0cdfe1d9 | ||
|
|
a50ef47a52 | ||
|
|
db4240d99b | ||
|
|
bf1d48709c | ||
|
|
c3c0c288a8 | ||
|
|
79e8bb3734 | ||
|
|
a6884440a0 | ||
|
|
b9e5f396fd | ||
|
|
fc40ebcaba | ||
|
|
54642037d3 | ||
|
|
0778089ff3 | ||
|
|
ac2afd6949 | ||
|
|
8162ce65cb | ||
|
|
6e4bdea1c2 | ||
|
|
daf8ec2e0a | ||
|
|
2a3ae31e4e | ||
|
|
9c27c41a5e | ||
|
|
a328da679c | ||
|
|
122496513b | ||
|
|
7363c4c692 | ||
|
|
012e683240 | ||
|
|
b3a37df4be | ||
|
|
cb94b19e61 | ||
|
|
50c4f1a6ac | ||
|
|
1d02313585 | ||
|
|
04ffc049b0 | ||
|
|
ca3db02ce8 | ||
|
|
ad7c2b20a2 | ||
|
|
89e39520ba | ||
|
|
6efaeb3ff6 | ||
|
|
e42819ef64 | ||
|
|
8825b0410a |
81
.gitignore
vendored
81
.gitignore
vendored
@@ -1,4 +1,81 @@
|
||||
.idea
|
||||
.vscode
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp/
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
.nuxt/
|
||||
.cache/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.template
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.cursor/
|
||||
.claude/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.sublime-workspace
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Temp files
|
||||
.temp/
|
||||
.tmp/
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# Debug
|
||||
.debug/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Yarn
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ We have adopted a Code of Conduct to ensure a welcoming and inclusive environmen
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Follow the [Angular Style Guide](https://angular.io/guide/styleguide) for the frontend code.
|
||||
- Follow the [React Documentation](https://react.dev/learn) for best practices in React development.
|
||||
- Use [TypeScript](https://www.typescriptlang.org/) for both frontend and backend code.
|
||||
- Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification for commit messages.
|
||||
|
||||
|
||||
287
README.md
287
README.md
@@ -1,11 +1,29 @@
|
||||
<h1 align="center">
|
||||
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||
</a>
|
||||
<br>
|
||||
Worklenz
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Worklenz/worklenz/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://github.com/Worklenz/worklenz/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Worklenz/worklenz" alt="Release">
|
||||
</a>
|
||||
<a href="https://github.com/Worklenz/worklenz/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/Worklenz/worklenz" alt="Stars">
|
||||
</a>
|
||||
<a href="https://github.com/Worklenz/worklenz/network/members">
|
||||
<img src="https://img.shields.io/github/forks/Worklenz/worklenz" alt="Forks">
|
||||
</a>
|
||||
<a href="https://github.com/Worklenz/worklenz/issues">
|
||||
<img src="https://img.shields.io/github/issues/Worklenz/worklenz" alt="Issues">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://worklenz.com/task-management/">Task Management</a> |
|
||||
<a href="https://worklenz.com/time-tracking/">Time Tracking</a> |
|
||||
@@ -27,6 +45,24 @@
|
||||
Worklenz is a project management tool designed to help organizations improve their efficiency. It provides a
|
||||
comprehensive solution for managing projects, tasks, and collaboration within teams.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Quick Start (Docker)](#-quick-start-docker---recommended)
|
||||
- [Manual Installation](#️-manual-installation-for-development)
|
||||
- [Deployment](#deployment)
|
||||
- [Local Development](#local-development-with-docker)
|
||||
- [Remote Server Deployment](#remote-server-deployment)
|
||||
- [Configuration](#configuration)
|
||||
- [MinIO Integration](#minio-integration)
|
||||
- [Security](#security)
|
||||
- [Analytics](#analytics)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## Features
|
||||
|
||||
- **Project Planning**: Create and organize projects, assign tasks to team members.
|
||||
@@ -39,20 +75,237 @@ comprehensive solution for managing projects, tasks, and collaboration within te
|
||||
|
||||
This repository contains the frontend and backend code for Worklenz.
|
||||
|
||||
- **Frontend**: Built using Angular, with [Ant Design of Angular](https://ng.ant.design/docs/introduce/en) as the UI
|
||||
library..
|
||||
- **Backend**: Built using a custom TypeScript implementation of ExpressJS, with PostgreSQL as the database, providing a
|
||||
robust, scalable, and type-safe backend.
|
||||
- **Frontend**: Built using React with Ant Design as the UI library.
|
||||
- **Backend**: Built using TypeScript, Express.js, with PostgreSQL as the database.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js version v18 or newer
|
||||
- Postgres version v15.6
|
||||
- Redis version v4.6.7 (not used yet. setup only.)
|
||||
- PostgreSQL version v15 or newer
|
||||
- Docker and Docker Compose (for containerized setup)
|
||||
|
||||
## Getting started with Worklenz.
|
||||
- Containerized Installation - Use docker to deploy Worklenz in production or development environments.
|
||||
- Manual installation - To get started with Worklenz, please follow this guide [worklenz setup guidelines](SETUP_THE_PROJECT.md).
|
||||
## Getting Started
|
||||
|
||||
Choose your preferred setup method below. Docker is recommended for quick setup and testing.
|
||||
|
||||
### 🚀 Quick Start (Docker - Recommended)
|
||||
|
||||
The fastest way to get Worklenz running locally with all dependencies included.
|
||||
|
||||
**Prerequisites:**
|
||||
- Docker and Docker Compose installed on your system
|
||||
- Git
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Worklenz/worklenz.git
|
||||
cd worklenz
|
||||
```
|
||||
|
||||
2. Start the Docker containers:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. Access the application:
|
||||
- **Frontend**: http://localhost:5000
|
||||
- **Backend API**: http://localhost:3000
|
||||
- **MinIO Console**: http://localhost:9001 (login: minioadmin/minioadmin)
|
||||
|
||||
4. To stop the services:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
**Alternative startup methods:**
|
||||
- **Windows**: Run `start.bat`
|
||||
- **Linux/macOS**: Run `./start.sh`
|
||||
|
||||
**Video Guide**: For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
|
||||
|
||||
### 🛠️ Manual Installation (For Development)
|
||||
|
||||
For developers who want to run the services individually or customize the setup.
|
||||
|
||||
**Prerequisites:**
|
||||
- Node.js (version 18 or higher)
|
||||
- PostgreSQL (version 15 or higher)
|
||||
- An S3-compatible storage service (like MinIO) or Azure Blob Storage
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Worklenz/worklenz.git
|
||||
cd worklenz
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
```bash
|
||||
cp worklenz-backend/.env.template worklenz-backend/.env
|
||||
# Update the environment variables with your configuration
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
# Backend dependencies
|
||||
cd worklenz-backend
|
||||
npm install
|
||||
|
||||
# Frontend dependencies
|
||||
cd ../worklenz-frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
4. Set up the database:
|
||||
```bash
|
||||
# Create a PostgreSQL database named worklenz_db
|
||||
cd worklenz-backend
|
||||
|
||||
# Execute the SQL setup files in the correct order
|
||||
psql -U your_username -d worklenz_db -f database/sql/0_extensions.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/1_tables.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/indexes.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/4_functions.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/triggers.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/3_views.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/2_dml.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/5_database_user.sql
|
||||
```
|
||||
|
||||
5. Start the development servers:
|
||||
```bash
|
||||
# Terminal 1: Start the backend
|
||||
cd worklenz-backend
|
||||
npm run dev
|
||||
|
||||
# Terminal 2: Start the frontend
|
||||
cd worklenz-frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. Access the application at http://localhost:5000
|
||||
|
||||
## Deployment
|
||||
|
||||
For local development, follow the [Quick Start (Docker)](#-quick-start-docker---recommended) section above.
|
||||
|
||||
### Remote Server Deployment
|
||||
|
||||
When deploying to a remote server:
|
||||
|
||||
1. Set up the environment files with your server's hostname:
|
||||
```bash
|
||||
# For HTTP/WS
|
||||
./update-docker-env.sh your-server-hostname
|
||||
|
||||
# For HTTPS/WSS
|
||||
./update-docker-env.sh your-server-hostname true
|
||||
```
|
||||
|
||||
2. Pull and run the latest Docker images:
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. Access the application through your server's hostname:
|
||||
- Frontend: http://your-server-hostname:5000
|
||||
- Backend API: http://your-server-hostname:3000
|
||||
|
||||
4. **Video Guide**: For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Worklenz requires several environment variables to be configured for proper operation. These include:
|
||||
|
||||
- Database credentials
|
||||
- Session secrets
|
||||
- Storage configuration (S3 or Azure)
|
||||
- Authentication settings
|
||||
|
||||
Please refer to the `.env.example` files for a full list of required variables.
|
||||
|
||||
The Docker setup uses environment variables to configure the services:
|
||||
|
||||
- **Frontend:**
|
||||
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
|
||||
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
|
||||
|
||||
- **Backend:**
|
||||
- Database connection parameters
|
||||
- Storage configuration
|
||||
- Other backend settings
|
||||
|
||||
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.
|
||||
|
||||
## MinIO Integration
|
||||
|
||||
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
||||
|
||||
### Working with MinIO
|
||||
|
||||
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
|
||||
|
||||
- **MinIO Console**: http://localhost:9001
|
||||
- Username: minioadmin
|
||||
- Password: minioadmin
|
||||
|
||||
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
|
||||
|
||||
### Backend Storage Configuration
|
||||
|
||||
The backend is pre-configured to use MinIO with the following settings:
|
||||
|
||||
```javascript
|
||||
// S3 credentials with MinIO defaults
|
||||
export const REGION = process.env.AWS_REGION || "us-east-1";
|
||||
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
|
||||
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
|
||||
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
|
||||
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
For production deployments:
|
||||
|
||||
1. Use strong, unique passwords and keys for all services
|
||||
2. Do not commit `.env` files to version control
|
||||
3. Use a production-grade PostgreSQL setup with proper backup procedures
|
||||
4. Enable HTTPS for all public endpoints
|
||||
5. Review and update dependencies regularly
|
||||
|
||||
## Security
|
||||
|
||||
If you believe you have found a security vulnerability in Worklenz, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports.
|
||||
|
||||
Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vulnerabilities.
|
||||
|
||||
## Analytics
|
||||
|
||||
Worklenz uses Google Analytics to understand how the application is being used. This helps us improve the application and make better decisions about future development.
|
||||
|
||||
### What We Track
|
||||
- Anonymous usage statistics
|
||||
- Page views and navigation patterns
|
||||
- Feature usage
|
||||
- Browser and device information
|
||||
|
||||
### Privacy
|
||||
- Analytics is opt-in only
|
||||
- No personal information is collected
|
||||
- Users can opt-out at any time
|
||||
- Data is stored according to Google's privacy policy
|
||||
|
||||
### How to Opt-Out
|
||||
If you've previously opted in and want to opt-out:
|
||||
1. Clear your browser's local storage for the Worklenz domain
|
||||
2. Or click the "Decline" button in the analytics notice if it appears
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -102,19 +355,11 @@ This repository contains the frontend and backend code for Worklenz.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
### Contributing
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community! If you'd like to contribute, please follow
|
||||
our [contributing guidelines](CONTRIBUTING.md).
|
||||
We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
|
||||
|
||||
### Security
|
||||
|
||||
If you believe you have found a security vulnerability in Worklenz, we encourage you to responsibly disclose this and
|
||||
not open a public issue. We will investigate all legitimate reports.
|
||||
|
||||
Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vulnerabilities.
|
||||
|
||||
### License
|
||||
## License
|
||||
|
||||
Worklenz is open source and released under the [GNU Affero General Public License Version 3 (AGPLv3)](LICENSE).
|
||||
|
||||
|
||||
@@ -4,21 +4,20 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js version v18 or newer - [Node.js](https://nodejs.org/en/download/current)
|
||||
- Postgres version v15.6 - [PostgreSQL](https://www.postgresql.org/download/)
|
||||
- Redis version v4.6.7 (not used yet. setup only.)
|
||||
- Node.js version v20 or newer - [Node.js](https://nodejs.org/en/download/)
|
||||
- PostgreSQL version v15 or newer - [PostgreSQL](https://www.postgresql.org/download/)
|
||||
- S3-compatible storage (like MinIO) for file storage
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `$ npm install -g ts-node`
|
||||
- `$ npm install -g typescript`
|
||||
- `$ npm install -g grunt grunt-cli`
|
||||
- `$ npm install -g typescript` (optional, but recommended)
|
||||
|
||||
## Installation
|
||||
**Clone the repository:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Worklenz/worklenz.git
|
||||
cd worklenz
|
||||
```
|
||||
|
||||
### Frontend installation
|
||||
@@ -32,13 +31,14 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Run the frontend:**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
4. Navigate to [http://localhost:4200](http://localhost:4200)
|
||||
4. Navigate to [http://localhost:5173](http://localhost:5173) (development server)
|
||||
|
||||
### Backend installation
|
||||
|
||||
@@ -54,13 +54,34 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
|
||||
3. **Configure Environment Variables:**
|
||||
|
||||
- Create a copy of the `.env.template` file and name it `.env`.
|
||||
- Update the required fields in `.env` with the specific information.
|
||||
- Create a copy of the `.env.example` file and name it `.env`.
|
||||
- Update the required fields in `.env` with your specific configuration.
|
||||
|
||||
4. **Restore Database**
|
||||
4. **Set up Database**
|
||||
- Create a new database named `worklenz_db` on your local PostgreSQL server.
|
||||
- Update the `DATABASE_NAME` and `PASSWORD` in the `database/6_user_permission.sql` with your DB credentials.
|
||||
- Open a query console and execute the queries from the .sql files in the `database` directories, following the provided order.
|
||||
- Update the database connection details in your `.env` file.
|
||||
- Execute the SQL setup files in the correct order:
|
||||
|
||||
```bash
|
||||
# From your PostgreSQL client or command line
|
||||
psql -U your_username -d worklenz_db -f database/sql/0_extensions.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/1_tables.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/indexes.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/4_functions.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/triggers.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/3_views.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/2_dml.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/5_database_user.sql
|
||||
```
|
||||
|
||||
Alternatively, you can use the provided shell script:
|
||||
|
||||
```bash
|
||||
# Make sure the script is executable
|
||||
chmod +x database/00-init-db.sh
|
||||
# Run the script (may need modifications for local execution)
|
||||
./database/00-init-db.sh
|
||||
```
|
||||
|
||||
5. **Install Dependencies:**
|
||||
|
||||
@@ -68,48 +89,49 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
npm install
|
||||
```
|
||||
|
||||
This command installs all the necessary libraries required to run the project.
|
||||
|
||||
6. **Run the Development Server:**
|
||||
|
||||
**a. Start the TypeScript compiler:**
|
||||
|
||||
Open a new terminal window and run the following command:
|
||||
|
||||
```bash
|
||||
grunt dev
|
||||
```
|
||||
|
||||
This starts the `grunt` task runner, which compiles TypeScript code into JavaScript.
|
||||
|
||||
**b. Start the development server:**
|
||||
|
||||
Open another separate terminal window and run the following command:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts the development server allowing you to work on the project.
|
||||
|
||||
7. **Run the Production Server:**
|
||||
|
||||
**a. Compile TypeScript to JavaScript:**
|
||||
**a. Build the project:**
|
||||
|
||||
Open a new terminal window and run the following command:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
```bash
|
||||
grunt build
|
||||
```
|
||||
|
||||
This starts the `grunt` task runner, which compiles TypeScript code into JavaScript for production use.
|
||||
This will compile the TypeScript code into JavaScript for production use.
|
||||
|
||||
**b. Start the production server:**
|
||||
|
||||
Once the compilation is complete, run the following command in the same terminal window:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
## Docker Setup (Alternative)
|
||||
|
||||
This starts the production server for your application.
|
||||
For an easier setup, you can use Docker and Docker Compose:
|
||||
|
||||
1. Make sure you have Docker and Docker Compose installed on your system.
|
||||
|
||||
2. From the root directory, run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. Access the application:
|
||||
- Frontend: http://localhost:5000 (Docker production build)
|
||||
- Backend API: http://localhost:3000
|
||||
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
|
||||
|
||||
4. To stop the services:
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
16
backup.sh
Normal file
16
backup.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
# Adjust these as needed:
|
||||
CONTAINER=worklenz_db
|
||||
DB_NAME=worklenz_db
|
||||
DB_USER=postgres
|
||||
BACKUP_DIR=./pg_backups
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
timestamp=$(date +%Y-%m-%d_%H-%M-%S)
|
||||
outfile="${BACKUP_DIR}/${DB_NAME}_${timestamp}.sql"
|
||||
echo "Creating backup $outfile ..."
|
||||
|
||||
docker exec -t "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" > "$outfile"
|
||||
echo "Backup saved to $outfile"
|
||||
@@ -1,20 +1,20 @@
|
||||
services:
|
||||
frontend:
|
||||
image: ghcr.io/worklenz/worklenz-frontend
|
||||
build:
|
||||
context: ./worklenz-frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: worklenz_frontend
|
||||
ports:
|
||||
- "4200:4200"
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_started
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./worklenz-frontend/.env.production
|
||||
networks:
|
||||
- worklenz
|
||||
- worklenz
|
||||
|
||||
backend:
|
||||
image: ghcr.io/worklenz/worklenz-backend
|
||||
build:
|
||||
context: ./worklenz-backend
|
||||
dockerfile: Dockerfile
|
||||
@@ -24,60 +24,138 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- ANGULAR_DIST_DIR
|
||||
- ANGULAR_SRC_DIR
|
||||
- AWS_REGION
|
||||
- BACKEND_PUBLIC_DIR
|
||||
- BACKEND_VIEWS_DIR
|
||||
- COMMIT_BUILD_IMMEDIATELY
|
||||
- COOKIE_SECRET
|
||||
- DB_HOST
|
||||
- DB_MAX_CLIENTS
|
||||
- DB_NAME
|
||||
- DB_PASSWORD
|
||||
- DB_PORT
|
||||
- DB_USER
|
||||
- GOOGLE_CALLBACK_URL
|
||||
- GOOGLE_CLIENT_ID
|
||||
- GOOGLE_CLIENT_SECRET
|
||||
- HOSTNAME
|
||||
- LOGIN_FAILURE_REDIRECT
|
||||
- NODE_ENV
|
||||
- PORT
|
||||
- SESSION_NAME
|
||||
- SESSION_SECRET
|
||||
- SLACK_WEBHOOK
|
||||
- SOCKET_IO_CORS
|
||||
- SOURCE_EMAIL
|
||||
- USE_PG_NATIVE
|
||||
- BUCKET
|
||||
- REGION
|
||||
- S3_URL
|
||||
- S3_ACCESS_KEY_ID
|
||||
- S3_SECRET_ACCESS_KEY
|
||||
minio:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./worklenz-backend/.env
|
||||
networks:
|
||||
- worklenz
|
||||
- worklenz
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: worklenz_minio
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||
volumes:
|
||||
- worklenz_minio_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
networks:
|
||||
- worklenz
|
||||
|
||||
# MinIO setup helper - creates default bucket on startup
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
container_name: worklenz_createbuckets
|
||||
depends_on:
|
||||
- minio
|
||||
restart: on-failure
|
||||
entrypoint: >
|
||||
/bin/sh -c '
|
||||
echo "Waiting for MinIO to start...";
|
||||
sleep 15;
|
||||
for i in 1 2 3 4 5; do
|
||||
echo "Attempt $i to connect to MinIO...";
|
||||
if /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; then
|
||||
echo "Successfully connected to MinIO!";
|
||||
/usr/bin/mc mb --ignore-existing myminio/worklenz-bucket;
|
||||
/usr/bin/mc policy set public myminio/worklenz-bucket;
|
||||
exit 0;
|
||||
fi
|
||||
echo "Connection failed, retrying in 5 seconds...";
|
||||
sleep 5;
|
||||
done;
|
||||
echo "Failed to connect to MinIO after 5 attempts";
|
||||
exit 1;
|
||||
'
|
||||
networks:
|
||||
- worklenz
|
||||
db:
|
||||
image: postgres:15
|
||||
container_name: worklenz_db
|
||||
environment:
|
||||
POSTGRES_DB: "${DB_NAME}"
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_DB: ${DB_NAME:-worklenz_db}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d ${DB_NAME} -U ${DB_USER}"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- worklenz
|
||||
- worklenz
|
||||
volumes:
|
||||
- worklenz_postgres_data:/var/lib/postgresql/data
|
||||
- ./worklenz-backend/database/:/docker-entrypoint-initdb.d
|
||||
- type: bind
|
||||
source: ./worklenz-backend/database/sql
|
||||
target: /docker-entrypoint-initdb.d/sql
|
||||
consistency: cached
|
||||
- type: bind
|
||||
source: ./worklenz-backend/database/migrations
|
||||
target: /docker-entrypoint-initdb.d/migrations
|
||||
consistency: cached
|
||||
- type: bind
|
||||
source: ./worklenz-backend/database/00_init.sh
|
||||
target: /docker-entrypoint-initdb.d/00_init.sh
|
||||
consistency: cached
|
||||
- type: bind
|
||||
source: ./pg_backups
|
||||
target: /docker-entrypoint-initdb.d/pg_backups
|
||||
command: >
|
||||
bash -c '
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y dos2unix
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache dos2unix
|
||||
fi
|
||||
|
||||
find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '"'"'
|
||||
for f; do
|
||||
dos2unix "$f" 2>/dev/null || true
|
||||
chmod +x "$f"
|
||||
done
|
||||
'"'"' sh {} +
|
||||
|
||||
exec docker-entrypoint.sh postgres
|
||||
'
|
||||
db-backup:
|
||||
image: postgres:15
|
||||
container_name: worklenz_db_backup
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_DB: ${DB_NAME:-worklenz_db}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./pg_backups:/pg_backups #host dir for backups files
|
||||
#setup bassh loop to backup data evey 24h
|
||||
command: >
|
||||
bash -c 'while true; do
|
||||
sleep 86400;
|
||||
PGPASSWORD=$$POSTGRES_PASSWORD pg_dump -h worklenz_db -U $$POSTGRES_USER -d $$POSTGRES_DB \
|
||||
> /pg_backups/worklenz_db_$$(date +%Y-%m-%d_%H-%M-%S).sql;
|
||||
find /pg_backups -type f -name "*.sql" -mtime +30 -delete;
|
||||
done'
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- worklenz
|
||||
|
||||
volumes:
|
||||
worklenz_postgres_data:
|
||||
worklenz_minio_data:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
worklenz:
|
||||
worklenz:
|
||||
|
||||
429
docs/enhanced-task-management-technical-guide.md
Normal file
429
docs/enhanced-task-management-technical-guide.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Enhanced Task Management: Technical Guide
|
||||
|
||||
## Overview
|
||||
The Enhanced Task Management system is a comprehensive React-based interface built on top of WorkLenz's existing task infrastructure. It provides a modern, grouped view with drag-and-drop functionality, bulk operations, and responsive design.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Structure
|
||||
```
|
||||
src/components/task-management/
|
||||
├── TaskListBoard.tsx # Main container with DnD context
|
||||
├── TaskGroup.tsx # Individual group with collapse/expand
|
||||
├── TaskRow.tsx # Task display with rich metadata
|
||||
├── GroupingSelector.tsx # Grouping method switcher
|
||||
└── BulkActionBar.tsx # Bulk operations toolbar
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
The system integrates with existing WorkLenz infrastructure:
|
||||
|
||||
- **Redux Store:** Uses `tasks.slice.ts` for state management
|
||||
- **Types:** Leverages existing TypeScript interfaces
|
||||
- **API Services:** Works with existing task API endpoints
|
||||
- **WebSocket:** Supports real-time updates via existing socket system
|
||||
|
||||
## Core Components
|
||||
|
||||
### TaskListBoard.tsx
|
||||
Main orchestrator component that provides:
|
||||
|
||||
- **DnD Context:** @dnd-kit drag-and-drop functionality
|
||||
- **State Management:** Redux integration for task data
|
||||
- **Event Handling:** Drag events and bulk operations
|
||||
- **Layout Structure:** Header controls and group container
|
||||
|
||||
#### Key Props
|
||||
```typescript
|
||||
interface TaskListBoardProps {
|
||||
projectId: string; // Required: Project identifier
|
||||
className?: string; // Optional: Additional CSS classes
|
||||
}
|
||||
```
|
||||
|
||||
#### Redux Selectors Used
|
||||
```typescript
|
||||
const {
|
||||
taskGroups, // ITaskListGroup[] - Grouped task data
|
||||
loadingGroups, // boolean - Loading state
|
||||
error, // string | null - Error state
|
||||
groupBy, // IGroupBy - Current grouping method
|
||||
search, // string | null - Search filter
|
||||
archived, // boolean - Show archived tasks
|
||||
} = useSelector((state: RootState) => state.taskReducer);
|
||||
```
|
||||
|
||||
### TaskGroup.tsx
|
||||
Renders individual task groups with:
|
||||
|
||||
- **Collapsible Headers:** Expand/collapse functionality
|
||||
- **Progress Indicators:** Visual completion progress
|
||||
- **Drop Zones:** Accept dropped tasks from other groups
|
||||
- **Group Statistics:** Task counts and completion rates
|
||||
|
||||
#### Key Props
|
||||
```typescript
|
||||
interface TaskGroupProps {
|
||||
group: ITaskListGroup; // Group data with tasks
|
||||
projectId: string; // Project context
|
||||
currentGrouping: IGroupBy; // Current grouping mode
|
||||
selectedTaskIds: string[]; // Selected task IDs
|
||||
onAddTask?: (groupId: string) => void;
|
||||
onToggleCollapse?: (groupId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### TaskRow.tsx
|
||||
Individual task display featuring:
|
||||
|
||||
- **Rich Metadata:** Progress, assignees, labels, due dates
|
||||
- **Drag Handles:** Sortable within and between groups
|
||||
- **Selection:** Multi-select with checkboxes
|
||||
- **Subtask Support:** Expandable hierarchy display
|
||||
|
||||
#### Key Props
|
||||
```typescript
|
||||
interface TaskRowProps {
|
||||
task: IProjectTask; // Task data
|
||||
projectId: string; // Project context
|
||||
groupId: string; // Parent group ID
|
||||
currentGrouping: IGroupBy; // Current grouping mode
|
||||
isSelected: boolean; // Selection state
|
||||
isDragOverlay?: boolean; // Drag overlay rendering
|
||||
index?: number; // Position in group
|
||||
onSelect?: (taskId: string, selected: boolean) => void;
|
||||
onToggleSubtasks?: (taskId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Redux Integration
|
||||
The system uses existing WorkLenz Redux patterns:
|
||||
|
||||
```typescript
|
||||
// Primary slice used
|
||||
import {
|
||||
fetchTaskGroups, // Async thunk for loading data
|
||||
reorderTasks, // Update task order/group
|
||||
setGroup, // Change grouping method
|
||||
updateTaskStatus, // Update individual task status
|
||||
updateTaskPriority, // Update individual task priority
|
||||
// ... other existing actions
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
1. **Component Mount:** `TaskListBoard` dispatches `fetchTaskGroups(projectId)`
|
||||
2. **Group Changes:** `setGroup(newGroupBy)` triggers data reorganization
|
||||
3. **Drag Operations:** `reorderTasks()` updates task positions and properties
|
||||
4. **Real-time Updates:** WebSocket events update Redux state automatically
|
||||
|
||||
## Drag and Drop Implementation
|
||||
|
||||
### DnD Kit Integration
|
||||
Uses @dnd-kit for modern, accessible drag-and-drop:
|
||||
|
||||
```typescript
|
||||
// Sensors for different input methods
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 }
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Drag Event Handling
|
||||
```typescript
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
// Determine source and target
|
||||
const sourceGroup = findTaskGroup(active.id);
|
||||
const targetGroup = findTargetGroup(over?.id);
|
||||
|
||||
// Update task arrays and dispatch changes
|
||||
dispatch(reorderTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: sourceIndex,
|
||||
toIndex: targetIndex,
|
||||
task: movedTask,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
### Smart Property Updates
|
||||
When tasks are moved between groups, properties update automatically:
|
||||
|
||||
- **Status Grouping:** Moving to "Done" group sets task status to "done"
|
||||
- **Priority Grouping:** Moving to "High" group sets task priority to "high"
|
||||
- **Phase Grouping:** Moving to "Testing" group sets task phase to "testing"
|
||||
|
||||
## Bulk Operations
|
||||
|
||||
### Selection State Management
|
||||
```typescript
|
||||
// Local state for task selection
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
|
||||
|
||||
// Selection handlers
|
||||
const handleTaskSelect = (taskId: string, selected: boolean) => {
|
||||
if (selected) {
|
||||
setSelectedTaskIds(prev => [...prev, taskId]);
|
||||
} else {
|
||||
setSelectedTaskIds(prev => prev.filter(id => id !== taskId));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Context-Aware Actions
|
||||
Bulk actions adapt to current grouping:
|
||||
|
||||
```typescript
|
||||
// Only show status changes when not grouped by status
|
||||
{currentGrouping !== 'status' && (
|
||||
<Dropdown overlay={statusMenu}>
|
||||
<Button>Change Status</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Memoized Selectors
|
||||
```typescript
|
||||
// Expensive group calculations are memoized
|
||||
const taskGroups = useMemo(() => {
|
||||
return createGroupsFromTasks(tasks, currentGrouping);
|
||||
}, [tasks, currentGrouping]);
|
||||
```
|
||||
|
||||
### Virtual Scrolling Ready
|
||||
For large datasets, the system is prepared for react-window integration:
|
||||
|
||||
```typescript
|
||||
// Large group detection
|
||||
const shouldVirtualize = group.tasks.length > 100;
|
||||
|
||||
return shouldVirtualize ? (
|
||||
<VirtualizedTaskList tasks={group.tasks} />
|
||||
) : (
|
||||
<StandardTaskList tasks={group.tasks} />
|
||||
);
|
||||
```
|
||||
|
||||
### Optimistic Updates
|
||||
UI updates immediately while API calls process in background:
|
||||
|
||||
```typescript
|
||||
// Immediate UI update
|
||||
dispatch(updateTaskStatusOptimistically(taskId, newStatus));
|
||||
|
||||
// API call with rollback on error
|
||||
try {
|
||||
await updateTaskStatus(taskId, newStatus);
|
||||
} catch (error) {
|
||||
dispatch(rollbackTaskStatusUpdate(taskId));
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Breakpoint Strategy
|
||||
```css
|
||||
/* Mobile-first responsive design */
|
||||
.task-row {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.task-row {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.task-row {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Progressive Enhancement
|
||||
- **Mobile:** Essential information only
|
||||
- **Tablet:** Additional metadata visible
|
||||
- **Desktop:** Full feature set with optimal layout
|
||||
|
||||
## Accessibility
|
||||
|
||||
### ARIA Implementation
|
||||
```typescript
|
||||
// Proper ARIA labels for screen readers
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Move task ${task.name}`}
|
||||
tabIndex={0}
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<DragOutlined />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
- **Tab:** Navigate between elements
|
||||
- **Space:** Select/deselect tasks
|
||||
- **Enter:** Activate buttons
|
||||
- **Arrows:** Navigate sortable lists with keyboard sensor
|
||||
|
||||
### Focus Management
|
||||
```typescript
|
||||
// Maintain focus during dynamic updates
|
||||
useEffect(() => {
|
||||
if (shouldFocusTask) {
|
||||
taskRef.current?.focus();
|
||||
}
|
||||
}, [taskGroups]);
|
||||
```
|
||||
|
||||
## WebSocket Integration
|
||||
|
||||
### Real-time Updates
|
||||
The system subscribes to existing WorkLenz WebSocket events:
|
||||
|
||||
```typescript
|
||||
// Socket event handlers (existing WorkLenz patterns)
|
||||
socket.on('TASK_STATUS_CHANGED', (data) => {
|
||||
dispatch(updateTaskStatus(data));
|
||||
});
|
||||
|
||||
socket.on('TASK_PROGRESS_UPDATED', (data) => {
|
||||
dispatch(updateTaskProgress(data));
|
||||
});
|
||||
```
|
||||
|
||||
### Live Collaboration
|
||||
- Multiple users can work simultaneously
|
||||
- Changes appear in real-time
|
||||
- Conflict resolution through server-side validation
|
||||
|
||||
## API Integration
|
||||
|
||||
### Existing Endpoints Used
|
||||
```typescript
|
||||
// Uses existing WorkLenz API services
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
|
||||
// Task data fetching
|
||||
tasksApiService.getTaskList(config);
|
||||
|
||||
// Task updates
|
||||
tasksApiService.updateTask(taskId, changes);
|
||||
|
||||
// Bulk operations
|
||||
tasksApiService.bulkUpdateTasks(taskIds, changes);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
```typescript
|
||||
try {
|
||||
await dispatch(fetchTaskGroups(projectId));
|
||||
} catch (error) {
|
||||
// Display user-friendly error message
|
||||
message.error('Failed to load tasks. Please try again.');
|
||||
logger.error('Task loading error:', error);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Component Testing
|
||||
```typescript
|
||||
// Example test structure
|
||||
describe('TaskListBoard', () => {
|
||||
it('should render task groups correctly', () => {
|
||||
const mockTasks = generateMockTasks(10);
|
||||
render(<TaskListBoard projectId="test-project" />);
|
||||
|
||||
expect(screen.getByText('Tasks (10)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle drag and drop operations', async () => {
|
||||
// Test drag and drop functionality
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
- Redux state management
|
||||
- API service integration
|
||||
- WebSocket event handling
|
||||
- Drag and drop operations
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Organization
|
||||
- Follow existing WorkLenz patterns
|
||||
- Use TypeScript strictly
|
||||
- Implement proper error boundaries
|
||||
- Maintain accessibility standards
|
||||
|
||||
### Performance Considerations
|
||||
- Memoize expensive calculations
|
||||
- Implement virtual scrolling for large datasets
|
||||
- Debounce user input operations
|
||||
- Optimize re-render cycles
|
||||
|
||||
### Styling Standards
|
||||
- Use existing Ant Design components
|
||||
- Follow WorkLenz design system
|
||||
- Implement responsive breakpoints
|
||||
- Maintain dark mode compatibility
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
- Custom column integration
|
||||
- Advanced filtering capabilities
|
||||
- Kanban board view
|
||||
- Enhanced time tracking
|
||||
- Task templates
|
||||
|
||||
### Extension Points
|
||||
The system is designed for easy extension:
|
||||
|
||||
```typescript
|
||||
// Plugin architecture ready
|
||||
interface TaskViewPlugin {
|
||||
name: string;
|
||||
component: React.ComponentType;
|
||||
supportedGroupings: IGroupBy[];
|
||||
}
|
||||
|
||||
const plugins: TaskViewPlugin[] = [
|
||||
{ name: 'kanban', component: KanbanView, supportedGroupings: ['status'] },
|
||||
{ name: 'timeline', component: TimelineView, supportedGroupings: ['phase'] },
|
||||
];
|
||||
```
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Bundle Size
|
||||
- Tree-shake unused dependencies
|
||||
- Code-split large components
|
||||
- Optimize asset loading
|
||||
|
||||
### Browser Compatibility
|
||||
- Modern browsers (ES2020+)
|
||||
- Graceful degradation for older browsers
|
||||
- Progressive enhancement approach
|
||||
|
||||
### Performance Monitoring
|
||||
- Track component render times
|
||||
- Monitor API response times
|
||||
- Measure user interaction latency
|
||||
275
docs/enhanced-task-management-user-guide.md
Normal file
275
docs/enhanced-task-management-user-guide.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Enhanced Task Management: User Guide
|
||||
|
||||
## What Is Enhanced Task Management?
|
||||
The Enhanced Task Management system provides a modern, grouped view of your tasks with advanced features like drag-and-drop, bulk operations, and dynamic grouping. This system builds on WorkLenz's existing task infrastructure while offering improved productivity and organization tools.
|
||||
|
||||
## Why Use Enhanced Task Management?
|
||||
- **Better Organization:** Group tasks by Status, Priority, or Phase for clearer project overview
|
||||
- **Increased Productivity:** Bulk operations let you update multiple tasks at once
|
||||
- **Intuitive Interface:** Drag-and-drop functionality makes task management feel natural
|
||||
- **Rich Task Display:** See progress, assignees, labels, and due dates at a glance
|
||||
- **Responsive Design:** Works seamlessly on desktop, tablet, and mobile devices
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Accessing Enhanced Task Management
|
||||
1. Navigate to your project workspace
|
||||
2. Look for the enhanced task view option in your project interface
|
||||
3. The system will display your tasks grouped by the current grouping method (default: Status)
|
||||
|
||||
### Understanding the Interface
|
||||
The enhanced task management interface consists of several key areas:
|
||||
|
||||
- **Header Controls:** Task count, grouping selector, and action buttons
|
||||
- **Task Groups:** Collapsible sections containing related tasks
|
||||
- **Individual Tasks:** Rich task cards with metadata and actions
|
||||
- **Bulk Action Bar:** Appears when multiple tasks are selected (blue bar)
|
||||
|
||||
## Task Grouping
|
||||
|
||||
### Available Grouping Options
|
||||
You can organize your tasks using three different grouping methods:
|
||||
|
||||
#### 1. Status Grouping (Default)
|
||||
Groups tasks by their current status:
|
||||
- **To Do:** Tasks not yet started
|
||||
- **Doing:** Tasks currently in progress
|
||||
- **Done:** Completed tasks
|
||||
|
||||
#### 2. Priority Grouping
|
||||
Groups tasks by their priority level:
|
||||
- **Critical:** Highest priority, urgent tasks
|
||||
- **High:** Important tasks requiring attention
|
||||
- **Medium:** Standard priority tasks
|
||||
- **Low:** Tasks that can be addressed later
|
||||
|
||||
#### 3. Phase Grouping
|
||||
Groups tasks by project phases:
|
||||
- **Planning:** Tasks in the planning stage
|
||||
- **Development:** Implementation and development tasks
|
||||
- **Testing:** Quality assurance and testing tasks
|
||||
- **Deployment:** Release and deployment tasks
|
||||
|
||||
### Switching Between Groupings
|
||||
1. Locate the "Group by" dropdown in the header controls
|
||||
2. Select your preferred grouping method (Status, Priority, or Phase)
|
||||
3. Tasks will automatically reorganize into the new groups
|
||||
4. Your grouping preference is saved for future sessions
|
||||
|
||||
### Group Features
|
||||
Each task group includes:
|
||||
- **Color-coded headers** with visual indicators
|
||||
- **Task count badges** showing the number of tasks in each group
|
||||
- **Progress indicators** showing completion percentage
|
||||
- **Collapse/expand functionality** to hide or show group contents
|
||||
- **Add task buttons** to quickly create tasks in specific groups
|
||||
|
||||
## Drag and Drop
|
||||
|
||||
### Moving Tasks Within Groups
|
||||
1. Hover over a task to reveal the drag handle (⋮⋮ icon)
|
||||
2. Click and hold the drag handle
|
||||
3. Drag the task to your desired position within the same group
|
||||
4. Release to drop the task in its new position
|
||||
|
||||
### Moving Tasks Between Groups
|
||||
1. Click and hold the drag handle on any task
|
||||
2. Drag the task over a different group
|
||||
3. The target group will highlight to show it can accept the task
|
||||
4. Release to drop the task into the new group
|
||||
5. The task's properties (status, priority, or phase) will automatically update
|
||||
|
||||
### Drag and Drop Benefits
|
||||
- **Instant Updates:** Task properties change automatically when moved between groups
|
||||
- **Visual Feedback:** Clear indicators show where tasks can be dropped
|
||||
- **Keyboard Accessible:** Alternative keyboard controls for accessibility
|
||||
- **Mobile Friendly:** Touch-friendly drag operations on mobile devices
|
||||
|
||||
## Multi-Select and Bulk Operations
|
||||
|
||||
### Selecting Tasks
|
||||
You can select multiple tasks using several methods:
|
||||
|
||||
#### Individual Selection
|
||||
- Click the checkbox next to any task to select it
|
||||
- Click again to deselect
|
||||
|
||||
#### Range Selection
|
||||
- Select the first task in your desired range
|
||||
- Hold Shift and click the last task in the range
|
||||
- All tasks between the first and last will be selected
|
||||
|
||||
#### Multiple Selection
|
||||
- Hold Ctrl (or Cmd on Mac) while clicking tasks
|
||||
- This allows you to select non-consecutive tasks
|
||||
|
||||
### Bulk Actions
|
||||
When you have tasks selected, a blue bulk action bar appears with these options:
|
||||
|
||||
#### Change Status (when not grouped by Status)
|
||||
- Update the status of all selected tasks at once
|
||||
- Choose from available status options in your project
|
||||
|
||||
#### Set Priority (when not grouped by Priority)
|
||||
- Assign the same priority level to all selected tasks
|
||||
- Options include Critical, High, Medium, and Low
|
||||
|
||||
#### More Actions
|
||||
Additional bulk operations include:
|
||||
- **Assign to Member:** Add team members to multiple tasks
|
||||
- **Add Labels:** Apply labels to selected tasks
|
||||
- **Archive Tasks:** Move multiple tasks to archive
|
||||
|
||||
#### Delete Tasks
|
||||
- Permanently remove multiple tasks at once
|
||||
- Confirmation dialog prevents accidental deletions
|
||||
|
||||
### Bulk Action Tips
|
||||
- The bulk action bar only shows relevant options based on your current grouping
|
||||
- You can clear your selection at any time using the "Clear" button
|
||||
- Bulk operations provide immediate feedback and can be undone if needed
|
||||
|
||||
## Task Display Features
|
||||
|
||||
### Rich Task Information
|
||||
Each task displays comprehensive information:
|
||||
|
||||
#### Basic Information
|
||||
- **Task Key:** Unique identifier (e.g., PROJ-123)
|
||||
- **Task Name:** Clear, descriptive title
|
||||
- **Description:** Additional details when available
|
||||
|
||||
#### Visual Indicators
|
||||
- **Progress Bar:** Shows completion percentage (0-100%)
|
||||
- **Priority Indicator:** Color-coded dot showing task importance
|
||||
- **Status Color:** Left border color indicates current status
|
||||
|
||||
#### Team and Collaboration
|
||||
- **Assignee Avatars:** Profile pictures of assigned team members (up to 3 visible)
|
||||
- **Labels:** Color-coded tags for categorization
|
||||
- **Comment Count:** Number of comments and discussions
|
||||
- **Attachment Count:** Number of files attached to the task
|
||||
|
||||
#### Timing Information
|
||||
- **Due Dates:** When tasks are scheduled to complete
|
||||
- Red text: Overdue tasks
|
||||
- Orange text: Due today or within 3 days
|
||||
- Gray text: Future due dates
|
||||
- **Time Tracking:** Estimated vs. logged time when available
|
||||
|
||||
### Subtask Support
|
||||
Tasks with subtasks include additional features:
|
||||
|
||||
#### Expanding Subtasks
|
||||
- Click the "+X" button next to task names to expand subtasks
|
||||
- Subtasks appear indented below the parent task
|
||||
- Click "−X" to collapse subtasks
|
||||
|
||||
#### Subtask Progress
|
||||
- Parent task progress reflects completion of all subtasks
|
||||
- Individual subtask progress is visible when expanded
|
||||
- Subtask counts show total number of child tasks
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Real-time Updates
|
||||
- Changes made by team members appear instantly
|
||||
- Live collaboration with multiple users
|
||||
- WebSocket connections ensure data synchronization
|
||||
|
||||
### Search and Filtering
|
||||
- Use existing project search and filter capabilities
|
||||
- Enhanced task management respects current filter settings
|
||||
- Search results maintain grouping organization
|
||||
|
||||
### Responsive Design
|
||||
The interface adapts to different screen sizes:
|
||||
|
||||
#### Desktop (Large Screens)
|
||||
- Full feature set with all metadata visible
|
||||
- Optimal drag-and-drop experience
|
||||
- Multi-column layouts where appropriate
|
||||
|
||||
#### Tablet (Medium Screens)
|
||||
- Condensed but functional interface
|
||||
- Touch-friendly interactions
|
||||
- Simplified metadata display
|
||||
|
||||
#### Mobile (Small Screens)
|
||||
- Stacked layout for easy navigation
|
||||
- Large touch targets for selections
|
||||
- Essential information prioritized
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Organizing Your Tasks
|
||||
1. **Choose the Right Grouping:** Select the grouping method that best fits your workflow
|
||||
2. **Use Labels Consistently:** Apply meaningful labels for better categorization
|
||||
3. **Keep Groups Balanced:** Avoid having too many tasks in a single group
|
||||
4. **Regular Maintenance:** Review and update task organization periodically
|
||||
|
||||
### Collaboration Tips
|
||||
1. **Clear Task Names:** Use descriptive titles that everyone understands
|
||||
2. **Proper Assignment:** Assign tasks to appropriate team members
|
||||
3. **Progress Updates:** Keep progress percentages current for accurate project tracking
|
||||
4. **Use Comments:** Communicate about tasks using the comment system
|
||||
|
||||
### Productivity Techniques
|
||||
1. **Batch Similar Operations:** Use bulk actions for efficiency
|
||||
2. **Prioritize Effectively:** Use priority grouping during planning phases
|
||||
3. **Track Progress:** Monitor completion rates using group progress indicators
|
||||
4. **Plan Ahead:** Use due dates and time estimates for better scheduling
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Navigation
|
||||
- **Tab:** Move focus between elements
|
||||
- **Enter:** Activate focused button or link
|
||||
- **Esc:** Close open dialogs or clear selections
|
||||
|
||||
### Selection
|
||||
- **Space:** Select/deselect focused task
|
||||
- **Shift + Click:** Range selection
|
||||
- **Ctrl + Click:** Multi-selection (Cmd + Click on Mac)
|
||||
|
||||
### Actions
|
||||
- **Delete:** Remove selected tasks (with confirmation)
|
||||
- **Ctrl + A:** Select all visible tasks (Cmd + A on Mac)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Tasks Not Moving Between Groups
|
||||
- Ensure you have edit permissions for the tasks
|
||||
- Check that you're dragging from the drag handle (⋮⋮ icon)
|
||||
- Verify the target group allows the task type
|
||||
|
||||
#### Bulk Actions Not Working
|
||||
- Confirm tasks are actually selected (checkboxes checked)
|
||||
- Ensure you have appropriate permissions
|
||||
- Check that the action is available for your current grouping
|
||||
|
||||
#### Missing Task Information
|
||||
- Some metadata may be hidden on smaller screens
|
||||
- Try expanding to full screen or using desktop view
|
||||
- Check that task has the required information (assignees, labels, etc.)
|
||||
|
||||
### Performance Tips
|
||||
- For projects with hundreds of tasks, consider using filters to reduce visible tasks
|
||||
- Collapse groups you're not actively working with
|
||||
- Clear selections when not performing bulk operations
|
||||
|
||||
## Getting Help
|
||||
- Contact your workspace administrator for permission-related issues
|
||||
- Check the main WorkLenz documentation for general task management help
|
||||
- Report bugs or feature requests through your organization's support channels
|
||||
|
||||
## What's New
|
||||
This enhanced task management system builds on WorkLenz's solid foundation while adding:
|
||||
- Modern drag-and-drop interfaces
|
||||
- Flexible grouping options
|
||||
- Powerful bulk operation capabilities
|
||||
- Rich visual task displays
|
||||
- Mobile-responsive design
|
||||
- Improved accessibility features
|
||||
60
docs/recurring-tasks-user-guide.md
Normal file
60
docs/recurring-tasks-user-guide.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Recurring Tasks: User Guide
|
||||
|
||||
## What Are Recurring Tasks?
|
||||
Recurring tasks are tasks that repeat automatically on a schedule you choose. This helps you save time and ensures important work is never forgotten. For example, you can set up a recurring task for weekly team meetings, monthly reports, or daily check-ins.
|
||||
|
||||
## Why Use Recurring Tasks?
|
||||
- **Save time:** No need to create the same task over and over.
|
||||
- **Stay organized:** Tasks appear automatically when needed.
|
||||
- **Never miss a deadline:** Tasks are created on time, every time.
|
||||
|
||||
## How to Set Up a Recurring Task
|
||||
1. Go to the tasks section in your workspace.
|
||||
2. Choose to create a new task and look for the option to make it recurring.
|
||||
3. Fill in the task details (name, description, assignees, etc.).
|
||||
4. Select your preferred schedule (see options below).
|
||||
5. Save the task. It will now be created automatically based on your chosen schedule.
|
||||
|
||||
## Schedule Options
|
||||
You can choose how often your task repeats. Here are the available options:
|
||||
|
||||
- **Daily:** The task is created every day.
|
||||
- **Weekly:** The task is created once a week. You can pick one or more days (e.g., every Monday and Thursday).
|
||||
- **Monthly:** The task is created once a month. You have two options:
|
||||
- **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months)
|
||||
- **On a specific day:** Choose a week (first, second, third, fourth, or last) and a day of the week
|
||||
- **Every X Days:** The task is created every specified number of days (e.g., every 3 days)
|
||||
- **Every X Weeks:** The task is created every specified number of weeks (e.g., every 2 weeks)
|
||||
- **Every X Months:** The task is created every specified number of months (e.g., every 3 months)
|
||||
|
||||
### Examples
|
||||
- "Send team update" every Friday (weekly)
|
||||
- "Submit expense report" on the 15th of each month (monthly, specific date)
|
||||
- "Monthly team meeting" on the first Monday of each month (monthly, specific day)
|
||||
- "Check backups" every day (daily)
|
||||
- "Review project status" every Monday and Thursday (weekly, multiple days)
|
||||
- "Quarterly report" every 3 months (every X months)
|
||||
|
||||
## Future Task Creation
|
||||
The system automatically creates tasks up to a certain point in the future to ensure timely scheduling:
|
||||
|
||||
- **Daily Tasks:** Created up to 7 days in advance
|
||||
- **Weekly Tasks:** Created up to 2 weeks in advance
|
||||
- **Monthly Tasks:** Created up to 2 months in advance
|
||||
- **Every X Days/Weeks/Months:** Created up to 2 intervals in advance
|
||||
|
||||
This ensures that:
|
||||
- You always have upcoming tasks visible in your schedule
|
||||
- Tasks are created at appropriate intervals
|
||||
- The system maintains a reasonable number of future tasks
|
||||
|
||||
## Tips
|
||||
- You can edit or stop a recurring task at any time.
|
||||
- Assign team members and labels to recurring tasks for better organization.
|
||||
- Check your task list regularly to see newly created recurring tasks.
|
||||
- For monthly tasks, dates are limited to 1-28 to ensure the task occurs on the same date every month.
|
||||
- Tasks are created automatically within the future limit window - you don't need to manually create them.
|
||||
- If you need to see tasks further in the future, they will be created automatically as the current tasks are completed.
|
||||
|
||||
## Need Help?
|
||||
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.
|
||||
104
docs/recurring-tasks.md
Normal file
104
docs/recurring-tasks.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Recurring Tasks Cron Job Documentation
|
||||
|
||||
## Overview
|
||||
The recurring tasks cron job automates the creation of tasks based on predefined templates and schedules. It ensures that tasks are generated at the correct intervals without manual intervention, supporting efficient project management and timely task assignment.
|
||||
|
||||
## Purpose
|
||||
- Automatically create tasks according to recurring schedules defined in the database.
|
||||
- Prevent duplicate task creation for the same schedule and date.
|
||||
- Assign team members and labels to newly created tasks as specified in the template.
|
||||
|
||||
## Scheduling Logic
|
||||
- The cron job is scheduled using the [cron](https://www.npmjs.com/package/cron) package.
|
||||
- The schedule is defined by a cron expression (e.g., `*/2 * * * *` for every 2 minutes, or `0 11 */1 * 1-5` for 11:00 UTC on weekdays).
|
||||
- On each tick, the job:
|
||||
1. Fetches all recurring task templates and their schedules.
|
||||
2. Determines the next occurrence for each template using `calculateNextEndDate`.
|
||||
3. Checks if a task for the next occurrence already exists.
|
||||
4. Creates a new task if it does not exist and the next occurrence is within the allowed future window.
|
||||
|
||||
## Future Limit Logic
|
||||
The system implements different future limits based on the schedule type to maintain an appropriate number of future tasks:
|
||||
|
||||
```typescript
|
||||
const FUTURE_LIMITS = {
|
||||
daily: moment.duration(7, 'days'),
|
||||
weekly: moment.duration(2, 'weeks'),
|
||||
monthly: moment.duration(2, 'months'),
|
||||
every_x_days: (interval: number) => moment.duration(interval * 2, 'days'),
|
||||
every_x_weeks: (interval: number) => moment.duration(interval * 2, 'weeks'),
|
||||
every_x_months: (interval: number) => moment.duration(interval * 2, 'months')
|
||||
};
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
- **Base Calculation:**
|
||||
```typescript
|
||||
const futureLimit = moment(template.last_checked_at || template.created_at)
|
||||
.add(getFutureLimit(schedule.schedule_type, schedule.interval), 'days');
|
||||
```
|
||||
|
||||
- **Task Creation Rules:**
|
||||
1. Only create tasks if the next occurrence is before the future limit
|
||||
2. Skip creation if a task already exists for that date
|
||||
3. Update `last_checked_at` after processing
|
||||
|
||||
- **Benefits:**
|
||||
- Prevents excessive task creation
|
||||
- Maintains system performance
|
||||
- Ensures timely task visibility
|
||||
- Allows for schedule modifications
|
||||
|
||||
## Date Handling
|
||||
- **Monthly Tasks:**
|
||||
- Dates are limited to 1-28 to ensure consistency across all months
|
||||
- This prevents issues with months having different numbers of days
|
||||
- No special handling needed for February or months with 30/31 days
|
||||
- **Weekly Tasks:**
|
||||
- Supports multiple days of the week (0-6, where 0 is Sunday)
|
||||
- Tasks are created for each selected day
|
||||
- **Interval-based Tasks:**
|
||||
- Every X days/weeks/months from the last task's end date
|
||||
- Minimum interval is 1 day/week/month
|
||||
- No maximum limit, but tasks are only created up to the future limit
|
||||
|
||||
## Database Interactions
|
||||
- **Templates and Schedules:**
|
||||
- Templates are stored in `task_recurring_templates`.
|
||||
- Schedules are stored in `task_recurring_schedules`.
|
||||
- The job joins these tables to get all necessary data for task creation.
|
||||
- **Task Creation:**
|
||||
- Uses a stored procedure `create_quick_task` to insert new tasks.
|
||||
- Assigns team members and labels by calling appropriate functions/controllers.
|
||||
- **State Tracking:**
|
||||
- Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing.
|
||||
- Maintains future limits based on schedule type.
|
||||
|
||||
## Task Creation Process
|
||||
1. **Fetch Templates:** Retrieve all templates and their associated schedules.
|
||||
2. **Determine Next Occurrence:** Use the last task's end date or the schedule's creation date to calculate the next due date.
|
||||
3. **Check for Existing Task:** Ensure no duplicate task is created for the same schedule and date.
|
||||
4. **Create Task:**
|
||||
- Insert the new task using the template's data.
|
||||
- Assign team members and labels as specified.
|
||||
5. **Update Schedule:** Record the last checked and created dates for accurate future runs.
|
||||
|
||||
## Configuration & Extension Points
|
||||
- **Cron Expression:** Modify the `TIME` constant in the code to change the schedule.
|
||||
- **Task Template Structure:** Extend the template or schedule interfaces to support additional fields.
|
||||
- **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed.
|
||||
- **Future Window:** Adjust the future limits by modifying the `FUTURE_LIMITS` configuration.
|
||||
|
||||
## Error Handling
|
||||
- Errors are logged using the `log_error` utility.
|
||||
- The job continues processing other templates even if one fails.
|
||||
- Failed task creations are not retried automatically.
|
||||
|
||||
## References
|
||||
- Source: `src/cron_jobs/recurring-tasks.ts`
|
||||
- Utilities: `src/shared/utils.ts`
|
||||
- Database: `src/config/db.ts`
|
||||
- Controllers: `src/controllers/tasks-controller.ts`
|
||||
|
||||
---
|
||||
For further customization or troubleshooting, refer to the source code and update the documentation as needed.
|
||||
223
docs/task-progress-guide-for-users.md
Normal file
223
docs/task-progress-guide-for-users.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# WorkLenz Task Progress Guide for Users
|
||||
|
||||
## Introduction
|
||||
WorkLenz offers three different ways to track and calculate task progress, each designed for different project management needs. This guide explains how each method works and when to use them.
|
||||
|
||||
## Default Progress Method
|
||||
|
||||
WorkLenz uses a simple completion-based approach as the default progress calculation method. This method is applied when no special progress methods are enabled.
|
||||
|
||||
### Example
|
||||
|
||||
If you have a parent task with four subtasks and two of the subtasks are marked complete:
|
||||
- Parent task: Not done
|
||||
- 2 subtasks: Done
|
||||
- 2 subtasks: Not done
|
||||
|
||||
The parent task will show as 40% complete (2 completed out of 5 total tasks).
|
||||
|
||||
## Available Progress Tracking Methods
|
||||
|
||||
WorkLenz provides these progress tracking methods:
|
||||
|
||||
1. **Manual Progress** - Directly input progress percentages for tasks
|
||||
2. **Weighted Progress** - Assign importance levels (weights) to tasks
|
||||
3. **Time-based Progress** - Calculate progress based on estimated time
|
||||
|
||||
Only one method can be enabled at a time for a project. If none are enabled, progress will be calculated based on task completion status.
|
||||
|
||||
## How to Select a Progress Method
|
||||
|
||||
1. Open the project drawer by clicking on the project settings icon or creating a new project
|
||||
2. In the project settings, find the "Progress Calculation Method" section
|
||||
3. Select your preferred method
|
||||
4. Save your changes
|
||||
|
||||
## Manual Progress Method
|
||||
|
||||
### How It Works
|
||||
|
||||
- You directly enter progress percentages (0-100%) for tasks without subtasks
|
||||
- Parent task progress is calculated as the average of all subtask progress values
|
||||
- Progress is updated in real-time as you adjust values
|
||||
|
||||
### When to Use Manual Progress
|
||||
|
||||
- For creative or subjective work where completion can't be measured objectively
|
||||
- When task progress doesn't follow a linear path
|
||||
- For projects where team members need flexibility in reporting progress
|
||||
|
||||
### Example
|
||||
|
||||
If you have a parent task with three subtasks:
|
||||
- Subtask A: 30% complete
|
||||
- Subtask B: 60% complete
|
||||
- Subtask C: 90% complete
|
||||
|
||||
The parent task will show as 60% complete (average of 30%, 60%, and 90%).
|
||||
|
||||
## Weighted Progress Method
|
||||
|
||||
### How It Works
|
||||
|
||||
- You assign "weight" values to tasks to indicate their importance
|
||||
- More important tasks have higher weights and influence the overall progress more
|
||||
- You still enter manual progress percentages for tasks without subtasks
|
||||
- Parent task progress is calculated using a weighted average
|
||||
|
||||
### When to Use Weighted Progress
|
||||
|
||||
- When some tasks are more important or time-consuming than others
|
||||
- For projects where all tasks aren't equal
|
||||
- When you want key deliverables to have more impact on overall progress
|
||||
|
||||
### Example
|
||||
|
||||
If you have a parent task with three subtasks:
|
||||
- Subtask A: 50% complete, Weight 60% (important task)
|
||||
- Subtask B: 75% complete, Weight 20% (less important task)
|
||||
- Subtask C: 25% complete, Weight 100% (critical task)
|
||||
|
||||
The parent task will be approximately 39% complete, with Subtask C having the greatest impact due to its higher weight.
|
||||
|
||||
### Important Notes About Weights
|
||||
|
||||
- Default weight is 100% if not specified
|
||||
- Weights range from 0% to 100%
|
||||
- Setting a weight to 0% removes that task from progress calculations
|
||||
- Only explicitly set weights for tasks that should have different importance
|
||||
- Weights are only relevant for subtasks, not for independent tasks
|
||||
|
||||
### Detailed Weighted Progress Calculation Example
|
||||
|
||||
To understand how weighted progress works with different weight values, consider this example:
|
||||
|
||||
For a parent task with two subtasks:
|
||||
- Subtask A: 80% complete, Weight 50%
|
||||
- Subtask B: 40% complete, Weight 100%
|
||||
|
||||
The calculation works as follows:
|
||||
|
||||
1. Each subtask's contribution is: (weight × progress) ÷ (sum of all weights)
|
||||
2. For Subtask A: (50 × 80%) ÷ (50 + 100) = 26.7%
|
||||
3. For Subtask B: (100 × 40%) ÷ (50 + 100) = 26.7%
|
||||
4. Total parent progress: 26.7% + 26.7% = 53.3%
|
||||
|
||||
The parent task would be approximately 53% complete.
|
||||
|
||||
This shows how the subtask with twice the weight (Subtask B) has twice the influence on the overall progress calculation, even though it has a lower completion percentage.
|
||||
|
||||
## Time-based Progress Method
|
||||
|
||||
### How It Works
|
||||
|
||||
- Use the task's time estimate as its "weight" in the progress calculation
|
||||
- You still enter manual progress percentages for tasks without subtasks
|
||||
- Tasks with longer time estimates have more influence on overall progress
|
||||
- Parent task progress is calculated based on time-weighted averages
|
||||
|
||||
### When to Use Time-based Progress
|
||||
|
||||
- For projects with well-defined time estimates
|
||||
- When task importance correlates with its duration
|
||||
- For billing or time-tracking focused projects
|
||||
- When you already maintain accurate time estimates
|
||||
|
||||
### Example
|
||||
|
||||
If you have a parent task with three subtasks:
|
||||
- Subtask A: 40% complete, Estimated Time 2.5 hours
|
||||
- Subtask B: 80% complete, Estimated Time 1 hour
|
||||
- Subtask C: 10% complete, Estimated Time 4 hours
|
||||
|
||||
The parent task will be approximately 29% complete, with the lengthy Subtask C pulling down the overall progress despite Subtask B being mostly complete.
|
||||
|
||||
### Important Notes About Time Estimates
|
||||
|
||||
- Tasks without time estimates don't influence progress calculations
|
||||
- Time is converted to minutes internally (a 2-hour task = 120 minutes)
|
||||
- Setting a time estimate to 0 removes that task from progress calculations
|
||||
- Time estimates serve dual purposes: scheduling/resource planning and progress weighting
|
||||
|
||||
### Detailed Time-based Progress Calculation Example
|
||||
|
||||
To understand how time-based progress works with different time estimates, consider this example:
|
||||
|
||||
For a parent task with three subtasks:
|
||||
- Subtask A: 40% complete, Estimated Time 2.5 hours
|
||||
- Subtask B: 80% complete, Estimated Time 1 hour
|
||||
- Subtask C: 10% complete, Estimated Time 4 hours
|
||||
|
||||
The calculation works as follows:
|
||||
|
||||
1. Convert hours to minutes: A = 150 min, B = 60 min, C = 240 min
|
||||
2. Total estimated time: 150 + 60 + 240 = 450 minutes
|
||||
3. Each subtask's contribution is: (time estimate × progress) ÷ (total time)
|
||||
4. For Subtask A: (150 × 40%) ÷ 450 = 13.3%
|
||||
5. For Subtask B: (60 × 80%) ÷ 450 = 10.7%
|
||||
6. For Subtask C: (240 × 10%) ÷ 450 = 5.3%
|
||||
7. Total parent progress: 13.3% + 10.7% + 5.3% = 29.3%
|
||||
|
||||
The parent task would be approximately 29% complete.
|
||||
|
||||
This demonstrates how tasks with longer time estimates (like Subtask C) have more influence on the overall progress calculation. Even though Subtask B is 80% complete, its shorter time estimate means it contributes less to the overall progress than the partially-completed but longer Subtask A.
|
||||
|
||||
### How It Works
|
||||
|
||||
- Tasks are either 0% (not done) or 100% (done)
|
||||
- Parent task progress = (completed tasks / total tasks) × 100%
|
||||
- Both the parent task and all subtasks count in this calculation
|
||||
|
||||
### When to Use Default Progress
|
||||
|
||||
- For simple projects with clear task completion criteria
|
||||
- When binary task status (done/not done) is sufficient
|
||||
- For teams new to project management who want simplicity
|
||||
|
||||
### Example
|
||||
|
||||
If you have a parent task with four subtasks and two of the subtasks are marked complete:
|
||||
- Parent task: Not done
|
||||
- 2 subtasks: Done
|
||||
- 2 subtasks: Not done
|
||||
|
||||
The parent task will show as 40% complete (2 completed out of 5 total tasks).
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Choose the Right Method for Your Project**
|
||||
- Consider your team's workflow and reporting needs
|
||||
- Match the method to your project's complexity
|
||||
|
||||
2. **Be Consistent**
|
||||
- Stick with one method throughout the project
|
||||
- Changing methods mid-project can cause confusion
|
||||
|
||||
3. **For Manual Progress**
|
||||
- Update progress regularly
|
||||
- Establish guidelines for progress reporting
|
||||
|
||||
4. **For Weighted Progress**
|
||||
- Assign weights based on objective criteria
|
||||
- Don't overuse extreme weights
|
||||
|
||||
5. **For Time-based Progress**
|
||||
- Keep time estimates accurate and up to date
|
||||
- Consider using time tracking to validate estimates
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
**Q: Can I change the progress method mid-project?**
|
||||
A: Yes, but it may cause progress values to change significantly. It's best to select a method at the project start.
|
||||
|
||||
**Q: What happens to task progress when I mark a task complete?**
|
||||
A: When a task is marked complete, its progress automatically becomes 100%, regardless of the progress method.
|
||||
|
||||
**Q: How do I enter progress for a task?**
|
||||
A: Open the task drawer, go to the Info tab, and use the progress slider for tasks without subtasks.
|
||||
|
||||
**Q: Can different projects use different progress methods?**
|
||||
A: Yes, each project can have its own progress method.
|
||||
|
||||
**Q: What if I don't see progress fields in my task drawer?**
|
||||
A: Progress input is only visible for tasks without subtasks. Parent tasks' progress is automatically calculated.
|
||||
550
docs/task-progress-methods.md
Normal file
550
docs/task-progress-methods.md
Normal file
@@ -0,0 +1,550 @@
|
||||
# Task Progress Tracking Methods in WorkLenz
|
||||
|
||||
## Overview
|
||||
WorkLenz supports three different methods for tracking task progress, each suitable for different project management approaches:
|
||||
|
||||
1. **Manual Progress** - Direct input of progress percentages
|
||||
2. **Weighted Progress** - Tasks have weights that affect overall progress calculation
|
||||
3. **Time-based Progress** - Progress calculated based on estimated time vs. time spent
|
||||
|
||||
These modes can be selected when creating or editing a project in the project drawer. Only one progress method can be enabled at a time. If none of these methods are enabled, progress will be calculated based on task completion status as described in the "Default Progress Tracking" section below.
|
||||
|
||||
## 1. Manual Progress Mode
|
||||
|
||||
This mode allows direct input of progress percentages for individual tasks without subtasks.
|
||||
|
||||
**Implementation:**
|
||||
- Enabled by setting `use_manual_progress` to true in the project settings
|
||||
- Progress is updated through the `on-update-task-progress.ts` socket event handler
|
||||
- The UI shows a manual progress input slider in the task drawer for tasks without subtasks
|
||||
- Updates the database with `progress_value` and sets `manual_progress` flag to true
|
||||
|
||||
**Calculation Logic:**
|
||||
- For tasks without subtasks: Uses the manually set progress value
|
||||
- For parent tasks: Calculates the average of all subtask progress values
|
||||
- Subtask progress comes from either manual values or completion status (0% or 100%)
|
||||
|
||||
**Code Example:**
|
||||
```typescript
|
||||
// Manual progress update via socket.io
|
||||
socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({
|
||||
task_id: task.id,
|
||||
progress_value: value,
|
||||
parent_task_id: task.parent_task_id
|
||||
}));
|
||||
```
|
||||
|
||||
## 2. Weighted Progress Mode
|
||||
|
||||
This mode allows assigning different weights to subtasks to reflect their relative importance in the overall task or project progress.
|
||||
|
||||
**Implementation:**
|
||||
- Enabled by setting `use_weighted_progress` to true in the project settings
|
||||
- Weights are updated through the `on-update-task-weight.ts` socket event handler
|
||||
- The UI shows a weight input for subtasks in the task drawer
|
||||
- Manual progress input is still required for tasks without subtasks
|
||||
- Default weight is 100 if not specified
|
||||
- Weight values range from 0 to 100%
|
||||
|
||||
**Calculation Logic:**
|
||||
- For tasks without subtasks: Uses the manually entered progress value
|
||||
- Progress is calculated using a weighted average: `SUM(progress_value * weight) / SUM(weight)`
|
||||
- This gives more influence to tasks with higher weights
|
||||
- A parent task's progress is the weighted average of its subtasks' progress values
|
||||
|
||||
**Code Example:**
|
||||
```typescript
|
||||
// Weight update via socket.io
|
||||
socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({
|
||||
task_id: task.id,
|
||||
weight: value,
|
||||
parent_task_id: task.parent_task_id
|
||||
}));
|
||||
```
|
||||
|
||||
## 3. Time-based Progress Mode
|
||||
|
||||
This mode calculates progress based on estimated time vs. actual time spent.
|
||||
|
||||
**Implementation:**
|
||||
- Enabled by setting `use_time_progress` to true in the project settings
|
||||
- Uses task time estimates (hours and minutes) for calculation
|
||||
- Manual progress input is still required for tasks without subtasks
|
||||
- No separate socket handler needed as it's calculated automatically
|
||||
|
||||
**Calculation Logic:**
|
||||
- For tasks without subtasks: Uses the manually entered progress value
|
||||
- Progress is calculated using time as the weight: `SUM(progress_value * estimated_minutes) / SUM(estimated_minutes)`
|
||||
- For tasks with time tracking, estimated vs. actual time can be factored in
|
||||
- Parent task progress is weighted by the estimated time of each subtask
|
||||
|
||||
**SQL Example:**
|
||||
```sql
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
```
|
||||
|
||||
## Default Progress Tracking (when no special mode is selected)
|
||||
|
||||
If no specific progress mode is enabled, the system falls back to a traditional completion-based calculation:
|
||||
|
||||
**Implementation:**
|
||||
- Default mode when all three special modes are disabled
|
||||
- Based on task completion status only
|
||||
|
||||
**Calculation Logic:**
|
||||
- For tasks without subtasks: 0% if not done, 100% if done
|
||||
- For parent tasks: `(completed_tasks / total_tasks) * 100`
|
||||
- Counts both the parent and all subtasks in the calculation
|
||||
|
||||
**SQL Example:**
|
||||
```sql
|
||||
-- Traditional calculation based on completion status
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks = 0 THEN
|
||||
_ratio = 0;
|
||||
ELSE
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
END IF;
|
||||
```
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
The progress calculation logic is implemented in PostgreSQL functions, primarily in the `get_task_complete_ratio` function. Progress updates flow through the system as follows:
|
||||
|
||||
1. **User Action**: User updates task progress or weight in the UI
|
||||
2. **Socket Event**: Client emits socket event (UPDATE_TASK_PROGRESS or UPDATE_TASK_WEIGHT)
|
||||
3. **Server Handler**: Server processes the event in the respective handler function
|
||||
4. **Database Update**: Progress/weight value is updated in the database
|
||||
5. **Recalculation**: If needed, parent task progress is recalculated
|
||||
6. **Broadcast**: Changes are broadcast to all clients in the project room
|
||||
7. **UI Update**: Client UI updates to reflect the new progress values
|
||||
|
||||
This architecture allows for real-time updates and consistent progress calculation across all clients.
|
||||
|
||||
## Manual Progress Input Implementation
|
||||
|
||||
Regardless of which progress tracking method is selected for a project, tasks without subtasks (leaf tasks) require manual progress input. This section details how manual progress input is implemented and used across all progress tracking methods.
|
||||
|
||||
### UI Component
|
||||
|
||||
The manual progress input component is implemented in `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` and includes:
|
||||
|
||||
1. **Progress Slider**: A slider UI control that allows users to set progress values from 0% to 100%
|
||||
2. **Progress Input Field**: A numeric input field that accepts direct entry of progress percentage
|
||||
3. **Progress Display**: Visual representation of the current progress value
|
||||
|
||||
The component is conditionally rendered in the task drawer for tasks that don't have subtasks.
|
||||
|
||||
**Usage Across Progress Methods:**
|
||||
- In **Manual Progress Mode**: Only the progress slider/input is shown
|
||||
- In **Weighted Progress Mode**: Both the progress slider/input and weight input are shown
|
||||
- In **Time-based Progress Mode**: The progress slider/input is shown alongside time estimate fields
|
||||
|
||||
### Progress Update Flow
|
||||
|
||||
When a user updates a task's progress manually, the following process occurs:
|
||||
|
||||
1. **User Input**: User adjusts the progress slider or enters a value in the input field
|
||||
2. **UI Event Handler**: The UI component captures the change event and validates the input
|
||||
3. **Socket Event Emission**: The component emits a `UPDATE_TASK_PROGRESS` socket event with:
|
||||
```typescript
|
||||
{
|
||||
task_id: task.id,
|
||||
progress_value: value, // The new progress value (0-100)
|
||||
parent_task_id: task.parent_task_id // For recalculation
|
||||
}
|
||||
```
|
||||
4. **Server Processing**: The socket event handler on the server:
|
||||
- Updates the task's `progress_value` in the database
|
||||
- Sets the `manual_progress` flag to true
|
||||
- Triggers recalculation of parent task progress
|
||||
|
||||
### Progress Calculation Across Methods
|
||||
|
||||
The calculation of progress differs based on the active progress method:
|
||||
|
||||
1. **For Leaf Tasks (no subtasks)** in all methods:
|
||||
- Progress is always the manually entered value (`progress_value`)
|
||||
- If the task is marked as completed, progress is automatically set to 100%
|
||||
|
||||
2. **For Parent Tasks**:
|
||||
- **Manual Progress Mode**: Simple average of all subtask progress values
|
||||
- **Weighted Progress Mode**: Weighted average where each subtask's progress is multiplied by its weight
|
||||
- **Time-based Progress Mode**: Weighted average where each subtask's progress is multiplied by its estimated time
|
||||
- **Default Mode**: Percentage of completed tasks (including parent) vs. total tasks
|
||||
|
||||
### Detailed Calculation for Weighted Progress Method
|
||||
|
||||
In Weighted Progress mode, both the manual progress input and weight assignment are critical components:
|
||||
|
||||
1. **Manual Progress Input**:
|
||||
- For leaf tasks (without subtasks), users must manually input progress percentages (0-100%)
|
||||
- If a leaf task is marked as complete, its progress is automatically set to 100%
|
||||
- If a leaf task's progress is not manually set, it defaults to 0% (or 100% if completed)
|
||||
|
||||
2. **Weight Assignment**:
|
||||
- Each task can be assigned a weight value between 0-100% (default 100% if not specified)
|
||||
- Higher weight values give tasks more influence in parent task progress calculations
|
||||
- A weight of 0% means the task doesn't contribute to the parent's progress calculation
|
||||
|
||||
3. **Parent Task Calculation**:
|
||||
The weighted progress formula is:
|
||||
```
|
||||
ParentProgress = ∑(SubtaskProgress * SubtaskWeight) / ∑(SubtaskWeight)
|
||||
```
|
||||
|
||||
**Example Calculation**:
|
||||
Consider a parent task with three subtasks:
|
||||
- Subtask A: Progress 50%, Weight 60%
|
||||
- Subtask B: Progress 75%, Weight 20%
|
||||
- Subtask C: Progress 25%, Weight 100%
|
||||
|
||||
Calculation:
|
||||
```
|
||||
ParentProgress = ((50 * 60) + (75 * 20) + (25 * 100)) / (60 + 20 + 100)
|
||||
ParentProgress = (3000 + 1500 + 2500) / 180
|
||||
ParentProgress = 7000 / 180
|
||||
ParentProgress = 38.89%
|
||||
```
|
||||
|
||||
Notice that Subtask C, despite having the lowest progress, has a significant impact on the parent task progress due to its higher weight.
|
||||
|
||||
4. **Zero Weight Handling**:
|
||||
Tasks with zero weight are excluded from the calculation:
|
||||
- Subtask A: Progress 40%, Weight 50%
|
||||
- Subtask B: Progress 80%, Weight 0%
|
||||
|
||||
Calculation:
|
||||
```
|
||||
ParentProgress = ((40 * 50) + (80 * 0)) / (50 + 0)
|
||||
ParentProgress = 2000 / 50
|
||||
ParentProgress = 40%
|
||||
```
|
||||
|
||||
In this case, only Subtask A influences the parent task progress because Subtask B has a weight of 0%.
|
||||
|
||||
5. **Default Weight Behavior**:
|
||||
When weights aren't explicitly assigned to some tasks:
|
||||
- Subtask A: Progress 30%, Weight 60% (explicitly set)
|
||||
- Subtask B: Progress 70%, Weight not set (defaults to 100%)
|
||||
- Subtask C: Progress 90%, Weight not set (defaults to 100%)
|
||||
|
||||
Calculation:
|
||||
```
|
||||
ParentProgress = ((30 * 60) + (70 * 100) + (90 * 100)) / (60 + 100 + 100)
|
||||
ParentProgress = (1800 + 7000 + 9000) / 260
|
||||
ParentProgress = 17800 / 260
|
||||
ParentProgress = 68.46%
|
||||
```
|
||||
|
||||
Note that Subtasks B and C have more influence than Subtask A because they have higher default weights.
|
||||
|
||||
6. **All Zero Weights Edge Case**:
|
||||
If all subtasks have zero weight, the progress is calculated as 0%:
|
||||
```
|
||||
ParentProgress = SUM(progress_value * 0) / SUM(0) = 0 / 0 = undefined
|
||||
```
|
||||
|
||||
The SQL implementation handles this with `NULLIF` and `COALESCE` to return 0% in this case.
|
||||
|
||||
4. **Actual SQL Implementation**:
|
||||
The database function implements the weighted calculation as follows:
|
||||
```sql
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(weight, 100) AS weight
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
```
|
||||
|
||||
This SQL implementation:
|
||||
- Gets all non-archived subtasks of the parent task
|
||||
- For each subtask, determines its progress value:
|
||||
- If manual progress is set, uses that value
|
||||
- Otherwise, uses 100% if the task is done or 0% if not done
|
||||
- Uses COALESCE to default weight to 100 if not specified
|
||||
- Calculates the weighted average, handling the case where sum of weights might be zero
|
||||
- Returns 0 if there are no subtasks with weights
|
||||
|
||||
### Detailed Calculation for Time-based Progress Method
|
||||
|
||||
In Time-based Progress mode, the task's estimated time serves as its weight in progress calculations:
|
||||
|
||||
1. **Manual Progress Input**:
|
||||
- As with weighted progress, leaf tasks require manual progress input
|
||||
- Progress is entered as a percentage (0-100%)
|
||||
- Completed tasks are automatically set to 100% progress
|
||||
|
||||
2. **Time Estimation**:
|
||||
- Each task has an estimated time in hours and minutes
|
||||
- These values are stored in `total_hours` and `total_minutes` fields
|
||||
- Time estimates effectively function as weights in progress calculations
|
||||
- Tasks with longer estimated durations have more influence on parent task progress
|
||||
- Tasks with zero or no time estimate don't contribute to the parent's progress calculation
|
||||
|
||||
3. **Parent Task Calculation**:
|
||||
The time-based progress formula is:
|
||||
```
|
||||
ParentProgress = ∑(SubtaskProgress * SubtaskEstimatedMinutes) / ∑(SubtaskEstimatedMinutes)
|
||||
```
|
||||
where `SubtaskEstimatedMinutes = (SubtaskHours * 60) + SubtaskMinutes`
|
||||
|
||||
**Example Calculation**:
|
||||
Consider a parent task with three subtasks:
|
||||
- Subtask A: Progress 40%, Estimated Time 2h 30m (150 minutes)
|
||||
- Subtask B: Progress 80%, Estimated Time 1h (60 minutes)
|
||||
- Subtask C: Progress 10%, Estimated Time 4h (240 minutes)
|
||||
|
||||
Calculation:
|
||||
```
|
||||
ParentProgress = ((40 * 150) + (80 * 60) + (10 * 240)) / (150 + 60 + 240)
|
||||
ParentProgress = (6000 + 4800 + 2400) / 450
|
||||
ParentProgress = 13200 / 450
|
||||
ParentProgress = 29.33%
|
||||
```
|
||||
|
||||
Note how Subtask C, with its large time estimate, significantly pulls down the overall progress despite Subtask B being mostly complete.
|
||||
|
||||
4. **Zero Time Estimate Handling**:
|
||||
Tasks with zero time estimate are excluded from the calculation:
|
||||
- Subtask A: Progress 40%, Estimated Time 3h (180 minutes)
|
||||
- Subtask B: Progress 80%, Estimated Time 0h (0 minutes)
|
||||
|
||||
Calculation:
|
||||
```
|
||||
ParentProgress = ((40 * 180) + (80 * 0)) / (180 + 0)
|
||||
ParentProgress = 7200 / 180
|
||||
ParentProgress = 40%
|
||||
```
|
||||
|
||||
In this case, only Subtask A influences the parent task progress because Subtask B has no time estimate.
|
||||
|
||||
5. **All Zero Time Estimates Edge Case**:
|
||||
If all subtasks have zero time estimates, the progress is calculated as 0%:
|
||||
```
|
||||
ParentProgress = SUM(progress_value * 0) / SUM(0) = 0 / 0 = undefined
|
||||
```
|
||||
|
||||
The SQL implementation handles this with `NULLIF` and `COALESCE` to return 0% in this case.
|
||||
|
||||
6. **Actual SQL Implementation**:
|
||||
The SQL function for this calculation first converts hours to minutes for consistent measurement:
|
||||
```sql
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
```
|
||||
|
||||
This implementation:
|
||||
- Gets all non-archived subtasks of the parent task
|
||||
- Determines each subtask's progress value (manual or completion-based)
|
||||
- Calculates total minutes by converting hours to minutes and adding them together
|
||||
- Uses COALESCE to treat NULL time estimates as 0 minutes
|
||||
- Uses NULLIF to handle cases where all time estimates are zero
|
||||
- Returns 0% progress if there are no subtasks with time estimates
|
||||
|
||||
### Common Implementation Considerations
|
||||
|
||||
For both weighted and time-based progress calculation:
|
||||
|
||||
1. **Null Handling**:
|
||||
- Tasks with NULL progress values are treated as 0% progress (unless completed)
|
||||
- Tasks with NULL weights default to 100 in weighted mode
|
||||
- Tasks with NULL time estimates are treated as 0 minutes in time-based mode
|
||||
|
||||
2. **Progress Propagation**:
|
||||
- When a leaf task's progress changes, all ancestor tasks are recalculated
|
||||
- Progress updates are propagated through socket events to all connected clients
|
||||
- The recalculation happens server-side to ensure consistency
|
||||
|
||||
3. **Edge Cases**:
|
||||
- If all subtasks have zero weight/time, the system falls back to a simple average
|
||||
- If a parent task has no subtasks, its own manual progress value is used
|
||||
- If a task is archived, it's excluded from parent task calculations
|
||||
|
||||
### Database Implementation
|
||||
|
||||
The manual progress value is stored in the `tasks` table with these relevant fields:
|
||||
|
||||
```sql
|
||||
tasks (
|
||||
-- other fields
|
||||
progress_value FLOAT, -- The manually entered progress value (0-100)
|
||||
manual_progress BOOLEAN, -- Flag indicating if progress was manually set
|
||||
weight INTEGER DEFAULT 100, -- For weighted progress calculation
|
||||
total_hours INTEGER, -- For time-based progress calculation
|
||||
total_minutes INTEGER -- For time-based progress calculation
|
||||
)
|
||||
```
|
||||
|
||||
### Integration with Parent Task Calculation
|
||||
|
||||
When a subtask's progress is updated manually, the parent task's progress is automatically recalculated based on the active progress method:
|
||||
|
||||
```typescript
|
||||
// Pseudocode for parent task recalculation
|
||||
function recalculateParentTaskProgress(taskId, parentTaskId) {
|
||||
if (!parentTaskId) return;
|
||||
|
||||
// Get project settings to determine active progress method
|
||||
const project = getProjectByTaskId(taskId);
|
||||
|
||||
if (project.use_manual_progress) {
|
||||
// Calculate average of all subtask progress values
|
||||
updateParentProgress(parentTaskId, calculateAverageProgress(parentTaskId));
|
||||
}
|
||||
else if (project.use_weighted_progress) {
|
||||
// Calculate weighted average using subtask weights
|
||||
updateParentProgress(parentTaskId, calculateWeightedProgress(parentTaskId));
|
||||
}
|
||||
else if (project.use_time_progress) {
|
||||
// Calculate weighted average using time estimates
|
||||
updateParentProgress(parentTaskId, calculateTimeBasedProgress(parentTaskId));
|
||||
}
|
||||
else {
|
||||
// Default: Calculate based on task completion
|
||||
updateParentProgress(parentTaskId, calculateCompletionBasedProgress(parentTaskId));
|
||||
}
|
||||
|
||||
// If this parent has a parent, continue recalculation up the tree
|
||||
const grandparentId = getParentTaskId(parentTaskId);
|
||||
if (grandparentId) {
|
||||
recalculateParentTaskProgress(parentTaskId, grandparentId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This recursive approach ensures that changes to any task's progress are properly propagated up the task hierarchy.
|
||||
|
||||
## Associated Files and Components
|
||||
|
||||
### Backend Files
|
||||
|
||||
1. **Socket Event Handlers**:
|
||||
- `worklenz-backend/src/socket.io/commands/on-update-task-progress.ts` - Handles manual progress updates
|
||||
- `worklenz-backend/src/socket.io/commands/on-update-task-weight.ts` - Handles task weight updates
|
||||
|
||||
2. **Database Functions**:
|
||||
- `worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql` - Contains the `get_task_complete_ratio` function that calculates progress based on the selected method
|
||||
- Functions that support project creation/updates with progress mode settings:
|
||||
- `create_project`
|
||||
- `update_project`
|
||||
|
||||
3. **Controllers**:
|
||||
- `worklenz-backend/src/controllers/project-workload/workload-gannt-base.ts` - Contains the `calculateTaskCompleteRatio` method
|
||||
- `worklenz-backend/src/controllers/projects-controller.ts` - Handles project-level progress calculations
|
||||
|
||||
### Frontend Files
|
||||
|
||||
1. **Project Configuration**:
|
||||
- `worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx` - Contains UI for selecting progress method when creating/editing projects
|
||||
|
||||
2. **Progress Visualization Components**:
|
||||
- `worklenz-frontend/src/components/project-list/project-list-table/project-list-progress/progress-list-progress.tsx` - Displays project progress
|
||||
- `worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx` - Displays task progress
|
||||
- `worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx` - Alternative task progress cell
|
||||
|
||||
3. **Progress Input Components**:
|
||||
- `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` - Component for inputting task progress/weight
|
||||
|
||||
## Choosing the Right Progress Method
|
||||
|
||||
Each progress method is suitable for different types of projects:
|
||||
|
||||
- **Manual Progress**: Best for creative work where progress is subjective
|
||||
- **Weighted Progress**: Ideal for projects where some tasks are more significant than others
|
||||
- **Time-based Progress**: Perfect for projects where time estimates are reliable and important
|
||||
|
||||
Project managers can choose the appropriate method when creating or editing a project in the project drawer, based on their team's workflow and project requirements.
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "worklenz",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
185
start.bat
Normal file
185
start.bat
Normal file
@@ -0,0 +1,185 @@
|
||||
@echo off
|
||||
echo Starting Worklenz setup... > worklenz_startup.log
|
||||
echo %DATE% %TIME% >> worklenz_startup.log
|
||||
echo.
|
||||
echo " __ __ _ _"
|
||||
echo " \ \ / / | | | |"
|
||||
echo " \ \ /\ / /__ _ __| | _| | ___ _ __ ____"
|
||||
echo " \ \/ \/ / _ \| '__| |/ / |/ _ \ '_ \|_ /"
|
||||
echo " \ /\ / (_) | | | <| | __/ | | |/ /"
|
||||
echo " \/ \/ \___/|_| |_|\_\_|\___|_| |_/___|"
|
||||
echo.
|
||||
echo W O R K L E N Z
|
||||
echo.
|
||||
echo Starting Worklenz Docker Environment...
|
||||
echo.
|
||||
|
||||
REM Check for Docker installation
|
||||
echo Checking for Docker installation...
|
||||
where docker >nul 2>>worklenz_startup.log
|
||||
IF %ERRORLEVEL% NEQ 0 (
|
||||
echo [91mWarning: Docker is not installed or not in PATH[0m
|
||||
echo Warning: Docker is not installed or not in PATH >> worklenz_startup.log
|
||||
echo Please install Docker first: https://docs.docker.com/get-docker/
|
||||
echo [93mContinuing for debugging purposes...[0m
|
||||
) ELSE (
|
||||
echo [92m^✓[0m Docker is installed
|
||||
echo Docker is installed >> worklenz_startup.log
|
||||
)
|
||||
|
||||
REM Check for docker-compose installation
|
||||
echo Checking for docker-compose...
|
||||
where docker-compose >nul 2>>worklenz_startup.log
|
||||
IF %ERRORLEVEL% NEQ 0 (
|
||||
echo [91mWarning: docker-compose is not installed or not in PATH[0m
|
||||
echo Warning: docker-compose is not installed or not in PATH >> worklenz_startup.log
|
||||
echo [93mContinuing for debugging purposes...[0m
|
||||
) ELSE (
|
||||
echo [92m^✓[0m docker-compose is installed
|
||||
echo docker-compose is installed >> worklenz_startup.log
|
||||
)
|
||||
|
||||
REM Check for update-docker-env.sh
|
||||
IF EXIST update-docker-env.sh (
|
||||
echo [94mFound update-docker-env.sh script. You can use it to update environment variables.[0m
|
||||
echo Found update-docker-env.sh script >> worklenz_startup.log
|
||||
)
|
||||
|
||||
REM Run preflight checks
|
||||
echo Running Docker daemon check...
|
||||
docker info >nul 2>>worklenz_startup.log
|
||||
IF %ERRORLEVEL% NEQ 0 (
|
||||
echo [91mWarning: Docker daemon is not running[0m
|
||||
echo Warning: Docker daemon is not running >> worklenz_startup.log
|
||||
echo Please start Docker and try again
|
||||
echo [93mContinuing for debugging purposes...[0m
|
||||
) ELSE (
|
||||
echo [92m^✓[0m Docker daemon is running
|
||||
echo Docker daemon is running >> worklenz_startup.log
|
||||
)
|
||||
|
||||
REM Stop any running containers
|
||||
echo Stopping any running containers...
|
||||
docker-compose down > nul 2>>worklenz_startup.log
|
||||
IF %ERRORLEVEL% NEQ 0 (
|
||||
echo [91mWarning: Error stopping containers[0m
|
||||
echo Warning: Error stopping containers >> worklenz_startup.log
|
||||
echo [93mContinuing anyway...[0m
|
||||
)
|
||||
|
||||
REM Start the containers
|
||||
echo Starting containers...
|
||||
echo Attempting to start containers... >> worklenz_startup.log
|
||||
|
||||
REM Start with docker-compose
|
||||
docker-compose up -d > docker_up_output.txt 2>&1
|
||||
type docker_up_output.txt >> worklenz_startup.log
|
||||
|
||||
REM Check for errors in output
|
||||
findstr /C:"Error" docker_up_output.txt > nul
|
||||
IF %ERRORLEVEL% EQU 0 (
|
||||
echo [91mErrors detected during startup[0m
|
||||
echo Errors detected during startup >> worklenz_startup.log
|
||||
type docker_up_output.txt
|
||||
)
|
||||
|
||||
del docker_up_output.txt > nul 2>&1
|
||||
|
||||
REM Wait for services to be ready
|
||||
echo Waiting for services to start...
|
||||
timeout /t 10 /nobreak > nul
|
||||
echo After timeout, checking services >> worklenz_startup.log
|
||||
|
||||
REM Check service status using docker-compose
|
||||
echo Checking service status...
|
||||
echo Checking service status... >> worklenz_startup.log
|
||||
docker-compose ps --services --filter "status=running" > running_services.txt 2>>worklenz_startup.log
|
||||
|
||||
REM Log services output
|
||||
type running_services.txt >> worklenz_startup.log
|
||||
|
||||
echo.
|
||||
echo Checking individual services:
|
||||
echo Checking individual services: >> worklenz_startup.log
|
||||
|
||||
REM Check frontend
|
||||
findstr /C:"frontend" running_services.txt > nul
|
||||
IF %ERRORLEVEL% EQU 0 (
|
||||
echo [92m^✓[0m Frontend is running
|
||||
echo Frontend URL: http://localhost:5000 (or https://localhost:5000 if SSL is enabled)
|
||||
echo Frontend is running >> worklenz_startup.log
|
||||
) ELSE (
|
||||
echo [91m^✗[0m Frontend service failed to start
|
||||
echo Frontend service failed to start >> worklenz_startup.log
|
||||
)
|
||||
|
||||
REM Check backend
|
||||
findstr /C:"backend" running_services.txt > nul
|
||||
IF %ERRORLEVEL% EQU 0 (
|
||||
echo [92m^✓[0m Backend is running
|
||||
echo Backend URL: http://localhost:3000 (or https://localhost:3000 if SSL is enabled)
|
||||
echo Backend is running >> worklenz_startup.log
|
||||
) ELSE (
|
||||
echo [91m^✗[0m Backend service failed to start
|
||||
echo Backend service failed to start >> worklenz_startup.log
|
||||
)
|
||||
|
||||
REM Check MinIO
|
||||
findstr /C:"minio" running_services.txt > nul
|
||||
IF %ERRORLEVEL% EQU 0 (
|
||||
echo [92m^✓[0m MinIO is running
|
||||
echo MinIO Console URL: http://localhost:9001 (login: minioadmin/minioadmin)
|
||||
echo MinIO is running >> worklenz_startup.log
|
||||
) ELSE (
|
||||
echo [91m^✗[0m MinIO service failed to start
|
||||
echo MinIO service failed to start >> worklenz_startup.log
|
||||
|
||||
REM Check MinIO logs
|
||||
echo Checking MinIO logs for errors:
|
||||
docker-compose logs minio --tail=20 > minio_logs.txt
|
||||
type minio_logs.txt
|
||||
type minio_logs.txt >> worklenz_startup.log
|
||||
del minio_logs.txt > nul 2>&1
|
||||
)
|
||||
|
||||
REM Check Database
|
||||
findstr /C:"db" running_services.txt > nul
|
||||
IF %ERRORLEVEL% EQU 0 (
|
||||
echo [92m^✓[0m Database is running
|
||||
echo Database is running >> worklenz_startup.log
|
||||
) ELSE (
|
||||
echo [91m^✗[0m Database service failed to start
|
||||
echo Database service failed to start >> worklenz_startup.log
|
||||
)
|
||||
|
||||
del running_services.txt > nul 2>&1
|
||||
|
||||
REM Check if all services are running
|
||||
set allRunning=1
|
||||
docker-compose ps --services | findstr /V /C:"frontend" /C:"backend" /C:"minio" /C:"db" > remaining_services.txt
|
||||
FOR /F "tokens=*" %%s IN (remaining_services.txt) DO (
|
||||
findstr /C:"%%s" running_services.txt > nul || set allRunning=0
|
||||
)
|
||||
del remaining_services.txt > nul 2>&1
|
||||
|
||||
IF %allRunning% EQU 1 (
|
||||
echo.
|
||||
echo [92mWorklenz setup completed![0m
|
||||
echo Setup completed successfully >> worklenz_startup.log
|
||||
) ELSE (
|
||||
echo.
|
||||
echo [93mWarning: Some services may not be running correctly.[0m
|
||||
echo Warning: Some services may not be running correctly >> worklenz_startup.log
|
||||
echo Run 'docker-compose logs' to check for errors.
|
||||
)
|
||||
|
||||
echo You can access the application at: http://localhost:5000
|
||||
echo To stop the services, run: stop.bat
|
||||
echo To update environment variables, run: update-docker-env.sh
|
||||
echo.
|
||||
echo Note: To enable SSL, set ENABLE_SSL=true in your .env file and run update-docker-env.sh
|
||||
echo.
|
||||
echo For any errors, check worklenz_startup.log file
|
||||
echo.
|
||||
echo Press any key to exit...
|
||||
pause > nul
|
||||
151
start.sh
Executable file
151
start.sh
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Colors for terminal output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print banner
|
||||
echo -e "${GREEN}"
|
||||
echo " __ __ _ _"
|
||||
echo " \ \ / / | | | |"
|
||||
echo " \ \ /\ / /__ _ __| | _| | ___ _ __ ____"
|
||||
echo " \ \/ \/ / _ \| '__| |/ / |/ _ \ '_ \|_ /"
|
||||
echo " \ /\ / (_) | | | <| | __/ | | |/ /"
|
||||
echo " \/ \/ \___/|_| |_|\_\_|\___|_| |_/___|"
|
||||
echo ""
|
||||
echo " W O R K L E N Z "
|
||||
echo -e "${NC}"
|
||||
echo "Starting Worklenz Docker Environment..."
|
||||
|
||||
# Check if update-docker-env.sh exists and is executable
|
||||
if [ -f update-docker-env.sh ] && [ -x update-docker-env.sh ]; then
|
||||
echo -e "${BLUE}Found update-docker-env.sh script. You can use it to update environment variables.${NC}"
|
||||
fi
|
||||
|
||||
# Function to check if a service is running
|
||||
check_service() {
|
||||
local service_name=$1
|
||||
local container_name=$2
|
||||
local url=$3
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
echo -e "${BLUE}Checking ${service_name} service...${NC}"
|
||||
|
||||
# First check if the container is running
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if docker ps | grep -q "${container_name}"; then
|
||||
# Container is running
|
||||
if [ -z "$url" ]; then
|
||||
# No URL to check, assume service is up
|
||||
echo -e "${GREEN}✓${NC} ${service_name} is running"
|
||||
return 0
|
||||
else
|
||||
# Check if service endpoint is responding
|
||||
if curl -s -f -o /dev/null "$url"; then
|
||||
echo -e "${GREEN}✓${NC} ${service_name} is running and responding at ${url}"
|
||||
return 0
|
||||
else
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo -e "${YELLOW}⚠${NC} ${service_name} container is running but not responding at ${url}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo -e "${RED}✗${NC} ${service_name} failed to start"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -n "."
|
||||
attempt=$((attempt+1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker is not installed or not in PATH${NC}"
|
||||
echo "Please install Docker first: https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker daemon is running
|
||||
echo -e "${BLUE}Running preflight checks...${NC}"
|
||||
if ! docker info &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker daemon is not running${NC}"
|
||||
echo "Please start Docker and try again"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} Docker is running"
|
||||
|
||||
# Determine Docker Compose command to use
|
||||
DOCKER_COMPOSE_CMD=""
|
||||
if command -v docker compose &> /dev/null; then
|
||||
DOCKER_COMPOSE_CMD="docker compose"
|
||||
echo -e "${GREEN}✓${NC} Using Docker Compose V2"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
DOCKER_COMPOSE_CMD="docker-compose"
|
||||
echo -e "${YELLOW}⚠${NC} Using legacy Docker Compose"
|
||||
else
|
||||
echo -e "${RED}Error: Docker Compose is not installed or not in PATH${NC}"
|
||||
echo "Please install Docker Compose: https://docs.docker.com/compose/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if any of the ports are already in use
|
||||
ports=(3000 5000 9000 9001 5432)
|
||||
for port in "${ports[@]}"; do
|
||||
if lsof -i:"$port" > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠ Warning: Port $port is already in use. This may cause conflicts.${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Start the containers
|
||||
echo -e "${BLUE}Starting Worklenz services...${NC}"
|
||||
$DOCKER_COMPOSE_CMD down
|
||||
$DOCKER_COMPOSE_CMD up -d
|
||||
|
||||
# Wait for services to fully initialize
|
||||
echo -e "${BLUE}Waiting for services to initialize...${NC}"
|
||||
echo "This may take a minute or two depending on your system..."
|
||||
|
||||
# Check each service
|
||||
check_service "Database" "worklenz_db" ""
|
||||
DB_STATUS=$?
|
||||
|
||||
check_service "MinIO" "worklenz_minio" "http://localhost:9000/minio/health/live"
|
||||
MINIO_STATUS=$?
|
||||
|
||||
check_service "Backend" "worklenz_backend" "http://localhost:3000/public/health"
|
||||
BACKEND_STATUS=$?
|
||||
|
||||
check_service "Frontend" "worklenz_frontend" "http://localhost:5000"
|
||||
FRONTEND_STATUS=$?
|
||||
|
||||
# Display service URLs
|
||||
echo -e "\n${BLUE}Service URLs:${NC}"
|
||||
[ $FRONTEND_STATUS -eq 0 ] && echo " • Frontend: http://localhost:5000 (or https://localhost:5000 if SSL is enabled)"
|
||||
[ $BACKEND_STATUS -eq 0 ] && echo " • Backend API: http://localhost:3000 (or https://localhost:3000 if SSL is enabled)"
|
||||
[ $MINIO_STATUS -eq 0 ] && echo " • MinIO Console: http://localhost:9001 (login: minioadmin/minioadmin)"
|
||||
|
||||
# Check if all services are up
|
||||
if [ $DB_STATUS -eq 0 ] && [ $MINIO_STATUS -eq 0 ] && [ $BACKEND_STATUS -eq 0 ] && [ $FRONTEND_STATUS -eq 0 ]; then
|
||||
echo -e "\n${GREEN}✅ All Worklenz services are running successfully!${NC}"
|
||||
else
|
||||
echo -e "\n${YELLOW}⚠ Some services may not be running properly. Check the logs for more details:${NC}"
|
||||
echo " $DOCKER_COMPOSE_CMD logs"
|
||||
fi
|
||||
|
||||
echo -e "\n${BLUE}Useful commands:${NC}"
|
||||
echo " • View logs: $DOCKER_COMPOSE_CMD logs -f"
|
||||
echo " • Stop services: ./stop.sh"
|
||||
echo " • Update environment variables: ./update-docker-env.sh"
|
||||
echo -e "\n${YELLOW}Note:${NC} To enable SSL, set ENABLE_SSL=true in your .env file and run ./update-docker-env.sh"
|
||||
7
stop.bat
Normal file
7
stop.bat
Normal file
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
echo [91mStopping Worklenz Docker Environment...[0m
|
||||
|
||||
REM Stop the containers
|
||||
docker-compose down
|
||||
|
||||
echo [92mWorklenz services have been stopped.[0m
|
||||
50
stop.sh
Executable file
50
stop.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Colors for terminal output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print banner
|
||||
echo -e "${RED}"
|
||||
echo " __ __ _ _"
|
||||
echo " \ \ / / | | | |"
|
||||
echo " \ \ /\ / /__ _ __| | _| | ___ _ __ ____"
|
||||
echo " \ \/ \/ / _ \| '__| |/ / |/ _ \ '_ \|_ /"
|
||||
echo " \ /\ / (_) | | | <| | __/ | | |/ /"
|
||||
echo " \/ \/ \___/|_| |_|\_\_|\___|_| |_/___|"
|
||||
echo ""
|
||||
echo " W O R K L E N Z "
|
||||
echo -e "${NC}"
|
||||
echo -e "${BLUE}Stopping Worklenz Docker Environment...${NC}"
|
||||
|
||||
# Determine Docker Compose command to use
|
||||
DOCKER_COMPOSE_CMD=""
|
||||
if command -v docker compose &> /dev/null; then
|
||||
DOCKER_COMPOSE_CMD="docker compose"
|
||||
echo -e "${GREEN}✓${NC} Using Docker Compose V2"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
DOCKER_COMPOSE_CMD="docker-compose"
|
||||
echo -e "${YELLOW}⚠${NC} Using legacy Docker Compose"
|
||||
else
|
||||
echo -e "${RED}Error: Docker Compose is not installed or not in PATH${NC}"
|
||||
echo "Please install Docker Compose: https://docs.docker.com/compose/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop the containers
|
||||
echo -e "${BLUE}Stopping all services...${NC}"
|
||||
$DOCKER_COMPOSE_CMD down
|
||||
|
||||
# Check if containers are still running
|
||||
if docker ps | grep -q "worklenz_"; then
|
||||
echo -e "${YELLOW}⚠ Some Worklenz containers are still running. Forcing stop...${NC}"
|
||||
docker stop $(docker ps -q --filter "name=worklenz_")
|
||||
echo -e "${GREEN}✓${NC} Forced stop completed."
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} All Worklenz services have been stopped successfully."
|
||||
fi
|
||||
|
||||
echo -e "\n${BLUE}To start Worklenz again, run:${NC} ./start.sh"
|
||||
244
task-progress-methods.md
Normal file
244
task-progress-methods.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Task Progress Tracking Methods in WorkLenz
|
||||
|
||||
## Overview
|
||||
WorkLenz supports three different methods for tracking task progress, each suitable for different project management approaches:
|
||||
|
||||
1. **Manual Progress** - Direct input of progress percentages
|
||||
2. **Weighted Progress** - Tasks have weights that affect overall progress calculation
|
||||
3. **Time-based Progress** - Progress calculated based on estimated time vs. time spent
|
||||
|
||||
These modes can be selected when creating or editing a project in the project drawer.
|
||||
|
||||
## 1. Manual Progress Mode
|
||||
|
||||
This mode allows direct input of progress percentages for individual tasks without subtasks.
|
||||
|
||||
**Implementation:**
|
||||
- Enabled by setting `use_manual_progress` to true in the project settings
|
||||
- Progress is updated through the `on-update-task-progress.ts` socket event handler
|
||||
- The UI shows a manual progress input slider in the task drawer for tasks without subtasks
|
||||
- Updates the database with `progress_value` and sets `manual_progress` flag to true
|
||||
|
||||
**Calculation Logic:**
|
||||
- For tasks without subtasks: Uses the manually set progress value
|
||||
- For parent tasks: Calculates the average of all subtask progress values
|
||||
- Subtask progress comes from either manual values or completion status (0% or 100%)
|
||||
|
||||
**Code Example:**
|
||||
```typescript
|
||||
// Manual progress update via socket.io
|
||||
socket?.emit(SocketEvents.UPDATE_TASK_PROGRESS.toString(), JSON.stringify({
|
||||
task_id: task.id,
|
||||
progress_value: value,
|
||||
parent_task_id: task.parent_task_id
|
||||
}));
|
||||
```
|
||||
|
||||
### Showing Progress in Subtask Rows
|
||||
|
||||
When manual progress is enabled in a project, progress is shown in the following ways:
|
||||
|
||||
1. **In Task List Views**:
|
||||
- Subtasks display their individual progress values in the progress column
|
||||
- Parent tasks display the calculated average progress of all subtasks
|
||||
|
||||
2. **Implementation Details**:
|
||||
- The progress values are stored in the `progress_value` column in the database
|
||||
- For subtasks with manual progress set, the value is shown directly
|
||||
- For subtasks without manual progress, the completion status determines the value (0% or 100%)
|
||||
- The task view model includes both `progress` and `complete_ratio` properties
|
||||
|
||||
**Relevant Components:**
|
||||
```typescript
|
||||
// From task-list-progress-cell.tsx
|
||||
const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
|
||||
return task.is_sub_task ? null : (
|
||||
<Tooltip title={`${task.completed_count || 0} / ${task.total_tasks_count || 0}`}>
|
||||
<Progress
|
||||
percent={task.complete_ratio || 0}
|
||||
type="circle"
|
||||
size={24}
|
||||
style={{ cursor: 'default' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Task Progress Calculation in Backend:**
|
||||
```typescript
|
||||
// From tasks-controller-base.ts
|
||||
// For tasks without subtasks, respect manual progress if set
|
||||
if (task.manual_progress === true && task.progress_value !== null) {
|
||||
// For manually set progress, use that value directly
|
||||
task.progress = parseInt(task.progress_value);
|
||||
task.complete_ratio = parseInt(task.progress_value);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Weighted Progress Mode
|
||||
|
||||
This mode allows assigning different weights to subtasks to reflect their relative importance in the overall task or project progress.
|
||||
|
||||
**Implementation:**
|
||||
- Enabled by setting `use_weighted_progress` to true in the project settings
|
||||
- Weights are updated through the `on-update-task-weight.ts` socket event handler
|
||||
- The UI shows a weight input for subtasks in the task drawer
|
||||
- Default weight is 100 if not specified
|
||||
|
||||
**Calculation Logic:**
|
||||
- Progress is calculated using a weighted average: `SUM(progress_value * weight) / SUM(weight)`
|
||||
- This gives more influence to tasks with higher weights
|
||||
- A parent task's progress is the weighted average of its subtasks' progress
|
||||
|
||||
**Code Example:**
|
||||
```typescript
|
||||
// Weight update via socket.io
|
||||
socket?.emit(SocketEvents.UPDATE_TASK_WEIGHT.toString(), JSON.stringify({
|
||||
task_id: task.id,
|
||||
weight: value,
|
||||
parent_task_id: task.parent_task_id
|
||||
}));
|
||||
```
|
||||
|
||||
## 3. Time-based Progress Mode
|
||||
|
||||
This mode calculates progress based on estimated time vs. actual time spent.
|
||||
|
||||
**Implementation:**
|
||||
- Enabled by setting `use_time_progress` to true in the project settings
|
||||
- Uses task time estimates (hours and minutes) for calculation
|
||||
- No separate socket handler needed as it's calculated automatically
|
||||
|
||||
**Calculation Logic:**
|
||||
- Progress is calculated using time as the weight: `SUM(progress_value * estimated_minutes) / SUM(estimated_minutes)`
|
||||
- For tasks with time tracking, estimated vs. actual time can be factored in
|
||||
- Parent task progress is weighted by the estimated time of each subtask
|
||||
|
||||
**SQL Example:**
|
||||
```sql
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(total_hours * 60 + total_minutes, 0) AS estimated_minutes
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
```
|
||||
|
||||
## Default Progress Tracking (when no special mode is selected)
|
||||
|
||||
If no specific progress mode is enabled, the system falls back to a traditional completion-based calculation:
|
||||
|
||||
**Implementation:**
|
||||
- Default mode when all three special modes are disabled
|
||||
- Based on task completion status only
|
||||
|
||||
**Calculation Logic:**
|
||||
- For tasks without subtasks: 0% if not done, 100% if done
|
||||
- For parent tasks: `(completed_tasks / total_tasks) * 100`
|
||||
- Counts both the parent and all subtasks in the calculation
|
||||
|
||||
**SQL Example:**
|
||||
```sql
|
||||
-- Traditional calculation based on completion status
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks = 0 THEN
|
||||
_ratio = 0;
|
||||
ELSE
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
END IF;
|
||||
```
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
The progress calculation logic is implemented in PostgreSQL functions, primarily in the `get_task_complete_ratio` function. Progress updates flow through the system as follows:
|
||||
|
||||
1. **User Action**: User updates task progress or weight in the UI
|
||||
2. **Socket Event**: Client emits socket event (UPDATE_TASK_PROGRESS or UPDATE_TASK_WEIGHT)
|
||||
3. **Server Handler**: Server processes the event in the respective handler function
|
||||
4. **Database Update**: Progress/weight value is updated in the database
|
||||
5. **Recalculation**: If needed, parent task progress is recalculated
|
||||
6. **Broadcast**: Changes are broadcast to all clients in the project room
|
||||
7. **UI Update**: Client UI updates to reflect the new progress values
|
||||
|
||||
This architecture allows for real-time updates and consistent progress calculation across all clients.
|
||||
|
||||
## Associated Files and Components
|
||||
|
||||
### Backend Files
|
||||
|
||||
1. **Socket Event Handlers**:
|
||||
- `worklenz-backend/src/socket.io/commands/on-update-task-progress.ts` - Handles manual progress updates
|
||||
- `worklenz-backend/src/socket.io/commands/on-update-task-weight.ts` - Handles task weight updates
|
||||
|
||||
2. **Database Functions**:
|
||||
- `worklenz-backend/database/migrations/20250423000000-subtask-manual-progress.sql` - Contains the `get_task_complete_ratio` function that calculates progress based on the selected method
|
||||
- Functions that support project creation/updates with progress mode settings:
|
||||
- `create_project`
|
||||
- `update_project`
|
||||
|
||||
3. **Controllers**:
|
||||
- `worklenz-backend/src/controllers/project-workload/workload-gannt-base.ts` - Contains the `calculateTaskCompleteRatio` method
|
||||
- `worklenz-backend/src/controllers/projects-controller.ts` - Handles project-level progress calculations
|
||||
- `worklenz-backend/src/controllers/tasks-controller-base.ts` - Handles task progress calculation and updates task view models
|
||||
|
||||
### Frontend Files
|
||||
|
||||
1. **Project Configuration**:
|
||||
- `worklenz-frontend/src/components/projects/project-drawer/project-drawer.tsx` - Contains UI for selecting progress method when creating/editing projects
|
||||
|
||||
2. **Progress Visualization Components**:
|
||||
- `worklenz-frontend/src/components/project-list/project-list-table/project-list-progress/progress-list-progress.tsx` - Displays project progress
|
||||
- `worklenz-frontend/src/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress.tsx` - Displays task progress
|
||||
- `worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-progress-cell/task-list-progress-cell.tsx` - Alternative task progress cell
|
||||
- `worklenz-frontend/src/components/task-list-common/task-row/task-row-progress/task-row-progress.tsx` - Displays progress in task rows
|
||||
|
||||
3. **Progress Input Components**:
|
||||
- `worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-progress/task-drawer-progress.tsx` - Component for inputting task progress/weight
|
||||
|
||||
## Choosing the Right Progress Method
|
||||
|
||||
Each progress method is suitable for different types of projects:
|
||||
|
||||
- **Manual Progress**: Best for creative work where progress is subjective
|
||||
- **Weighted Progress**: Ideal for projects where some tasks are more significant than others
|
||||
- **Time-based Progress**: Perfect for projects where time estimates are reliable and important
|
||||
|
||||
Project managers can choose the appropriate method when creating or editing a project in the project drawer, based on their team's workflow and project requirements.
|
||||
141
update-docker-env.sh
Executable file
141
update-docker-env.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to set environment variables for Docker deployment
|
||||
# Usage: ./update-docker-env.sh [hostname] [use_ssl]
|
||||
|
||||
# Default hostname if not provided
|
||||
DEFAULT_HOSTNAME="localhost"
|
||||
HOSTNAME=${1:-$DEFAULT_HOSTNAME}
|
||||
|
||||
# Check if SSL should be used
|
||||
USE_SSL=${2:-false}
|
||||
|
||||
# Set protocol prefixes based on SSL flag
|
||||
if [ "$USE_SSL" = "true" ]; then
|
||||
HTTP_PREFIX="https://"
|
||||
WS_PREFIX="wss://"
|
||||
else
|
||||
HTTP_PREFIX="http://"
|
||||
WS_PREFIX="ws://"
|
||||
fi
|
||||
|
||||
# Frontend URLs
|
||||
FRONTEND_URL="${HTTP_PREFIX}${HOSTNAME}:5000"
|
||||
MINIO_DASHBOARD_URL="${HTTP_PREFIX}${HOSTNAME}:9001"
|
||||
|
||||
# Create or overwrite frontend .env.development file
|
||||
mkdir -p worklenz-frontend
|
||||
cat > worklenz-frontend/.env.development << EOL
|
||||
# API Connection
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_SOCKET_URL=ws://localhost:3000
|
||||
|
||||
# Application Environment
|
||||
VITE_APP_TITLE=Worklenz
|
||||
VITE_APP_ENV=development
|
||||
|
||||
# Mixpanel
|
||||
VITE_MIXPANEL_TOKEN=
|
||||
|
||||
# Recaptcha
|
||||
VITE_ENABLE_RECAPTCHA=false
|
||||
VITE_RECAPTCHA_SITE_KEY=
|
||||
|
||||
# Session ID
|
||||
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
||||
EOL
|
||||
|
||||
# Create frontend .env.production file
|
||||
cat > worklenz-frontend/.env.production << EOL
|
||||
# API Connection
|
||||
VITE_API_URL=${HTTP_PREFIX}${HOSTNAME}:3000
|
||||
VITE_SOCKET_URL=${WS_PREFIX}${HOSTNAME}:3000
|
||||
|
||||
# Application Environment
|
||||
VITE_APP_TITLE=Worklenz
|
||||
VITE_APP_ENV=production
|
||||
|
||||
# Mixpanel
|
||||
VITE_MIXPANEL_TOKEN=
|
||||
|
||||
# Recaptcha
|
||||
VITE_ENABLE_RECAPTCHA=false
|
||||
VITE_RECAPTCHA_SITE_KEY=
|
||||
|
||||
# Session ID
|
||||
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
||||
EOL
|
||||
|
||||
# Create backend environment file
|
||||
mkdir -p worklenz-backend
|
||||
cat > worklenz-backend/.env << EOL
|
||||
# Server
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
SESSION_NAME=worklenz.sid
|
||||
SESSION_SECRET=$(openssl rand -base64 48)
|
||||
COOKIE_SECRET=$(openssl rand -base64 48)
|
||||
|
||||
# CORS
|
||||
SOCKET_IO_CORS=${FRONTEND_URL}
|
||||
SERVER_CORS=${FRONTEND_URL}
|
||||
|
||||
|
||||
# Google Login
|
||||
GOOGLE_CLIENT_ID="your_google_client_id"
|
||||
GOOGLE_CLIENT_SECRET="your_google_client_secret"
|
||||
GOOGLE_CALLBACK_URL="${FRONTEND_URL}/secure/google/verify"
|
||||
LOGIN_FAILURE_REDIRECT="${FRONTEND_URL}/auth/authenticating"
|
||||
LOGIN_SUCCESS_REDIRECT="${FRONTEND_URL}/auth/authenticating"
|
||||
|
||||
# Database
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=password
|
||||
DB_NAME=worklenz_db
|
||||
DB_MAX_CLIENTS=50
|
||||
USE_PG_NATIVE=true
|
||||
|
||||
# Storage Configuration
|
||||
STORAGE_PROVIDER=s3
|
||||
AWS_REGION=us-east-1
|
||||
AWS_BUCKET=worklenz-bucket
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
S3_URL=http://minio:9000
|
||||
|
||||
# Backend Directories
|
||||
BACKEND_PUBLIC_DIR=./public
|
||||
BACKEND_VIEWS_DIR=./views
|
||||
|
||||
# Host
|
||||
HOSTNAME=${HOSTNAME}
|
||||
FRONTEND_URL=${FRONTEND_URL}
|
||||
|
||||
# Email
|
||||
SOURCE_EMAIL=no-reply@example.com
|
||||
|
||||
# Notifications
|
||||
SLACK_WEBHOOK=
|
||||
|
||||
# Other Settings
|
||||
COMMIT_BUILD_IMMEDIATELY=true
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=$(openssl rand -base64 48)
|
||||
EOL
|
||||
|
||||
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
||||
echo "Created/updated environment files:"
|
||||
echo "- worklenz-frontend/.env.development (development)"
|
||||
echo "- worklenz-frontend/.env.production (production build)"
|
||||
echo "- worklenz-backend/.env"
|
||||
echo
|
||||
echo "To run with Docker Compose, use: docker-compose up -d"
|
||||
echo
|
||||
echo "Frontend URL: ${FRONTEND_URL}"
|
||||
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
||||
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
||||
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
||||
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
||||
@@ -1,5 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
build
|
||||
.scannerwork
|
||||
coverage
|
||||
.dockerignore
|
||||
.git
|
||||
*.md
|
||||
tests
|
||||
|
||||
|
||||
@@ -2,56 +2,84 @@
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
SESSION_NAME=worklenz.sid
|
||||
SESSION_SECRET="YOUR_SESSION_SECRET_HERE"
|
||||
COOKIE_SECRET="YOUR_COOKIE_SECRET_HERE"
|
||||
SESSION_SECRET="your_session_secret"
|
||||
COOKIE_SECRET="your_cookie_secret"
|
||||
|
||||
# CORS
|
||||
SOCKET_IO_CORS=http://localhost:4200
|
||||
SOCKET_IO_CORS=http://localhost:5000
|
||||
SERVER_CORS=*
|
||||
|
||||
# Database
|
||||
DB_USER=DATABASE_USER_HERE # default : worklenz_backend (update "user-permission.sql" if needed)
|
||||
DB_PASSWORD=DATABASE_PASSWORD_HERE
|
||||
DB_NAME=DATABASE_NAME_HERE # default : worklenz_db
|
||||
DB_HOST=DATABASE_HOST_HERE # default : localhost
|
||||
DB_PORT=DATABASE_PORT_HERE # default : 5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=your_db_password
|
||||
DB_NAME=worklenz_db
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_MAX_CLIENTS=50
|
||||
|
||||
# Google Login
|
||||
GOOGLE_CLIENT_ID="GOOGLE_CLIENT_ID_HERE"
|
||||
GOOGLE_CLIENT_SECRET="GOOGLE_CLIENT_SECRET_HERE"
|
||||
GOOGLE_CALLBACK_URL="http://localhost:3000/secure/google/verify"
|
||||
LOGIN_FAILURE_REDIRECT="/"
|
||||
LOGIN_SUCCESS_REDIRECT="http://localhost:4200/auth/authenticate"
|
||||
GOOGLE_CLIENT_ID="your_google_client_id"
|
||||
GOOGLE_CLIENT_SECRET="your_google_client_secret"
|
||||
GOOGLE_CALLBACK_URL="http://localhost:5000/secure/google/verify"
|
||||
LOGIN_FAILURE_REDIRECT="http://localhost:5000/auth/authenticating"
|
||||
LOGIN_SUCCESS_REDIRECT="http://localhost:5000/auth/authenticating"
|
||||
|
||||
# CLI
|
||||
ANGULAR_DIST_DIR="/path/worklenz_frontend/dist/worklenz"
|
||||
ANGULAR_SRC_DIR="/path/worklenz_frontend"
|
||||
BACKEND_PUBLIC_DIR="/path/worklenz_backend/src/public"
|
||||
BACKEND_VIEWS_DIR="/path/worklenz_backend/src/views/admin"
|
||||
COMMIT_BUILD_IMMEDIATELY=true
|
||||
ANGULAR_DIST_DIR="path/to/frontend/dist"
|
||||
ANGULAR_SRC_DIR="path/to/frontend"
|
||||
BACKEND_PUBLIC_DIR="path/to/backend/public"
|
||||
BACKEND_VIEWS_DIR="path/to/backend/views"
|
||||
COMMIT_BUILD_IMMEDIATELY=false
|
||||
|
||||
# HOST
|
||||
HOSTNAME=localhost:4200
|
||||
HOSTNAME=localhost:5000
|
||||
|
||||
# SLACK
|
||||
SLACK_WEBHOOK=SLACK_WEBHOOK_HERE
|
||||
USE_PG_NATIVE=true
|
||||
SLACK_WEBHOOK=your_slack_webhook_url
|
||||
USE_PG_NATIVE=false
|
||||
|
||||
# JWT SECRET
|
||||
JWT_SECRET=JWT_SECRET_CODE_HERE
|
||||
JWT_SECRET=your_jwt_secret
|
||||
|
||||
# AWS
|
||||
AWS_REGION="us-west-2"
|
||||
AWS_ACCESS_KEY_ID="AWS_ACCESS_KEY_ID_HERE"
|
||||
AWS_SECRET_ACCESS_KEY="AWS_SECRET_ACCESS_KEY_HERE"
|
||||
# FRONTEND_URL
|
||||
FRONTEND_URL=http://localhost:5000
|
||||
|
||||
# S3 Credentials
|
||||
REGION="us-west-2"
|
||||
BUCKET="BUCKET_NAME_HERE"
|
||||
S3_URL="S3_URL_HERE"
|
||||
S3_ACCESS_KEY_ID="S3_ACCESS_KEY_ID_HERE"
|
||||
S3_SECRET_ACCESS_KEY="S3_SECRET_ACCESS_KEY_HERE"
|
||||
# STORAGE
|
||||
STORAGE_PROVIDER=s3 # values s3 or azure
|
||||
|
||||
# SES email
|
||||
SOURCE_EMAIL="SOURCE_EMAIL_HERE" #Worklenz <noreply@worklenz.com>
|
||||
# AWS - SES
|
||||
AWS_REGION="your_aws_region"
|
||||
AWS_ACCESS_KEY_ID="your_aws_access_key_id"
|
||||
AWS_SECRET_ACCESS_KEY="your_aws_secret_access_key"
|
||||
|
||||
# S3
|
||||
S3_REGION="S3_REGION"
|
||||
S3_BUCKET="your_s3_bucket"
|
||||
S3_URL="your_s3_url"
|
||||
S3_ACCESS_KEY_ID="S3_ACCESS_KEY_ID"
|
||||
S3_SECRET_ACCESS_KEY="S3_SECRET_ACCESS_KEY"
|
||||
|
||||
# Azure Storage
|
||||
AZURE_STORAGE_ACCOUNT_NAME="your_storage_account_name"
|
||||
AZURE_STORAGE_CONTAINER="your_storage_container"
|
||||
AZURE_STORAGE_ACCOUNT_KEY="your_storage_account_key"
|
||||
AZURE_STORAGE_URL="your_storage_url"
|
||||
|
||||
# DIRECTPAY
|
||||
DP_STAGE=DEV
|
||||
DP_URL=your_url
|
||||
DP_MERCHANT_ID=your_merchant_id
|
||||
DP_SECRET_KEY=your_secret_key
|
||||
DP_API_KEY=your_api_key
|
||||
|
||||
CONTACT_US_EMAIL=support@example.com
|
||||
|
||||
GOOGLE_CAPTCHA_SECRET_KEY=your_captcha_secret_key
|
||||
GOOGLE_CAPTCHA_PASS_SCORE=0.8
|
||||
|
||||
# Email Cronjobs
|
||||
ENABLE_EMAIL_CRONJOBS=true
|
||||
|
||||
# RECURRING_JOBS
|
||||
ENABLE_RECURRING_JOBS=true
|
||||
RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5"
|
||||
3
worklenz-backend/.gitignore
vendored
3
worklenz-backend/.gitignore
vendored
@@ -20,9 +20,6 @@ coverage
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
# Use the official Node.js 18 image as a base
|
||||
FROM node:18
|
||||
# --- Stage 1: Build ---
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
ARG RELEASE_VERSION
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
curl \
|
||||
postgresql-server-dev-all \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create and set the working directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install global dependencies
|
||||
RUN npm install -g ts-node typescript grunt grunt-cli
|
||||
|
||||
# Copy package.json and package-lock.json (if available)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install app dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Run the build script to compile TypeScript to JavaScript
|
||||
RUN npm run build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
RUN echo "$RELEASE_VERSION" > release
|
||||
|
||||
# --- Stage 2: Production Image ---
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y libpq5 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/src/app/package*.json ./
|
||||
COPY --from=builder /usr/src/app/build ./build
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/release ./release
|
||||
COPY --from=builder /usr/src/app/worklenz-email-templates ./worklenz-email-templates
|
||||
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build/bin/www"]
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
module.exports = function (grunt) {
|
||||
|
||||
// Project configuration.
|
||||
grunt.initConfig({
|
||||
pkg: grunt.file.readJSON("package.json"),
|
||||
clean: {
|
||||
dist: "build"
|
||||
},
|
||||
compress: require("./grunt/grunt-compress"),
|
||||
copy: {
|
||||
main: {
|
||||
files: [
|
||||
{expand: true, cwd: "src", src: ["public/**"], dest: "build"},
|
||||
{expand: true, cwd: "src", src: ["views/**"], dest: "build"},
|
||||
{expand: true, cwd: "landing-page-assets", src: ["**"], dest: "build/public/assets"},
|
||||
{expand: true, cwd: "src", src: ["shared/sample-data.json"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "src", src: ["shared/templates/**"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "src", src: ["shared/postgresql-error-codes.json"], dest: "build", filter: "isFile"},
|
||||
]
|
||||
},
|
||||
packages: {
|
||||
files: [
|
||||
{expand: true, cwd: "", src: [".env"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: [".gitignore"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["release"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["jest.config.js"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["package.json"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["package-lock.json"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["common_modules/**"], dest: "build"}
|
||||
]
|
||||
}
|
||||
},
|
||||
sync: {
|
||||
main: {
|
||||
files: [
|
||||
{cwd: "src", src: ["views/**", "public/**"], dest: "build/"}, // makes all src relative to cwd
|
||||
],
|
||||
verbose: true,
|
||||
failOnError: true,
|
||||
compareUsing: "md5"
|
||||
}
|
||||
},
|
||||
uglify: {
|
||||
all: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build",
|
||||
src: "**/*.js",
|
||||
dest: "build"
|
||||
}]
|
||||
},
|
||||
controllers: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build",
|
||||
src: "controllers/*.js",
|
||||
dest: "build"
|
||||
}]
|
||||
},
|
||||
routes: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build",
|
||||
src: "routes/**/*.js",
|
||||
dest: "build"
|
||||
}]
|
||||
},
|
||||
assets: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build",
|
||||
src: "public/assets/**/*.js",
|
||||
dest: "build"
|
||||
}]
|
||||
}
|
||||
},
|
||||
shell: {
|
||||
tsc: {
|
||||
command: "tsc --build tsconfig.prod.json"
|
||||
},
|
||||
esbuild: {
|
||||
// command: "esbuild `find src -type f -name '*.ts'` --platform=node --minify=false --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=build"
|
||||
command: "node esbuild && node cli/esbuild-patch"
|
||||
},
|
||||
tsc_dev: {
|
||||
command: "tsc --build tsconfig.json"
|
||||
},
|
||||
swagger: {
|
||||
command: "node ./cli/swagger"
|
||||
},
|
||||
inline_queries: {
|
||||
command: "node ./cli/inline-queries"
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
scripts: {
|
||||
files: ["src/**/*.ts"],
|
||||
tasks: ["shell:tsc_dev"],
|
||||
options: {
|
||||
debounceDelay: 250,
|
||||
spawn: false,
|
||||
}
|
||||
},
|
||||
other: {
|
||||
files: ["src/**/*.pug", "landing-page-assets/**"],
|
||||
tasks: ["sync"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
grunt.registerTask("clean", ["clean"]);
|
||||
grunt.registerTask("copy", ["copy:main"]);
|
||||
grunt.registerTask("swagger", ["shell:swagger"]);
|
||||
grunt.registerTask("build:tsc", ["shell:tsc"]);
|
||||
grunt.registerTask("build", ["clean", "shell:tsc", "copy:main", "compress"]);
|
||||
grunt.registerTask("build:es", ["clean", "shell:esbuild", "copy:main", "uglify:assets", "compress"]);
|
||||
grunt.registerTask("build:strict", ["clean", "shell:tsc", "copy:packages", "uglify:all", "copy:main", "compress"]);
|
||||
grunt.registerTask("dev", ["clean", "copy:main", "shell:tsc_dev", "shell:inline_queries", "watch"]);
|
||||
|
||||
// Load the plugin that provides the "uglify" task.
|
||||
grunt.loadNpmTasks("grunt-contrib-watch");
|
||||
grunt.loadNpmTasks("grunt-contrib-clean");
|
||||
grunt.loadNpmTasks("grunt-contrib-copy");
|
||||
grunt.loadNpmTasks("grunt-contrib-uglify");
|
||||
grunt.loadNpmTasks("grunt-contrib-compress");
|
||||
grunt.loadNpmTasks("grunt-shell");
|
||||
grunt.loadNpmTasks("grunt-sync");
|
||||
|
||||
// Default task(s).
|
||||
grunt.registerTask("default", []);
|
||||
};
|
||||
@@ -1,81 +1,96 @@
|
||||
# Worklenz Backend
|
||||
|
||||
1. **Open your IDE:**
|
||||
This is the Express.js backend for the Worklenz project management application.
|
||||
|
||||
Open the project directory in your preferred code editor or IDE like Visual Studio Code.
|
||||
## Getting Started
|
||||
|
||||
2. **Configure Environment Variables:**
|
||||
Follow these steps to set up the backend for development:
|
||||
|
||||
- Create a copy of the `.env.template` file and name it `.env`.
|
||||
- Update the required fields in `.env` with the specific information.
|
||||
1. **Configure Environment Variables:**
|
||||
|
||||
3. **Restore Database**
|
||||
- Create a new database named `worklenz_db` on your local PostgreSQL server.
|
||||
- Update the `DATABASE_NAME` and `PASSWORD` in the `database/6_user_permission.sql` with your DB credentials.
|
||||
- Open a query console and execute the queries from the .sql files in the `database` directories, following the provided order.
|
||||
- Create a copy of the `.env.example` file and name it `.env`.
|
||||
- Update the required fields in `.env` with your specific configuration.
|
||||
|
||||
4. **Install Dependencies:**
|
||||
2. **Set up Database:**
|
||||
- Create a new database named `worklenz_db` on your local PostgreSQL server.
|
||||
- Update the database connection details in your `.env` file.
|
||||
- Execute the SQL setup files in the correct order:
|
||||
|
||||
```bash
|
||||
# From your PostgreSQL client or command line
|
||||
psql -U your_username -d worklenz_db -f database/sql/0_extensions.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/1_tables.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/indexes.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/4_functions.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/triggers.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/3_views.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/2_dml.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/5_database_user.sql
|
||||
```
|
||||
|
||||
Alternatively, you can use the provided shell script:
|
||||
|
||||
```bash
|
||||
# Make sure the script is executable
|
||||
chmod +x database/00-init-db.sh
|
||||
# Run the script (may need modifications for local execution)
|
||||
./database/00-init-db.sh
|
||||
```
|
||||
|
||||
3. **Install Dependencies:**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
This command installs all the necessary libraries required to run the project.
|
||||
4. **Run the Development Server:**
|
||||
|
||||
5. **Run the Development Server:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**a. Start the TypeScript compiler:**
|
||||
This starts the development server with hot reloading enabled.
|
||||
|
||||
Open a new terminal window and run the following command:
|
||||
5. **Build for Production:**
|
||||
|
||||
```bash
|
||||
grunt dev
|
||||
```
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This starts the `grunt` task runner, which compiles TypeScript code into JavaScript.
|
||||
This will compile the TypeScript code into JavaScript for production use.
|
||||
|
||||
**b. Start the development server:**
|
||||
6. **Start Production Server:**
|
||||
|
||||
Open another separate terminal window and run the following command:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
## API Documentation
|
||||
|
||||
This starts the development server allowing you to work on the project.
|
||||
The API endpoints are organized into logical controllers and follow RESTful design principles. The main API routes are prefixed with `/api/v1`.
|
||||
|
||||
6. **Run the Production Server:**
|
||||
### Authentication
|
||||
|
||||
**a. Compile TypeScript to JavaScript:**
|
||||
Authentication is handled via JWT tokens. Protected routes require a valid token in the Authorization header.
|
||||
|
||||
Open a new terminal window and run the following command:
|
||||
### File Storage
|
||||
|
||||
```bash
|
||||
grunt build
|
||||
```
|
||||
The application supports both S3-compatible storage and Azure Blob Storage for file uploads. Configure your preferred storage option in the `.env` file.
|
||||
|
||||
This starts the `grunt` task runner, which compiles TypeScript code into JavaScript for production use.
|
||||
## Development Guidelines
|
||||
|
||||
**b. Start the production server:**
|
||||
- Code should be written in TypeScript
|
||||
- Follow the established patterns for controllers, services, and middlewares
|
||||
- Add proper error handling for all API endpoints
|
||||
- Write unit tests for critical functionality
|
||||
- Document API endpoints with clear descriptions and examples
|
||||
|
||||
Once the compilation is complete, run the following command in the same terminal window:
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
This starts the production server for your application.
|
||||
## Docker Support
|
||||
|
||||
### CLI
|
||||
|
||||
- Create controller: `$ node new controller Test`
|
||||
- Create angular release: `$ node new release`
|
||||
|
||||
### Developement Rules
|
||||
|
||||
- Controllers should only generate/create using the CLI (`node new controller Projects`)
|
||||
- Validations should only be done using a middleware placed under src/validators/ and used inside the routers (E.g., api-router.ts)
|
||||
- Validators should only generate/create using the CLI (`node new vaidator projects-params`)
|
||||
|
||||
## Pull submodules
|
||||
- git submodule update --init --recursive
|
||||
The backend can be run in a Docker container. See the main project README for Docker setup instructions.
|
||||
|
||||
88
worklenz-backend/database/00_init.sh
Normal file
88
worklenz-backend/database/00_init.sh
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting database initialization..."
|
||||
|
||||
SQL_DIR="/docker-entrypoint-initdb.d/sql"
|
||||
MIGRATIONS_DIR="/docker-entrypoint-initdb.d/migrations"
|
||||
BACKUP_DIR="/docker-entrypoint-initdb.d/pg_backups"
|
||||
|
||||
# --------------------------------------------
|
||||
# 🗄️ STEP 1: Attempt to restore latest backup
|
||||
# --------------------------------------------
|
||||
|
||||
if [ -d "$BACKUP_DIR" ]; then
|
||||
LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | head -n 1)
|
||||
else
|
||||
LATEST_BACKUP=""
|
||||
fi
|
||||
|
||||
if [ -f "$LATEST_BACKUP" ]; then
|
||||
echo "🗄️ Found latest backup: $LATEST_BACKUP"
|
||||
echo "⏳ Restoring from backup..."
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$LATEST_BACKUP"
|
||||
echo "✅ Backup restoration complete. Skipping schema and migrations."
|
||||
exit 0
|
||||
else
|
||||
echo "ℹ️ No valid backup found. Proceeding with base schema and migrations."
|
||||
fi
|
||||
|
||||
# --------------------------------------------
|
||||
# 🏗️ STEP 2: Continue with base schema setup
|
||||
# --------------------------------------------
|
||||
|
||||
# Create migrations table if it doesn't exist
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
"
|
||||
|
||||
# List of base schema files to execute in order
|
||||
BASE_SQL_FILES=(
|
||||
"0_extensions.sql"
|
||||
"1_tables.sql"
|
||||
"indexes.sql"
|
||||
"4_functions.sql"
|
||||
"triggers.sql"
|
||||
"3_views.sql"
|
||||
"2_dml.sql"
|
||||
"5_database_user.sql"
|
||||
)
|
||||
|
||||
echo "Running base schema SQL files in order..."
|
||||
|
||||
for file in "${BASE_SQL_FILES[@]}"; do
|
||||
full_path="$SQL_DIR/$file"
|
||||
if [ -f "$full_path" ]; then
|
||||
echo "Executing $file..."
|
||||
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$full_path"
|
||||
else
|
||||
echo "WARNING: $file not found, skipping."
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Base schema SQL execution complete."
|
||||
|
||||
# --------------------------------------------
|
||||
# 🚀 STEP 3: Apply SQL migrations
|
||||
# --------------------------------------------
|
||||
|
||||
if [ -d "$MIGRATIONS_DIR" ] && compgen -G "$MIGRATIONS_DIR/*.sql" > /dev/null; then
|
||||
echo "Applying migrations..."
|
||||
for f in "$MIGRATIONS_DIR"/*.sql; do
|
||||
version=$(basename "$f")
|
||||
if ! psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT 1 FROM schema_migrations WHERE version = '$version'" | grep -q 1; then
|
||||
echo "Applying migration: $version"
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$f"
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "INSERT INTO schema_migrations (version) VALUES ('$version');"
|
||||
else
|
||||
echo "Skipping already applied migration: $version"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "No migration files found or directory is empty, skipping migrations."
|
||||
fi
|
||||
|
||||
echo "🎉 Database initialization completed successfully."
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,77 +0,0 @@
|
||||
CREATE OR REPLACE FUNCTION sys_insert_task_priorities() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO task_priorities (name, value, color_code) VALUES ('Low', 0, '#75c997');
|
||||
INSERT INTO task_priorities (name, value, color_code) VALUES ('Medium', 1, '#fbc84c');
|
||||
INSERT INTO task_priorities (name, value, color_code) VALUES ('High', 2, '#f37070');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_access_levels() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO project_access_levels (name, key)
|
||||
VALUES ('Admin', 'ADMIN');
|
||||
INSERT INTO project_access_levels (name, key)
|
||||
VALUES ('Member', 'MEMBER');
|
||||
INSERT INTO project_access_levels (name, key)
|
||||
VALUES ('Project Manager', 'PROJECT_MANAGER');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_task_status_categories() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO sys_task_status_categories (name, color_code, index, is_todo)
|
||||
VALUES ('To do', '#a9a9a9', 0, TRUE);
|
||||
INSERT INTO sys_task_status_categories (name, color_code, index, is_doing)
|
||||
VALUES ('Doing', '#70a6f3', 1, TRUE);
|
||||
INSERT INTO sys_task_status_categories (name, color_code, index, is_done)
|
||||
VALUES ('Done', '#75c997', 2, TRUE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_statuses() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO sys_project_statuses (name, color_code, icon, sort_order, is_default)
|
||||
VALUES ('Cancelled', '#f37070', 'close-circle', 0, FALSE),
|
||||
('Blocked', '#cbc8a1', 'stop', 1, FALSE),
|
||||
('On Hold', '#cbc8a1', 'stop', 2, FALSE),
|
||||
('Proposed', '#cbc8a1', 'clock-circle', 3, TRUE),
|
||||
('In Planning', '#cbc8a1', 'clock-circle', 4, FALSE),
|
||||
('In Progress', '#80ca79', 'clock-circle', 5, FALSE),
|
||||
('Completed', '#80ca79', 'check-circle', 6, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_healths() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('Not Set', '#a9a9a9', 0, TRUE);
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('Needs Attention', '#fbc84c', 1, FALSE);
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('At Risk', '#f37070', 2, FALSE);
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('Good', '#75c997', 3, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
SELECT sys_insert_task_priorities();
|
||||
SELECT sys_insert_project_access_levels();
|
||||
SELECT sys_insert_task_status_categories();
|
||||
SELECT sys_insert_project_statuses();
|
||||
SELECT sys_insert_project_healths();
|
||||
|
||||
DROP FUNCTION sys_insert_task_priorities();
|
||||
DROP FUNCTION sys_insert_project_access_levels();
|
||||
DROP FUNCTION sys_insert_task_status_categories();
|
||||
DROP FUNCTION sys_insert_project_statuses();
|
||||
DROP FUNCTION sys_insert_project_healths();
|
||||
|
||||
INSERT INTO timezones (name, abbrev, utc_offset)
|
||||
SELECT name, abbrev, utc_offset
|
||||
FROM pg_timezone_names;
|
||||
@@ -1,34 +0,0 @@
|
||||
CREATE VIEW task_labels_view(name, task_id, label_id) AS
|
||||
SELECT (SELECT team_labels.name
|
||||
FROM team_labels
|
||||
WHERE team_labels.id = task_labels.label_id) AS name,
|
||||
task_labels.task_id,
|
||||
task_labels.label_id
|
||||
FROM task_labels;
|
||||
|
||||
CREATE VIEW tasks_with_status_view(task_id, parent_task_id, is_todo, is_doing, is_done) AS
|
||||
SELECT tasks.id AS task_id,
|
||||
tasks.parent_task_id,
|
||||
stsc.is_todo,
|
||||
stsc.is_doing,
|
||||
stsc.is_done
|
||||
FROM tasks
|
||||
JOIN task_statuses ts ON tasks.status_id = ts.id
|
||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||
WHERE tasks.archived IS FALSE;
|
||||
|
||||
CREATE VIEW team_member_info_view(avatar_url, email, name, user_id, team_member_id, team_id) AS
|
||||
SELECT u.avatar_url,
|
||||
COALESCE(u.email, (SELECT email_invitations.email
|
||||
FROM email_invitations
|
||||
WHERE email_invitations.team_member_id = team_members.id)) AS email,
|
||||
COALESCE(u.name, (SELECT email_invitations.name
|
||||
FROM email_invitations
|
||||
WHERE email_invitations.team_member_id = team_members.id)) AS name,
|
||||
u.id AS user_id,
|
||||
team_members.id AS team_member_id,
|
||||
team_members.team_id
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
-- Default ROLE : worklenz_client
|
||||
-- Default USER : worklenz_backend
|
||||
-- Change DATABASE_NAME, ROLE, PASSWORD and USER as needed.
|
||||
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
CREATE ROLE worklenz_client;
|
||||
|
||||
GRANT CONNECT ON DATABASE 'DATABASE_NAME' TO worklenz_client;
|
||||
GRANT INSERT, SELECT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO worklenz_client;
|
||||
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON task_priorities FROM worklenz_client;
|
||||
GRANT SELECT ON task_priorities TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON project_access_levels FROM worklenz_client;
|
||||
GRANT SELECT ON project_access_levels TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON timezones FROM worklenz_client;
|
||||
GRANT SELECT ON timezones TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON worklenz_alerts FROM worklenz_client;
|
||||
GRANT SELECT ON worklenz_alerts TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON sys_task_status_categories FROM worklenz_client;
|
||||
GRANT SELECT ON sys_task_status_categories TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON sys_project_statuses FROM worklenz_client;
|
||||
GRANT SELECT ON sys_project_statuses TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON sys_project_healths FROM worklenz_client;
|
||||
GRANT SELECT ON sys_project_healths TO worklenz_client;
|
||||
|
||||
CREATE USER worklenz_backend WITH PASSWORD 'PASSWORD';
|
||||
GRANT worklenz_client TO worklenz_backend;
|
||||
@@ -1 +1,36 @@
|
||||
All database DDLs, DMLs and migrations relates to the application should be stored here as well.
|
||||
|
||||
# Worklenz Database
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `sql/` - Contains all SQL files needed for database initialization
|
||||
- `migrations/` - Contains database migration scripts
|
||||
- `00-init-db.sh` - Initialization script that executes SQL files in the correct order
|
||||
|
||||
## SQL File Execution Order
|
||||
|
||||
The database initialization files should be executed in the following order:
|
||||
|
||||
1. `sql/0_extensions.sql` - PostgreSQL extensions
|
||||
2. `sql/1_tables.sql` - Table definitions and constraints
|
||||
3. `sql/indexes.sql` - All database indexes
|
||||
4. `sql/4_functions.sql` - Database functions
|
||||
5. `sql/triggers.sql` - Database triggers
|
||||
6. `sql/3_views.sql` - Database views
|
||||
7. `sql/2_dml.sql` - Data Manipulation Language statements (inserts, updates)
|
||||
8. `sql/5_database_user.sql` - Database user setup
|
||||
|
||||
## Docker-based Setup
|
||||
|
||||
In the Docker environment, we use a shell script called `00-init-db.sh` to control the SQL file execution order:
|
||||
|
||||
1. The shell script creates a `sql/` subdirectory if it doesn't exist
|
||||
2. It copies all .sql files into this subdirectory
|
||||
3. It executes the SQL files from the subdirectory in the correct order
|
||||
|
||||
This approach prevents the SQL files from being executed twice by Docker's automatic initialization mechanism, which would cause errors for objects that already exist.
|
||||
|
||||
## Manual Setup
|
||||
|
||||
If you're setting up the database manually, please follow the execution order listed above. Ensure your SQL files are in the `sql/` subdirectory before executing the script.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
-- Performance indexes for optimized tasks queries
|
||||
-- Migration: 20250115000000-performance-indexes.sql
|
||||
|
||||
-- Composite index for main task filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
|
||||
ON tasks(project_id, archived, parent_task_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for status joins
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
|
||||
ON tasks(status_id, project_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for assignees lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
|
||||
ON tasks_assignees(task_id, team_member_id);
|
||||
|
||||
-- Index for phase lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
|
||||
ON task_phase(task_id, phase_id);
|
||||
|
||||
-- Index for subtask counting
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
|
||||
ON tasks(parent_task_id, archived)
|
||||
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
|
||||
|
||||
-- Index for labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
|
||||
ON task_labels(task_id, label_id);
|
||||
|
||||
-- Index for comments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
|
||||
ON task_comments(task_id);
|
||||
|
||||
-- Index for attachments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
|
||||
ON task_attachments(task_id);
|
||||
|
||||
-- Index for work log aggregation
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
|
||||
ON task_work_log(task_id);
|
||||
|
||||
-- Index for subscribers check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
|
||||
ON task_subscribers(task_id);
|
||||
|
||||
-- Index for dependencies check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
|
||||
ON task_dependencies(task_id);
|
||||
|
||||
-- Index for timers lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
|
||||
ON task_timers(task_id, user_id);
|
||||
|
||||
-- Index for custom columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
|
||||
ON cc_column_values(task_id);
|
||||
|
||||
-- Index for team member info view optimization
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
|
||||
ON team_members(team_id, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Index for notification settings
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
|
||||
ON notification_settings(user_id, team_id);
|
||||
|
||||
-- Index for task status categories
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
|
||||
ON task_statuses(category_id, project_id);
|
||||
|
||||
-- Index for project phases
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
|
||||
ON project_phases(project_id, sort_index);
|
||||
|
||||
-- Index for task priorities
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
|
||||
ON task_priorities(value);
|
||||
|
||||
-- Index for team labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
|
||||
ON team_labels(team_id);
|
||||
|
||||
-- NEW INDEXES FOR PERFORMANCE OPTIMIZATION --
|
||||
|
||||
-- Composite index for task main query optimization (covers most WHERE conditions)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
|
||||
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for sorting by sort_order with project filter
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
|
||||
ON tasks(project_id, sort_order)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for email_invitations to optimize team_member_info_view
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
|
||||
ON email_invitations(team_member_id);
|
||||
|
||||
-- Covering index for task status with category information
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
|
||||
ON task_statuses(id, category_id, project_id);
|
||||
|
||||
-- Index for task aggregation queries (parent task progress calculation)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
|
||||
ON tasks(parent_task_id, status_id, archived)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for project team member filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
|
||||
ON team_members(team_id, active, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Covering index for tasks with frequently accessed columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
|
||||
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task search functionality
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
|
||||
ON tasks USING gin(to_tsvector('english', name))
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for date-based filtering (if used)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
|
||||
ON tasks(project_id, start_date, end_date)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task timers with user filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
|
||||
ON task_timers(user_id, task_id);
|
||||
|
||||
-- Index for sys_task_status_categories lookups
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
|
||||
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Fix window function error in task sort optimized functions
|
||||
-- Error: window functions are not allowed in UPDATE
|
||||
|
||||
-- Replace the optimized sort functions to avoid CTE usage in UPDATE statements
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
|
||||
IF (_to_index = -1)
|
||||
THEN
|
||||
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
|
||||
END IF;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order < _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Replace the second optimized sort function
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order <= _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order >= _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Add simple bulk update function as alternative
|
||||
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_update_record RECORD;
|
||||
BEGIN
|
||||
-- Simple approach: update each task's sort_order from the provided array
|
||||
FOR _update_record IN
|
||||
SELECT
|
||||
(item->>'task_id')::uuid as task_id,
|
||||
(item->>'sort_order')::int as sort_order,
|
||||
(item->>'status_id')::uuid as status_id,
|
||||
(item->>'priority_id')::uuid as priority_id,
|
||||
(item->>'phase_id')::uuid as phase_id
|
||||
FROM json_array_elements(_updates) as item
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET
|
||||
sort_order = _update_record.sort_order,
|
||||
status_id = COALESCE(_update_record.status_id, status_id),
|
||||
priority_id = COALESCE(_update_record.priority_id, priority_id)
|
||||
WHERE id = _update_record.task_id;
|
||||
|
||||
-- Handle phase updates separately since it's in a different table
|
||||
IF _update_record.phase_id IS NOT NULL THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Migration: Add manual task progress
|
||||
-- Date: 2025-04-22
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Add manual progress fields to tasks table
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN IF NOT EXISTS manual_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS progress_value INTEGER DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS weight INTEGER DEFAULT NULL;
|
||||
|
||||
-- Update function to consider manual progress
|
||||
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_done FLOAT = 0;
|
||||
_sub_tasks_done FLOAT = 0;
|
||||
_sub_tasks_count FLOAT = 0;
|
||||
_total_completed FLOAT = 0;
|
||||
_total_tasks FLOAT = 0;
|
||||
_ratio FLOAT = 0;
|
||||
_is_manual BOOLEAN = FALSE;
|
||||
_manual_value INTEGER = NULL;
|
||||
BEGIN
|
||||
-- Check if manual progress is set
|
||||
SELECT manual_progress, progress_value
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _is_manual, _manual_value;
|
||||
|
||||
-- If manual progress is enabled and has a value, use it directly
|
||||
IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _manual_value,
|
||||
'total_completed', 0,
|
||||
'total_tasks', 0,
|
||||
'is_manual', TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Otherwise calculate automatically as before
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks > 0 THEN
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
ELSE
|
||||
_ratio = _parent_task_done * 100;
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _ratio,
|
||||
'total_completed', _total_completed,
|
||||
'total_tasks', _total_tasks,
|
||||
'is_manual', FALSE
|
||||
);
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,687 @@
|
||||
-- Migration: Enhance manual task progress with subtask support
|
||||
-- Date: 2025-04-23
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update function to consider subtask manual progress when calculating parent task progress
|
||||
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_done FLOAT = 0;
|
||||
_sub_tasks_done FLOAT = 0;
|
||||
_sub_tasks_count FLOAT = 0;
|
||||
_total_completed FLOAT = 0;
|
||||
_total_tasks FLOAT = 0;
|
||||
_ratio FLOAT = 0;
|
||||
_is_manual BOOLEAN = FALSE;
|
||||
_manual_value INTEGER = NULL;
|
||||
_project_id UUID;
|
||||
_use_manual_progress BOOLEAN = FALSE;
|
||||
_use_weighted_progress BOOLEAN = FALSE;
|
||||
_use_time_progress BOOLEAN = FALSE;
|
||||
BEGIN
|
||||
-- Check if manual progress is set for this task
|
||||
SELECT manual_progress, progress_value, project_id
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _is_manual, _manual_value, _project_id;
|
||||
|
||||
-- Check if the project uses manual progress
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT COALESCE(use_manual_progress, FALSE),
|
||||
COALESCE(use_weighted_progress, FALSE),
|
||||
COALESCE(use_time_progress, FALSE)
|
||||
FROM projects
|
||||
WHERE id = _project_id
|
||||
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
|
||||
END IF;
|
||||
|
||||
-- Get all subtasks
|
||||
SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id AND archived IS FALSE
|
||||
INTO _sub_tasks_count;
|
||||
|
||||
-- If manual progress is enabled and has a value AND there are no subtasks, use it directly
|
||||
IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _manual_value,
|
||||
'total_completed', 0,
|
||||
'total_tasks', 0,
|
||||
'is_manual', TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- If there are no subtasks, just use the parent task's status
|
||||
IF _sub_tasks_count = 0 THEN
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
_ratio = _parent_task_done * 100;
|
||||
ELSE
|
||||
-- If project uses manual progress, calculate based on subtask manual progress values
|
||||
IF _use_manual_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(AVG(progress_value), 0)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
-- If project uses weighted progress, calculate based on subtask weights
|
||||
ELSIF _use_weighted_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(weight, 100) AS weight
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
-- If project uses time-based progress, calculate based on estimated time
|
||||
ELSIF _use_time_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(total_minutes, 0) AS estimated_minutes
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation based on completion status
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks = 0 THEN
|
||||
_ratio = 0;
|
||||
ELSE
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Ensure ratio is between 0 and 100
|
||||
IF _ratio < 0 THEN
|
||||
_ratio = 0;
|
||||
ELSIF _ratio > 100 THEN
|
||||
_ratio = 100;
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _ratio,
|
||||
'total_completed', _total_completed,
|
||||
'total_tasks', _total_tasks,
|
||||
'is_manual', _is_manual
|
||||
);
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_project(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_user_id UUID;
|
||||
_team_id UUID;
|
||||
_client_id UUID;
|
||||
_project_id UUID;
|
||||
_project_manager_team_member_id UUID;
|
||||
_client_name TEXT;
|
||||
_project_name TEXT;
|
||||
BEGIN
|
||||
-- need a test, can be throw errors
|
||||
_client_name = TRIM((_body ->> 'client_name')::TEXT);
|
||||
_project_name = TRIM((_body ->> 'name')::TEXT);
|
||||
|
||||
-- add inside the controller
|
||||
_user_id = (_body ->> 'user_id')::UUID;
|
||||
_team_id = (_body ->> 'team_id')::UUID;
|
||||
_project_manager_team_member_id = (_body ->> 'team_member_id')::UUID;
|
||||
|
||||
-- cache exists client if exists
|
||||
SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id;
|
||||
|
||||
-- insert client if not exists
|
||||
IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE
|
||||
THEN
|
||||
INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id;
|
||||
END IF;
|
||||
|
||||
-- check whether the project name is already in
|
||||
IF EXISTS(
|
||||
SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name)
|
||||
AND team_id = _team_id AND id != (_body ->> 'id')::UUID
|
||||
)
|
||||
THEN
|
||||
RAISE 'PROJECT_EXISTS_ERROR:%', _project_name;
|
||||
END IF;
|
||||
|
||||
-- update the project
|
||||
UPDATE projects
|
||||
SET name = _project_name,
|
||||
notes = (_body ->> 'notes')::TEXT,
|
||||
color_code = (_body ->> 'color_code')::TEXT,
|
||||
status_id = (_body ->> 'status_id')::UUID,
|
||||
health_id = (_body ->> 'health_id')::UUID,
|
||||
key = (_body ->> 'key')::TEXT,
|
||||
start_date = (_body ->> 'start_date')::TIMESTAMPTZ,
|
||||
end_date = (_body ->> 'end_date')::TIMESTAMPTZ,
|
||||
client_id = _client_id,
|
||||
folder_id = (_body ->> 'folder_id')::UUID,
|
||||
category_id = (_body ->> 'category_id')::UUID,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
estimated_working_days = (_body ->> 'working_days')::INTEGER,
|
||||
estimated_man_days = (_body ->> 'man_days')::INTEGER,
|
||||
hours_per_day = (_body ->> 'hours_per_day')::INTEGER,
|
||||
use_manual_progress = COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE),
|
||||
use_weighted_progress = COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE),
|
||||
use_time_progress = COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE)
|
||||
WHERE id = (_body ->> 'id')::UUID
|
||||
AND team_id = _team_id
|
||||
RETURNING id INTO _project_id;
|
||||
|
||||
UPDATE project_members SET project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'MEMBER') WHERE project_id = _project_id;
|
||||
|
||||
IF NOT (_project_manager_team_member_id IS NULL)
|
||||
THEN
|
||||
PERFORM update_project_manager(_project_manager_team_member_id, _project_id::UUID);
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'id', _project_id,
|
||||
'name', (_body ->> 'name')::TEXT,
|
||||
'project_manager_id', _project_manager_team_member_id::UUID
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 3. Also modify the create_project function to handle the new fields during project creation
|
||||
CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project_id UUID;
|
||||
_user_id UUID;
|
||||
_team_id UUID;
|
||||
_team_member_id UUID;
|
||||
_client_id UUID;
|
||||
_client_name TEXT;
|
||||
_project_name TEXT;
|
||||
_project_created_log TEXT;
|
||||
_project_member_added_log TEXT;
|
||||
_project_created_log_id UUID;
|
||||
_project_manager_team_member_id UUID;
|
||||
_project_key TEXT;
|
||||
BEGIN
|
||||
_client_name = TRIM((_body ->> 'client_name')::TEXT);
|
||||
_project_name = TRIM((_body ->> 'name')::TEXT);
|
||||
_project_key = TRIM((_body ->> 'key')::TEXT);
|
||||
_project_created_log = (_body ->> 'project_created_log')::TEXT;
|
||||
_project_member_added_log = (_body ->> 'project_member_added_log')::TEXT;
|
||||
_user_id = (_body ->> 'user_id')::UUID;
|
||||
_team_id = (_body ->> 'team_id')::UUID;
|
||||
_project_manager_team_member_id = (_body ->> 'project_manager_id')::UUID;
|
||||
|
||||
SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id INTO _team_member_id;
|
||||
|
||||
-- cache exists client if exists
|
||||
SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id;
|
||||
|
||||
-- insert client if not exists
|
||||
IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE
|
||||
THEN
|
||||
INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id;
|
||||
END IF;
|
||||
|
||||
-- check whether the project name is already in
|
||||
IF EXISTS(SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) AND team_id = _team_id)
|
||||
THEN
|
||||
RAISE 'PROJECT_EXISTS_ERROR:%', _project_name;
|
||||
END IF;
|
||||
|
||||
-- create the project
|
||||
INSERT
|
||||
INTO projects (name, key, color_code, start_date, end_date, team_id, notes, owner_id, status_id, health_id, folder_id,
|
||||
category_id, estimated_working_days, estimated_man_days, hours_per_day,
|
||||
use_manual_progress, use_weighted_progress, use_time_progress, client_id)
|
||||
VALUES (_project_name,
|
||||
UPPER(_project_key),
|
||||
(_body ->> 'color_code')::TEXT,
|
||||
(_body ->> 'start_date')::TIMESTAMPTZ,
|
||||
(_body ->> 'end_date')::TIMESTAMPTZ,
|
||||
_team_id,
|
||||
(_body ->> 'notes')::TEXT,
|
||||
_user_id,
|
||||
(_body ->> 'status_id')::UUID,
|
||||
(_body ->> 'health_id')::UUID,
|
||||
(_body ->> 'folder_id')::UUID,
|
||||
(_body ->> 'category_id')::UUID,
|
||||
(_body ->> 'working_days')::INTEGER,
|
||||
(_body ->> 'man_days')::INTEGER,
|
||||
(_body ->> 'hours_per_day')::INTEGER,
|
||||
COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE),
|
||||
COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE),
|
||||
COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE),
|
||||
_client_id)
|
||||
RETURNING id INTO _project_id;
|
||||
|
||||
-- register the project log
|
||||
INSERT INTO project_logs (project_id, team_id, description)
|
||||
VALUES (_project_id, _team_id, _project_created_log)
|
||||
RETURNING id INTO _project_created_log_id;
|
||||
|
||||
-- insert the project creator as a project member
|
||||
INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id)
|
||||
VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'ADMIN'),
|
||||
_project_id,
|
||||
(SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE));
|
||||
|
||||
-- insert statuses
|
||||
INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order)
|
||||
VALUES ('To Do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE), 0);
|
||||
INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order)
|
||||
VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE), 1);
|
||||
INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order)
|
||||
VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE), 2);
|
||||
|
||||
-- insert default project columns
|
||||
PERFORM insert_task_list_columns(_project_id);
|
||||
|
||||
-- add project manager role if exists
|
||||
IF NOT is_null_or_empty(_project_manager_team_member_id) THEN
|
||||
PERFORM update_project_manager(_project_manager_team_member_id, _project_id);
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'id', _project_id,
|
||||
'name', _project_name,
|
||||
'project_created_log_id', _project_created_log_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 4. Update the getById function to include the new fields in the response
|
||||
CREATE OR REPLACE FUNCTION getProjectById(_project_id UUID, _team_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
BEGIN
|
||||
SELECT ROW_TO_JSON(rec) INTO _result
|
||||
FROM (SELECT p.id,
|
||||
p.name,
|
||||
p.key,
|
||||
p.color_code,
|
||||
p.start_date,
|
||||
p.end_date,
|
||||
c.name AS client_name,
|
||||
c.id AS client_id,
|
||||
p.notes,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
ts.name AS status,
|
||||
ts.color_code AS status_color,
|
||||
ts.icon AS status_icon,
|
||||
ts.id AS status_id,
|
||||
h.name AS health,
|
||||
h.color_code AS health_color,
|
||||
h.icon AS health_icon,
|
||||
h.id AS health_id,
|
||||
pc.name AS category_name,
|
||||
pc.color_code AS category_color,
|
||||
pc.id AS category_id,
|
||||
p.phase_label,
|
||||
p.estimated_man_days AS man_days,
|
||||
p.estimated_working_days AS working_days,
|
||||
p.hours_per_day,
|
||||
p.use_manual_progress,
|
||||
p.use_weighted_progress,
|
||||
-- Additional fields
|
||||
COALESCE((SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t)))
|
||||
FROM (SELECT pm.id,
|
||||
pm.project_id,
|
||||
tm.id AS team_member_id,
|
||||
tm.user_id,
|
||||
u.name,
|
||||
u.email,
|
||||
u.avatar_url,
|
||||
u.phone_number,
|
||||
pal.name AS access_level,
|
||||
pal.key AS access_level_key,
|
||||
pal.id AS access_level_id,
|
||||
EXISTS(SELECT 1
|
||||
FROM project_members
|
||||
INNER JOIN project_access_levels ON
|
||||
project_members.project_access_level_id = project_access_levels.id
|
||||
WHERE project_id = p.id
|
||||
AND project_access_levels.key = 'PROJECT_MANAGER'
|
||||
AND team_member_id = tm.id) AS is_project_manager
|
||||
FROM project_members pm
|
||||
INNER JOIN team_members tm ON pm.team_member_id = tm.id
|
||||
INNER JOIN users u ON tm.user_id = u.id
|
||||
INNER JOIN project_access_levels pal ON pm.project_access_level_id = pal.id
|
||||
WHERE pm.project_id = p.id) t), '[]'::JSON) AS members,
|
||||
(SELECT COUNT(DISTINCT (id))
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p.id) AS task_count,
|
||||
(SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t)))
|
||||
FROM (SELECT project_members.id,
|
||||
project_members.project_id,
|
||||
team_members.id AS team_member_id,
|
||||
team_members.user_id,
|
||||
users.name,
|
||||
users.email,
|
||||
users.avatar_url,
|
||||
project_access_levels.name AS access_level,
|
||||
project_access_levels.key AS access_level_key,
|
||||
project_access_levels.id AS access_level_id
|
||||
FROM project_members
|
||||
INNER JOIN team_members ON project_members.team_member_id = team_members.id
|
||||
INNER JOIN users ON team_members.user_id = users.id
|
||||
INNER JOIN project_access_levels
|
||||
ON project_members.project_access_level_id = project_access_levels.id
|
||||
WHERE project_id = p.id
|
||||
AND project_access_levels.key = 'PROJECT_MANAGER'
|
||||
LIMIT 1) t) AS project_manager,
|
||||
|
||||
(SELECT EXISTS(SELECT 1
|
||||
FROM project_subscribers
|
||||
WHERE project_id = p.id
|
||||
AND user_id = (SELECT user_id
|
||||
FROM project_members
|
||||
WHERE team_member_id = (SELECT id
|
||||
FROM team_members
|
||||
WHERE user_id IN
|
||||
(SELECT user_id FROM is_member_of_project_cte))
|
||||
AND project_id = p.id))) AS subscribed,
|
||||
(SELECT name
|
||||
FROM users
|
||||
WHERE id =
|
||||
(SELECT owner_id FROM projects WHERE id = p.id)) AS project_owner,
|
||||
(SELECT default_view
|
||||
FROM project_members
|
||||
WHERE project_id = p.id
|
||||
AND team_member_id IN (SELECT id FROM is_member_of_project_cte)) AS team_member_default_view,
|
||||
(SELECT EXISTS(SELECT user_id
|
||||
FROM archived_projects
|
||||
WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte)
|
||||
AND project_id = p.id)) AS archived,
|
||||
|
||||
(SELECT EXISTS(SELECT user_id
|
||||
FROM favorite_projects
|
||||
WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte)
|
||||
AND project_id = p.id)) AS favorite
|
||||
|
||||
FROM projects p
|
||||
LEFT JOIN sys_project_statuses ts ON p.status_id = ts.id
|
||||
LEFT JOIN sys_project_healths h ON p.health_id = h.id
|
||||
LEFT JOIN project_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN clients c ON p.client_id = c.id,
|
||||
LATERAL (SELECT id, user_id
|
||||
FROM team_members
|
||||
WHERE id = (SELECT team_member_id
|
||||
FROM project_members
|
||||
WHERE project_id = p.id
|
||||
AND team_member_id IN (SELECT id
|
||||
FROM team_members
|
||||
WHERE team_id = _team_id)
|
||||
LIMIT 1)) is_member_of_project_cte
|
||||
|
||||
WHERE p.id = _project_id
|
||||
AND p.team_id = _team_id) rec;
|
||||
|
||||
RETURN _result;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task JSON;
|
||||
_priorities JSON;
|
||||
_projects JSON;
|
||||
_statuses JSON;
|
||||
_team_members JSON;
|
||||
_assignees JSON;
|
||||
_phases JSON;
|
||||
BEGIN
|
||||
|
||||
-- Select task info
|
||||
SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
INTO _task
|
||||
FROM (WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the given task
|
||||
SELECT id,
|
||||
parent_task_id,
|
||||
0 AS level
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Traverse up to parent tasks
|
||||
SELECT t.id,
|
||||
t.parent_task_id,
|
||||
th.level + 1 AS level
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE th.parent_task_id IS NOT NULL)
|
||||
SELECT id,
|
||||
name,
|
||||
description,
|
||||
start_date,
|
||||
end_date,
|
||||
done,
|
||||
total_minutes,
|
||||
priority_id,
|
||||
project_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status_id,
|
||||
parent_task_id,
|
||||
sort_order,
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id,
|
||||
CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key,
|
||||
(SELECT start_time
|
||||
FROM task_timers
|
||||
WHERE task_id = tasks.id
|
||||
AND user_id = _user_id) AS timer_start_time,
|
||||
parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT COUNT('*')
|
||||
FROM tasks
|
||||
WHERE parent_task_id = tasks.id
|
||||
AND archived IS FALSE) AS sub_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_with_status_view tt
|
||||
WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id)
|
||||
AND tt.is_done IS TRUE)
|
||||
AS completed_count,
|
||||
(SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON)
|
||||
FROM (SELECT task_labels.label_id AS id,
|
||||
(SELECT name FROM team_labels WHERE id = task_labels.label_id),
|
||||
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
|
||||
FROM task_labels
|
||||
WHERE task_id = tasks.id
|
||||
ORDER BY name) r) AS labels,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count,
|
||||
(SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter,
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees,
|
||||
(SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id,
|
||||
billable,
|
||||
schedule_id,
|
||||
progress_value,
|
||||
weight,
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level
|
||||
FROM tasks
|
||||
WHERE id = _task_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _priorities
|
||||
FROM (SELECT id, name FROM task_priorities ORDER BY value) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _phases
|
||||
FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _projects
|
||||
FROM (SELECT id, name
|
||||
FROM projects
|
||||
WHERE team_id = _team_id
|
||||
AND (CASE
|
||||
WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE
|
||||
ELSE is_member_of_project(projects.id, _user_id, _team_id) END)
|
||||
ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _statuses
|
||||
FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _team_members
|
||||
FROM (SELECT team_members.id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT avatar_url
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = team_members.id)
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id
|
||||
WHERE team_id = _team_id
|
||||
AND team_members.active IS TRUE) rec;
|
||||
|
||||
SELECT get_task_assignees(_task_id) INTO _assignees;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task', _task,
|
||||
'priorities', _priorities,
|
||||
'projects', _projects,
|
||||
'statuses', _statuses,
|
||||
'team_members', _team_members,
|
||||
'assignees', _assignees,
|
||||
'phases', _phases
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Add use_manual_progress, use_weighted_progress, and use_time_progress to projects table if they don't exist
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Add a trigger to reset manual progress when a task gets a new subtask
|
||||
CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
-- When a task gets a new subtask (parent_task_id is set), reset the parent's manual_progress flag
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
UPDATE tasks
|
||||
SET manual_progress = false
|
||||
WHERE id = NEW.parent_task_id
|
||||
AND manual_progress = true;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create the trigger on the tasks table
|
||||
DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks;
|
||||
CREATE TRIGGER reset_parent_manual_progress_trigger
|
||||
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_parent_task_manual_progress();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,157 @@
|
||||
-- Migration: Add progress and weight activity types support
|
||||
-- Date: 2025-04-24
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update the get_activity_logs_by_task function to handle progress and weight attribute types
|
||||
CREATE OR REPLACE FUNCTION get_activity_logs_by_task(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
BEGIN
|
||||
SELECT ROW_TO_JSON(rec)
|
||||
INTO _result
|
||||
FROM (SELECT (SELECT tasks.created_at FROM tasks WHERE tasks.id = _task_id),
|
||||
(SELECT name
|
||||
FROM users
|
||||
WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)),
|
||||
(SELECT avatar_url
|
||||
FROM users
|
||||
WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)),
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec2))), '[]'::JSON)
|
||||
FROM (SELECT task_id,
|
||||
created_at,
|
||||
attribute_type,
|
||||
log_type,
|
||||
|
||||
-- Case for previous value
|
||||
(CASE
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT name FROM task_statuses WHERE id = old_value::UUID)
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT name FROM task_priorities WHERE id = old_value::UUID)
|
||||
WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped')
|
||||
THEN (SELECT name FROM project_phases WHERE id = old_value::UUID)
|
||||
WHEN (attribute_type = 'progress' OR attribute_type = 'weight')
|
||||
THEN old_value
|
||||
ELSE (old_value) END) AS previous,
|
||||
|
||||
-- Case for current value
|
||||
(CASE
|
||||
WHEN (attribute_type = 'assignee')
|
||||
THEN (SELECT name FROM users WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'label')
|
||||
THEN (SELECT name FROM team_labels WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT name FROM task_statuses WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT name FROM task_priorities WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped')
|
||||
THEN (SELECT name FROM project_phases WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'progress' OR attribute_type = 'weight')
|
||||
THEN new_value
|
||||
ELSE (new_value) END) AS current,
|
||||
|
||||
-- Case for assigned user
|
||||
(CASE
|
||||
WHEN (attribute_type = 'assignee')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (CASE
|
||||
WHEN (new_value IS NOT NULL)
|
||||
THEN (SELECT name FROM users WHERE users.id = new_value::UUID)
|
||||
ELSE (next_string) END) AS name,
|
||||
(SELECT avatar_url FROM users WHERE users.id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS assigned_user,
|
||||
|
||||
-- Case for label data
|
||||
(CASE
|
||||
WHEN (attribute_type = 'label')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM team_labels WHERE id = new_value::UUID),
|
||||
(SELECT color_code FROM team_labels WHERE id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS label_data,
|
||||
|
||||
-- Case for previous status
|
||||
(CASE
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM task_statuses WHERE id = old_value::UUID),
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID)),
|
||||
(SELECT color_code_dark
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID))) rec)
|
||||
ELSE (NULL) END) AS previous_status,
|
||||
|
||||
-- Case for next status
|
||||
(CASE
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM task_statuses WHERE id = new_value::UUID),
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID)),
|
||||
(SELECT color_code_dark
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID))) rec)
|
||||
ELSE (NULL) END) AS next_status,
|
||||
|
||||
-- Case for previous priority
|
||||
(CASE
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM task_priorities WHERE id = old_value::UUID),
|
||||
(SELECT color_code FROM task_priorities WHERE id = old_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS previous_priority,
|
||||
|
||||
-- Case for next priority
|
||||
(CASE
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM task_priorities WHERE id = new_value::UUID),
|
||||
(SELECT color_code FROM task_priorities WHERE id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS next_priority,
|
||||
|
||||
-- Case for previous phase
|
||||
(CASE
|
||||
WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM project_phases WHERE id = old_value::UUID),
|
||||
(SELECT color_code FROM project_phases WHERE id = old_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS previous_phase,
|
||||
|
||||
-- Case for next phase
|
||||
(CASE
|
||||
WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM project_phases WHERE id = new_value::UUID),
|
||||
(SELECT color_code FROM project_phases WHERE id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS next_phase,
|
||||
|
||||
-- Case for done by
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM users WHERE users.id = tal.user_id),
|
||||
(SELECT avatar_url FROM users WHERE users.id = tal.user_id)) rec) AS done_by,
|
||||
|
||||
-- Add log text for progress and weight
|
||||
(CASE
|
||||
WHEN (attribute_type = 'progress')
|
||||
THEN 'updated the progress of'
|
||||
WHEN (attribute_type = 'weight')
|
||||
THEN 'updated the weight of'
|
||||
ELSE ''
|
||||
END) AS log_text
|
||||
|
||||
|
||||
FROM task_activity_logs tal
|
||||
WHERE task_id = _task_id
|
||||
ORDER BY created_at DESC) rec2) AS logs) rec;
|
||||
RETURN _result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,243 @@
|
||||
-- Migration: Update time-based progress mode to work for all tasks
|
||||
-- Date: 2025-04-25
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update function to use time-based progress for all tasks
|
||||
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_done FLOAT = 0;
|
||||
_sub_tasks_done FLOAT = 0;
|
||||
_sub_tasks_count FLOAT = 0;
|
||||
_total_completed FLOAT = 0;
|
||||
_total_tasks FLOAT = 0;
|
||||
_ratio FLOAT = 0;
|
||||
_is_manual BOOLEAN = FALSE;
|
||||
_manual_value INTEGER = NULL;
|
||||
_project_id UUID;
|
||||
_use_manual_progress BOOLEAN = FALSE;
|
||||
_use_weighted_progress BOOLEAN = FALSE;
|
||||
_use_time_progress BOOLEAN = FALSE;
|
||||
_task_complete BOOLEAN = FALSE;
|
||||
BEGIN
|
||||
-- Check if manual progress is set for this task
|
||||
SELECT manual_progress, progress_value, project_id,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = tasks.id
|
||||
AND is_done IS TRUE
|
||||
) AS is_complete
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _is_manual, _manual_value, _project_id, _task_complete;
|
||||
|
||||
-- Check if the project uses manual progress
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT COALESCE(use_manual_progress, FALSE),
|
||||
COALESCE(use_weighted_progress, FALSE),
|
||||
COALESCE(use_time_progress, FALSE)
|
||||
FROM projects
|
||||
WHERE id = _project_id
|
||||
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
|
||||
END IF;
|
||||
|
||||
-- Get all subtasks
|
||||
SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id AND archived IS FALSE
|
||||
INTO _sub_tasks_count;
|
||||
|
||||
-- If task is complete, always return 100%
|
||||
IF _task_complete IS TRUE THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', 100,
|
||||
'total_completed', 1,
|
||||
'total_tasks', 1,
|
||||
'is_manual', FALSE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Use manual progress value in two cases:
|
||||
-- 1. When task has manual_progress = TRUE and progress_value is set
|
||||
-- 2. When project has use_manual_progress = TRUE and progress_value is set
|
||||
IF (_is_manual IS TRUE AND _manual_value IS NOT NULL) OR
|
||||
(_use_manual_progress IS TRUE AND _manual_value IS NOT NULL) THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _manual_value,
|
||||
'total_completed', 0,
|
||||
'total_tasks', 0,
|
||||
'is_manual', TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- If there are no subtasks, just use the parent task's status (unless in time-based mode)
|
||||
IF _sub_tasks_count = 0 THEN
|
||||
-- Use time-based estimation for tasks without subtasks if enabled
|
||||
IF _use_time_progress IS TRUE THEN
|
||||
-- For time-based tasks without subtasks, we still need some progress calculation
|
||||
-- If the task is completed, return 100%
|
||||
-- Otherwise, use the progress value if set manually, or 0
|
||||
SELECT
|
||||
CASE
|
||||
WHEN _task_complete IS TRUE THEN 100
|
||||
ELSE COALESCE(_manual_value, 0)
|
||||
END
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation for non-time-based tasks
|
||||
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
_ratio = _parent_task_done * 100;
|
||||
END IF;
|
||||
ELSE
|
||||
-- If project uses manual progress, calculate based on subtask manual progress values
|
||||
IF _use_manual_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.manual_progress,
|
||||
t.progress_value,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) AS is_complete
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
),
|
||||
subtask_with_values AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- For completed tasks, always use 100%
|
||||
WHEN is_complete IS TRUE THEN 100
|
||||
-- For tasks with progress value set, use it regardless of manual_progress flag
|
||||
WHEN progress_value IS NOT NULL THEN progress_value
|
||||
-- Default to 0 for incomplete tasks with no progress value
|
||||
ELSE 0
|
||||
END AS progress_value
|
||||
FROM subtask_progress
|
||||
)
|
||||
SELECT COALESCE(AVG(progress_value), 0)
|
||||
FROM subtask_with_values
|
||||
INTO _ratio;
|
||||
-- If project uses weighted progress, calculate based on subtask weights
|
||||
ELSIF _use_weighted_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.manual_progress,
|
||||
t.progress_value,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) AS is_complete,
|
||||
COALESCE(t.weight, 100) AS weight
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
),
|
||||
subtask_with_values AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- For completed tasks, always use 100%
|
||||
WHEN is_complete IS TRUE THEN 100
|
||||
-- For tasks with progress value set, use it regardless of manual_progress flag
|
||||
WHEN progress_value IS NOT NULL THEN progress_value
|
||||
-- Default to 0 for incomplete tasks with no progress value
|
||||
ELSE 0
|
||||
END AS progress_value,
|
||||
weight
|
||||
FROM subtask_progress
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_with_values
|
||||
INTO _ratio;
|
||||
-- If project uses time-based progress, calculate based on estimated time
|
||||
ELSIF _use_time_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.manual_progress,
|
||||
t.progress_value,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) AS is_complete,
|
||||
COALESCE(t.total_minutes, 0) AS estimated_minutes
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
),
|
||||
subtask_with_values AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- For completed tasks, always use 100%
|
||||
WHEN is_complete IS TRUE THEN 100
|
||||
-- For tasks with progress value set, use it regardless of manual_progress flag
|
||||
WHEN progress_value IS NOT NULL THEN progress_value
|
||||
-- Default to 0 for incomplete tasks with no progress value
|
||||
ELSE 0
|
||||
END AS progress_value,
|
||||
estimated_minutes
|
||||
FROM subtask_progress
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_with_values
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation based on completion status
|
||||
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks = 0 THEN
|
||||
_ratio = 0;
|
||||
ELSE
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Ensure ratio is between 0 and 100
|
||||
IF _ratio < 0 THEN
|
||||
_ratio = 0;
|
||||
ELSIF _ratio > 100 THEN
|
||||
_ratio = 100;
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _ratio,
|
||||
'total_completed', _total_completed,
|
||||
'total_tasks', _total_tasks,
|
||||
'is_manual', _is_manual
|
||||
);
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,289 @@
|
||||
-- Migration: Improve parent task progress calculation using weights and time estimation
|
||||
-- Date: 2025-04-26
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update function to better calculate parent task progress based on subtask weights or time estimations
|
||||
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_done FLOAT = 0;
|
||||
_sub_tasks_done FLOAT = 0;
|
||||
_sub_tasks_count FLOAT = 0;
|
||||
_total_completed FLOAT = 0;
|
||||
_total_tasks FLOAT = 0;
|
||||
_ratio FLOAT = 0;
|
||||
_is_manual BOOLEAN = FALSE;
|
||||
_manual_value INTEGER = NULL;
|
||||
_project_id UUID;
|
||||
_use_manual_progress BOOLEAN = FALSE;
|
||||
_use_weighted_progress BOOLEAN = FALSE;
|
||||
_use_time_progress BOOLEAN = FALSE;
|
||||
BEGIN
|
||||
-- Check if manual progress is set for this task
|
||||
SELECT manual_progress, progress_value, project_id
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _is_manual, _manual_value, _project_id;
|
||||
|
||||
-- Check if the project uses manual progress
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT COALESCE(use_manual_progress, FALSE),
|
||||
COALESCE(use_weighted_progress, FALSE),
|
||||
COALESCE(use_time_progress, FALSE)
|
||||
FROM projects
|
||||
WHERE id = _project_id
|
||||
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
|
||||
END IF;
|
||||
|
||||
-- Get all subtasks
|
||||
SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id AND archived IS FALSE
|
||||
INTO _sub_tasks_count;
|
||||
|
||||
-- Only respect manual progress for tasks without subtasks
|
||||
IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _manual_value,
|
||||
'total_completed', 0,
|
||||
'total_tasks', 0,
|
||||
'is_manual', TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- If there are no subtasks, just use the parent task's status
|
||||
IF _sub_tasks_count = 0 THEN
|
||||
-- For tasks without subtasks in time-based mode
|
||||
IF _use_time_progress IS TRUE THEN
|
||||
SELECT
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE COALESCE(_manual_value, 0)
|
||||
END
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation for non-time-based tasks
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
_ratio = _parent_task_done * 100;
|
||||
END IF;
|
||||
ELSE
|
||||
-- For parent tasks with subtasks, always use the appropriate calculation based on project mode
|
||||
-- If project uses manual progress, calculate based on subtask manual progress values
|
||||
IF _use_manual_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(AVG(progress_value), 0)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
-- If project uses weighted progress, calculate based on subtask weights
|
||||
ELSIF _use_weighted_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(weight, 100) AS weight -- Default weight is 100 if not specified
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
-- If project uses time-based progress, calculate based on estimated time (total_minutes)
|
||||
ELSIF _use_time_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(total_minutes, 0) AS estimated_minutes -- Use time estimation for weighting
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation based on completion status when no special mode is enabled
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks = 0 THEN
|
||||
_ratio = 0;
|
||||
ELSE
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Ensure ratio is between 0 and 100
|
||||
IF _ratio < 0 THEN
|
||||
_ratio = 0;
|
||||
ELSIF _ratio > 100 THEN
|
||||
_ratio = 100;
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _ratio,
|
||||
'total_completed', _total_completed,
|
||||
'total_tasks', _total_tasks,
|
||||
'is_manual', _is_manual
|
||||
);
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Make sure we recalculate parent task progress when subtask progress changes
|
||||
CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
_ratio FLOAT;
|
||||
BEGIN
|
||||
-- Check if this is a subtask
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
_parent_task_id := NEW.parent_task_id;
|
||||
|
||||
-- Force any parent task with subtasks to NOT use manual progress
|
||||
UPDATE tasks
|
||||
SET manual_progress = FALSE
|
||||
WHERE id = _parent_task_id;
|
||||
END IF;
|
||||
|
||||
-- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user
|
||||
-- to mark it as done. We'll annotate this in a way that the socket handler can detect.
|
||||
IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN
|
||||
-- Check if task has status in "done" category
|
||||
SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id;
|
||||
|
||||
-- Get the progress ratio for this task
|
||||
SELECT get_task_complete_ratio(NEW.id)->>'ratio' INTO _ratio;
|
||||
|
||||
IF _ratio::FLOAT >= 100 THEN
|
||||
-- Log that this task is at 100% progress
|
||||
RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for updates to task progress
|
||||
DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks;
|
||||
CREATE TRIGGER update_parent_task_progress_trigger
|
||||
AFTER UPDATE OF progress_value, weight, total_minutes ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_parent_task_progress();
|
||||
|
||||
-- Create a function to ensure parent tasks never have manual progress when they have subtasks
|
||||
CREATE OR REPLACE FUNCTION ensure_parent_task_without_manual_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
-- If this is a new subtask being created or a task is being converted to a subtask
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
-- Force the parent task to NOT use manual progress
|
||||
UPDATE tasks
|
||||
SET manual_progress = FALSE
|
||||
WHERE id = NEW.parent_task_id;
|
||||
|
||||
-- Log that we've reset manual progress for a parent task
|
||||
RAISE NOTICE 'Reset manual progress for parent task % because it has subtasks', NEW.parent_task_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for when tasks are created or updated with a parent_task_id
|
||||
DROP TRIGGER IF EXISTS ensure_parent_task_without_manual_progress_trigger ON tasks;
|
||||
CREATE TRIGGER ensure_parent_task_without_manual_progress_trigger
|
||||
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION ensure_parent_task_without_manual_progress();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,150 @@
|
||||
-- Migration: Update socket event handlers to set progress-mode handlers
|
||||
-- Date: 2025-04-26
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Create ENUM type for progress modes
|
||||
CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default');
|
||||
|
||||
-- Alter tasks table to use ENUM type
|
||||
ALTER TABLE tasks
|
||||
ALTER COLUMN progress_mode TYPE progress_mode_type
|
||||
USING progress_mode::text::progress_mode_type;
|
||||
|
||||
-- Update the on_update_task_progress function to set progress_mode
|
||||
CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task_id UUID;
|
||||
_progress_value INTEGER;
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
_current_mode progress_mode_type;
|
||||
BEGIN
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_progress_value = (_body ->> 'progress_value')::INTEGER;
|
||||
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||
|
||||
-- Get the project ID and determine the current progress mode
|
||||
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT
|
||||
CASE
|
||||
WHEN use_manual_progress IS TRUE THEN 'manual'
|
||||
WHEN use_weighted_progress IS TRUE THEN 'weighted'
|
||||
WHEN use_time_progress IS TRUE THEN 'time'
|
||||
ELSE 'default'
|
||||
END
|
||||
INTO _current_mode
|
||||
FROM projects
|
||||
WHERE id = _project_id;
|
||||
ELSE
|
||||
_current_mode := 'default';
|
||||
END IF;
|
||||
|
||||
-- Update the task with progress value and set the progress mode
|
||||
UPDATE tasks
|
||||
SET progress_value = _progress_value,
|
||||
manual_progress = TRUE,
|
||||
progress_mode = _current_mode,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id;
|
||||
|
||||
-- Return the updated task info
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task_id', _task_id,
|
||||
'progress_value', _progress_value,
|
||||
'progress_mode', _current_mode
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the on_update_task_weight function to set progress_mode when weight is updated
|
||||
CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task_id UUID;
|
||||
_weight INTEGER;
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
BEGIN
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_weight = (_body ->> 'weight')::INTEGER;
|
||||
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||
|
||||
-- Get the project ID
|
||||
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||
|
||||
-- Update the task with weight value and set progress_mode to 'weighted'
|
||||
UPDATE tasks
|
||||
SET weight = _weight,
|
||||
progress_mode = 'weighted',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id;
|
||||
|
||||
-- Return the updated task info
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task_id', _task_id,
|
||||
'weight', _weight
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create a function to reset progress values when switching project progress modes
|
||||
CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_old_mode progress_mode_type;
|
||||
_new_mode progress_mode_type;
|
||||
_project_id UUID;
|
||||
BEGIN
|
||||
_project_id := NEW.id;
|
||||
|
||||
-- Determine old and new modes
|
||||
_old_mode :=
|
||||
CASE
|
||||
WHEN OLD.use_manual_progress IS TRUE THEN 'manual'
|
||||
WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'
|
||||
WHEN OLD.use_time_progress IS TRUE THEN 'time'
|
||||
ELSE 'default'
|
||||
END;
|
||||
|
||||
_new_mode :=
|
||||
CASE
|
||||
WHEN NEW.use_manual_progress IS TRUE THEN 'manual'
|
||||
WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'
|
||||
WHEN NEW.use_time_progress IS TRUE THEN 'time'
|
||||
ELSE 'default'
|
||||
END;
|
||||
|
||||
-- If mode has changed, reset progress values for tasks with the old mode
|
||||
IF _old_mode <> _new_mode THEN
|
||||
-- Reset progress values for tasks that were set in the old mode
|
||||
UPDATE tasks
|
||||
SET progress_value = NULL,
|
||||
progress_mode = NULL
|
||||
WHERE project_id = _project_id
|
||||
AND progress_mode = _old_mode;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create trigger to reset progress values when project progress mode changes
|
||||
DROP TRIGGER IF EXISTS reset_progress_on_mode_change ON projects;
|
||||
CREATE TRIGGER reset_progress_on_mode_change
|
||||
AFTER UPDATE OF use_manual_progress, use_weighted_progress, use_time_progress
|
||||
ON projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_project_progress_values();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,160 @@
|
||||
-- Migration: Fix progress_mode_type ENUM and casting issues
|
||||
-- Date: 2025-04-27
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- First, let's ensure the ENUM type exists with the correct values
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Check if the type exists
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'progress_mode_type') THEN
|
||||
CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default');
|
||||
ELSE
|
||||
-- Add any missing values to the existing ENUM
|
||||
BEGIN
|
||||
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'manual';
|
||||
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'weighted';
|
||||
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'time';
|
||||
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'default';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN
|
||||
-- Ignore if values already exist
|
||||
NULL;
|
||||
END;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Update functions to use proper type casting
|
||||
CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task_id UUID;
|
||||
_progress_value INTEGER;
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
_current_mode progress_mode_type;
|
||||
BEGIN
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_progress_value = (_body ->> 'progress_value')::INTEGER;
|
||||
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||
|
||||
-- Get the project ID and determine the current progress mode
|
||||
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT
|
||||
CASE
|
||||
WHEN use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
|
||||
WHEN use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
|
||||
WHEN use_time_progress IS TRUE THEN 'time'::progress_mode_type
|
||||
ELSE 'default'::progress_mode_type
|
||||
END
|
||||
INTO _current_mode
|
||||
FROM projects
|
||||
WHERE id = _project_id;
|
||||
ELSE
|
||||
_current_mode := 'default'::progress_mode_type;
|
||||
END IF;
|
||||
|
||||
-- Update the task with progress value and set the progress mode
|
||||
UPDATE tasks
|
||||
SET progress_value = _progress_value,
|
||||
manual_progress = TRUE,
|
||||
progress_mode = _current_mode,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id;
|
||||
|
||||
-- Return the updated task info
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task_id', _task_id,
|
||||
'progress_value', _progress_value,
|
||||
'progress_mode', _current_mode
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the on_update_task_weight function to use proper type casting
|
||||
CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task_id UUID;
|
||||
_weight INTEGER;
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
BEGIN
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_weight = (_body ->> 'weight')::INTEGER;
|
||||
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||
|
||||
-- Get the project ID
|
||||
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||
|
||||
-- Update the task with weight value and set progress_mode to 'weighted'
|
||||
UPDATE tasks
|
||||
SET weight = _weight,
|
||||
progress_mode = 'weighted'::progress_mode_type,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id;
|
||||
|
||||
-- Return the updated task info
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task_id', _task_id,
|
||||
'weight', _weight
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the reset_project_progress_values function to use proper type casting
|
||||
CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_old_mode progress_mode_type;
|
||||
_new_mode progress_mode_type;
|
||||
_project_id UUID;
|
||||
BEGIN
|
||||
_project_id := NEW.id;
|
||||
|
||||
-- Determine old and new modes with proper type casting
|
||||
_old_mode :=
|
||||
CASE
|
||||
WHEN OLD.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
|
||||
WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
|
||||
WHEN OLD.use_time_progress IS TRUE THEN 'time'::progress_mode_type
|
||||
ELSE 'default'::progress_mode_type
|
||||
END;
|
||||
|
||||
_new_mode :=
|
||||
CASE
|
||||
WHEN NEW.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
|
||||
WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
|
||||
WHEN NEW.use_time_progress IS TRUE THEN 'time'::progress_mode_type
|
||||
ELSE 'default'::progress_mode_type
|
||||
END;
|
||||
|
||||
-- If mode has changed, reset progress values for tasks with the old mode
|
||||
IF _old_mode <> _new_mode THEN
|
||||
-- Reset progress values for tasks that were set in the old mode
|
||||
UPDATE tasks
|
||||
SET progress_value = NULL,
|
||||
progress_mode = NULL
|
||||
WHERE project_id = _project_id
|
||||
AND progress_mode::text::progress_mode_type = _old_mode;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the tasks table to ensure proper type casting for existing values
|
||||
UPDATE tasks
|
||||
SET progress_mode = progress_mode::text::progress_mode_type
|
||||
WHERE progress_mode IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,166 @@
|
||||
-- Migration: Fix multilevel subtask progress calculation for weighted and manual progress
|
||||
-- Date: 2025-05-06
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update the trigger function to recursively recalculate parent task progress up the entire hierarchy
|
||||
CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
_ratio FLOAT;
|
||||
BEGIN
|
||||
-- Check if this is a subtask
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
_parent_task_id := NEW.parent_task_id;
|
||||
|
||||
-- Force any parent task with subtasks to NOT use manual progress
|
||||
UPDATE tasks
|
||||
SET manual_progress = FALSE
|
||||
WHERE id = _parent_task_id;
|
||||
|
||||
-- Calculate and update the parent's progress value
|
||||
SELECT (get_task_complete_ratio(_parent_task_id)->>'ratio')::FLOAT INTO _ratio;
|
||||
|
||||
-- Update the parent's progress value
|
||||
UPDATE tasks
|
||||
SET progress_value = _ratio
|
||||
WHERE id = _parent_task_id;
|
||||
|
||||
-- Recursively propagate changes up the hierarchy by using a recursive CTE
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the parent task
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id
|
||||
FROM tasks
|
||||
WHERE id = _parent_task_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Go up to each ancestor
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id
|
||||
FROM tasks t
|
||||
JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE t.id IS NOT NULL
|
||||
)
|
||||
-- For each ancestor, recalculate its progress
|
||||
UPDATE tasks
|
||||
SET
|
||||
manual_progress = FALSE,
|
||||
progress_value = (SELECT (get_task_complete_ratio(task_hierarchy.id)->>'ratio')::FLOAT)
|
||||
FROM task_hierarchy
|
||||
WHERE tasks.id = task_hierarchy.id
|
||||
AND task_hierarchy.parent_task_id IS NOT NULL;
|
||||
|
||||
-- Log the recalculation for debugging
|
||||
RAISE NOTICE 'Updated progress for task % to %', _parent_task_id, _ratio;
|
||||
END IF;
|
||||
|
||||
-- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user
|
||||
-- to mark it as done. We'll annotate this in a way that the socket handler can detect.
|
||||
IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN
|
||||
-- Check if task has status in "done" category
|
||||
SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id;
|
||||
|
||||
-- Get the progress ratio for this task
|
||||
SELECT (get_task_complete_ratio(NEW.id)->>'ratio')::FLOAT INTO _ratio;
|
||||
|
||||
IF _ratio >= 100 THEN
|
||||
-- Log that this task is at 100% progress
|
||||
RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update existing trigger or create a new one to handle more changes
|
||||
DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks;
|
||||
CREATE TRIGGER update_parent_task_progress_trigger
|
||||
AFTER UPDATE OF progress_value, weight, total_minutes, parent_task_id, manual_progress ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_parent_task_progress();
|
||||
|
||||
-- Also add a trigger for when a new task is inserted
|
||||
DROP TRIGGER IF EXISTS update_parent_task_progress_on_insert_trigger ON tasks;
|
||||
CREATE TRIGGER update_parent_task_progress_on_insert_trigger
|
||||
AFTER INSERT ON tasks
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.parent_task_id IS NOT NULL)
|
||||
EXECUTE FUNCTION update_parent_task_progress();
|
||||
|
||||
-- Add a comment to explain the fix
|
||||
COMMENT ON FUNCTION update_parent_task_progress() IS
|
||||
'This function recursively updates progress values for all ancestors when a task''s progress changes.
|
||||
The previous version only updated the immediate parent, which led to incorrect progress values for
|
||||
higher-level parent tasks when using weighted or manual progress calculations with multi-level subtasks.';
|
||||
|
||||
-- Add a function to immediately recalculate all task progress values in the correct order
|
||||
-- This will fix existing data where parent tasks don't have proper progress values
|
||||
CREATE OR REPLACE FUNCTION recalculate_all_task_progress() RETURNS void AS
|
||||
$$
|
||||
BEGIN
|
||||
-- First, reset manual_progress flag for all tasks that have subtasks
|
||||
UPDATE tasks AS t
|
||||
SET manual_progress = FALSE
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks
|
||||
WHERE parent_task_id = t.id
|
||||
AND archived IS FALSE
|
||||
);
|
||||
|
||||
-- Start recalculation from leaf tasks (no subtasks) and propagate upward
|
||||
-- This ensures calculations are done in the right order
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with all leaf tasks (no subtasks)
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id,
|
||||
0 AS level
|
||||
FROM tasks
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tasks AS sub
|
||||
WHERE sub.parent_task_id = tasks.id
|
||||
AND sub.archived IS FALSE
|
||||
)
|
||||
AND archived IS FALSE
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Move up to parent tasks, but only after processing all their children
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id,
|
||||
th.level + 1
|
||||
FROM tasks t
|
||||
JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE t.archived IS FALSE
|
||||
)
|
||||
-- Sort by level to ensure we calculate in the right order (leaves first, then parents)
|
||||
-- This ensures we're using already updated progress values
|
||||
UPDATE tasks
|
||||
SET progress_value = (SELECT (get_task_complete_ratio(tasks.id)->>'ratio')::FLOAT)
|
||||
FROM (
|
||||
SELECT id, level
|
||||
FROM task_hierarchy
|
||||
ORDER BY level
|
||||
) AS ordered_tasks
|
||||
WHERE tasks.id = ordered_tasks.id
|
||||
AND (manual_progress IS FALSE OR manual_progress IS NULL);
|
||||
|
||||
-- Log the completion of the recalculation
|
||||
RAISE NOTICE 'Finished recalculating all task progress values';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Execute the function to fix existing data
|
||||
SELECT recalculate_all_task_progress();
|
||||
|
||||
COMMIT;
|
||||
2
worklenz-backend/database/migrations/README.md
Normal file
2
worklenz-backend/database/migrations/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
Migrations should be executed out in the sequence specified by the filename. They should be removed once the migrations
|
||||
have been released to all databases.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
-- Fix Duplicate Sort Orders Script
|
||||
-- This script detects and fixes duplicate sort order values that break task ordering
|
||||
|
||||
-- 1. DETECTION QUERIES - Run these first to see the scope of the problem
|
||||
|
||||
-- Check for duplicates in main sort_order column
|
||||
SELECT
|
||||
project_id,
|
||||
sort_order,
|
||||
COUNT(*) as duplicate_count,
|
||||
STRING_AGG(id::text, ', ') as task_ids
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
GROUP BY project_id, sort_order
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY project_id, sort_order;
|
||||
|
||||
-- Check for duplicates in status_sort_order
|
||||
SELECT
|
||||
project_id,
|
||||
status_sort_order,
|
||||
COUNT(*) as duplicate_count,
|
||||
STRING_AGG(id::text, ', ') as task_ids
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
GROUP BY project_id, status_sort_order
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY project_id, status_sort_order;
|
||||
|
||||
-- Check for duplicates in priority_sort_order
|
||||
SELECT
|
||||
project_id,
|
||||
priority_sort_order,
|
||||
COUNT(*) as duplicate_count,
|
||||
STRING_AGG(id::text, ', ') as task_ids
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
GROUP BY project_id, priority_sort_order
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY project_id, priority_sort_order;
|
||||
|
||||
-- Check for duplicates in phase_sort_order
|
||||
SELECT
|
||||
project_id,
|
||||
phase_sort_order,
|
||||
COUNT(*) as duplicate_count,
|
||||
STRING_AGG(id::text, ', ') as task_ids
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
GROUP BY project_id, phase_sort_order
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY project_id, phase_sort_order;
|
||||
|
||||
-- Note: member_sort_order removed - no longer used
|
||||
|
||||
-- 2. CLEANUP FUNCTIONS
|
||||
|
||||
-- Fix duplicates in main sort_order column
|
||||
CREATE OR REPLACE FUNCTION fix_sort_order_duplicates() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
-- For each project, reassign sort_order values to ensure uniqueness
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
-- Reassign sort_order values sequentially for this project
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Fixed sort_order duplicates for all projects';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Fix duplicates in status_sort_order column
|
||||
CREATE OR REPLACE FUNCTION fix_status_sort_order_duplicates() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY status_sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET status_sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Fixed status_sort_order duplicates for all projects';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Fix duplicates in priority_sort_order column
|
||||
CREATE OR REPLACE FUNCTION fix_priority_sort_order_duplicates() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY priority_sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET priority_sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Fixed priority_sort_order duplicates for all projects';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Fix duplicates in phase_sort_order column
|
||||
CREATE OR REPLACE FUNCTION fix_phase_sort_order_duplicates() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY phase_sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET phase_sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Fixed phase_sort_order duplicates for all projects';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Note: fix_member_sort_order_duplicates() removed - no longer needed
|
||||
|
||||
-- Master function to fix all sort order duplicates
|
||||
CREATE OR REPLACE FUNCTION fix_all_duplicate_sort_orders() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Starting sort order cleanup for all columns...';
|
||||
|
||||
PERFORM fix_sort_order_duplicates();
|
||||
PERFORM fix_status_sort_order_duplicates();
|
||||
PERFORM fix_priority_sort_order_duplicates();
|
||||
PERFORM fix_phase_sort_order_duplicates();
|
||||
|
||||
RAISE NOTICE 'Completed sort order cleanup for all columns';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- 3. VERIFICATION FUNCTION
|
||||
|
||||
-- Verify that duplicates have been fixed
|
||||
CREATE OR REPLACE FUNCTION verify_sort_order_integrity() RETURNS TABLE(
|
||||
column_name text,
|
||||
project_id uuid,
|
||||
duplicate_count bigint,
|
||||
status text
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
-- Check sort_order duplicates
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'sort_order'::text as column_name,
|
||||
t.project_id,
|
||||
COUNT(*) as duplicate_count,
|
||||
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
|
||||
FROM tasks t
|
||||
WHERE t.project_id IS NOT NULL
|
||||
GROUP BY t.project_id, t.sort_order
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Check status_sort_order duplicates
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'status_sort_order'::text as column_name,
|
||||
t.project_id,
|
||||
COUNT(*) as duplicate_count,
|
||||
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
|
||||
FROM tasks t
|
||||
WHERE t.project_id IS NOT NULL
|
||||
GROUP BY t.project_id, t.status_sort_order
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Check priority_sort_order duplicates
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'priority_sort_order'::text as column_name,
|
||||
t.project_id,
|
||||
COUNT(*) as duplicate_count,
|
||||
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
|
||||
FROM tasks t
|
||||
WHERE t.project_id IS NOT NULL
|
||||
GROUP BY t.project_id, t.priority_sort_order
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Check phase_sort_order duplicates
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'phase_sort_order'::text as column_name,
|
||||
t.project_id,
|
||||
COUNT(*) as duplicate_count,
|
||||
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
|
||||
FROM tasks t
|
||||
WHERE t.project_id IS NOT NULL
|
||||
GROUP BY t.project_id, t.phase_sort_order
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Note: member_sort_order verification removed - column no longer used
|
||||
|
||||
END
|
||||
$$;
|
||||
|
||||
-- 4. USAGE INSTRUCTIONS
|
||||
|
||||
/*
|
||||
USAGE:
|
||||
|
||||
1. First, run the detection queries to see which projects have duplicates
|
||||
2. Then run this to fix all duplicates:
|
||||
SELECT fix_all_duplicate_sort_orders();
|
||||
3. Finally, verify the fix worked:
|
||||
SELECT * FROM verify_sort_order_integrity();
|
||||
|
||||
If verification returns no rows, all duplicates have been fixed successfully.
|
||||
|
||||
WARNING: This will reassign sort order values based on current order + creation time.
|
||||
Make sure to backup your database before running these functions.
|
||||
*/
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Migration: Add separate sort order columns for different grouping types
|
||||
-- This allows users to maintain different task orders when switching between grouping views
|
||||
|
||||
-- Add new sort order columns
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS status_sort_order INTEGER DEFAULT 0;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS priority_sort_order INTEGER DEFAULT 0;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS phase_sort_order INTEGER DEFAULT 0;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS member_sort_order INTEGER DEFAULT 0;
|
||||
|
||||
-- Initialize new columns with current sort_order values
|
||||
UPDATE tasks SET
|
||||
status_sort_order = sort_order,
|
||||
priority_sort_order = sort_order,
|
||||
phase_sort_order = sort_order,
|
||||
member_sort_order = sort_order
|
||||
WHERE status_sort_order = 0
|
||||
OR priority_sort_order = 0
|
||||
OR phase_sort_order = 0
|
||||
OR member_sort_order = 0;
|
||||
|
||||
-- Add constraints to ensure non-negative values
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0);
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0);
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0);
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_member_sort_order_check CHECK (member_sort_order >= 0);
|
||||
|
||||
-- Add indexes for performance (since these will be used for ordering)
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_member_sort_order ON tasks(project_id, member_sort_order);
|
||||
|
||||
-- Update comments for documentation
|
||||
COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status';
|
||||
COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority';
|
||||
COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase';
|
||||
COMMENT ON COLUMN tasks.member_sort_order IS 'Sort order when grouped by members/assignees';
|
||||
@@ -0,0 +1,172 @@
|
||||
-- Migration: Update database functions to handle grouping-specific sort orders
|
||||
|
||||
-- Function to get the appropriate sort column name based on grouping type
|
||||
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
CASE _group_by
|
||||
WHEN 'status' THEN RETURN 'status_sort_order';
|
||||
WHEN 'priority' THEN RETURN 'priority_sort_order';
|
||||
WHEN 'phase' THEN RETURN 'phase_sort_order';
|
||||
WHEN 'members' THEN RETURN 'member_sort_order';
|
||||
ELSE RETURN 'sort_order'; -- fallback to general sort_order
|
||||
END CASE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Updated bulk sort order function to handle different sort columns
|
||||
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_update_record RECORD;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
-- Get the appropriate sort column based on grouping
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Simple approach: update each task's sort_order from the provided array
|
||||
FOR _update_record IN
|
||||
SELECT
|
||||
(item->>'task_id')::uuid as task_id,
|
||||
(item->>'sort_order')::int as sort_order,
|
||||
(item->>'status_id')::uuid as status_id,
|
||||
(item->>'priority_id')::uuid as priority_id,
|
||||
(item->>'phase_id')::uuid as phase_id
|
||||
FROM json_array_elements(_updates) as item
|
||||
LOOP
|
||||
-- Update the appropriate sort column and other fields using dynamic SQL
|
||||
-- Only update sort_order if we're using the default sorting
|
||||
IF _sort_column = 'sort_order' THEN
|
||||
UPDATE tasks SET
|
||||
sort_order = _update_record.sort_order,
|
||||
status_id = COALESCE(_update_record.status_id, status_id),
|
||||
priority_id = COALESCE(_update_record.priority_id, priority_id)
|
||||
WHERE id = _update_record.task_id;
|
||||
ELSE
|
||||
-- Update only the grouping-specific sort column, not the main sort_order
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
|
||||
'status_id = COALESCE($2, status_id), ' ||
|
||||
'priority_id = COALESCE($3, priority_id) ' ||
|
||||
'WHERE id = $4';
|
||||
|
||||
EXECUTE _sql USING
|
||||
_update_record.sort_order,
|
||||
_update_record.status_id,
|
||||
_update_record.priority_id,
|
||||
_update_record.task_id;
|
||||
END IF;
|
||||
|
||||
-- Handle phase updates separately since it's in a different table
|
||||
IF _update_record.phase_id IS NOT NULL THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Updated main sort order change handler
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_from_index INT;
|
||||
_to_index INT;
|
||||
_task_id UUID;
|
||||
_project_id UUID;
|
||||
_from_group UUID;
|
||||
_to_group UUID;
|
||||
_group_by TEXT;
|
||||
_batch_size INT := 100;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
_project_id = (_body ->> 'project_id')::UUID;
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_from_index = (_body ->> 'from_index')::INT;
|
||||
_to_index = (_body ->> 'to_index')::INT;
|
||||
_from_group = (_body ->> 'from_group')::UUID;
|
||||
_to_group = (_body ->> 'to_group')::UUID;
|
||||
_group_by = (_body ->> 'group_by')::TEXT;
|
||||
|
||||
-- Get the appropriate sort column
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Handle group changes
|
||||
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
|
||||
IF (_group_by = 'status') THEN
|
||||
UPDATE tasks
|
||||
SET status_id = _to_group
|
||||
WHERE id = _task_id
|
||||
AND status_id = _from_group
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'priority') THEN
|
||||
UPDATE tasks
|
||||
SET priority_id = _to_group
|
||||
WHERE id = _task_id
|
||||
AND priority_id = _from_group
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'phase') THEN
|
||||
IF (is_null_or_empty(_to_group) IS FALSE) THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_task_id, _to_group)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
|
||||
ELSE
|
||||
DELETE FROM task_phase WHERE task_id = _task_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Handle sort order changes using dynamic SQL
|
||||
IF (_from_index <> _to_index) THEN
|
||||
-- For the main sort_order column, we need to be careful about unique constraints
|
||||
IF _sort_column = 'sort_order' THEN
|
||||
-- Use a transaction-safe approach for the main sort_order column
|
||||
IF (_to_index > _from_index) THEN
|
||||
-- Moving down: decrease sort_order for items between old and new position
|
||||
UPDATE tasks SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order <= _to_index;
|
||||
ELSE
|
||||
-- Moving up: increase sort_order for items between new and old position
|
||||
UPDATE tasks SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order >= _to_index
|
||||
AND sort_order < _from_index;
|
||||
END IF;
|
||||
|
||||
-- Set the new sort_order for the moved task
|
||||
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id;
|
||||
ELSE
|
||||
-- For grouping-specific columns, use dynamic SQL since there's no unique constraint
|
||||
IF (_to_index > _from_index) THEN
|
||||
-- Moving down: decrease sort_order for items between old and new position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1 ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
|
||||
EXECUTE _sql USING _project_id, _from_index, _to_index;
|
||||
ELSE
|
||||
-- Moving up: increase sort_order for items between new and old position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1 ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
|
||||
EXECUTE _sql USING _project_id, _to_index, _from_index;
|
||||
END IF;
|
||||
|
||||
-- Set the new sort_order for the moved task
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1 WHERE id = $2';
|
||||
EXECUTE _sql USING _to_index, _task_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,179 @@
|
||||
-- Migration: Fix sort order constraint violations
|
||||
|
||||
-- First, let's ensure all existing tasks have unique sort_order values within each project
|
||||
-- This is a one-time fix to ensure data consistency
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
-- For each project, reassign sort_order values to ensure uniqueness
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
-- Reassign sort_order values sequentially for this project
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Now create a better version of our functions that properly handles the constraints
|
||||
|
||||
-- Updated bulk sort order function that avoids sort_order conflicts
|
||||
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_update_record RECORD;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
-- Get the appropriate sort column based on grouping
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Process each update record
|
||||
FOR _update_record IN
|
||||
SELECT
|
||||
(item->>'task_id')::uuid as task_id,
|
||||
(item->>'sort_order')::int as sort_order,
|
||||
(item->>'status_id')::uuid as status_id,
|
||||
(item->>'priority_id')::uuid as priority_id,
|
||||
(item->>'phase_id')::uuid as phase_id
|
||||
FROM json_array_elements(_updates) as item
|
||||
LOOP
|
||||
-- Update the grouping-specific sort column and other fields
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
|
||||
'status_id = COALESCE($2, status_id), ' ||
|
||||
'priority_id = COALESCE($3, priority_id), ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE id = $4';
|
||||
|
||||
EXECUTE _sql USING
|
||||
_update_record.sort_order,
|
||||
_update_record.status_id,
|
||||
_update_record.priority_id,
|
||||
_update_record.task_id;
|
||||
|
||||
-- Handle phase updates separately since it's in a different table
|
||||
IF _update_record.phase_id IS NOT NULL THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Also update the helper function to be more explicit
|
||||
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
CASE _group_by
|
||||
WHEN 'status' THEN RETURN 'status_sort_order';
|
||||
WHEN 'priority' THEN RETURN 'priority_sort_order';
|
||||
WHEN 'phase' THEN RETURN 'phase_sort_order';
|
||||
WHEN 'members' THEN RETURN 'member_sort_order';
|
||||
-- For backward compatibility, still support general sort_order but be explicit
|
||||
WHEN 'general' THEN RETURN 'sort_order';
|
||||
ELSE RETURN 'status_sort_order'; -- Default to status sorting
|
||||
END CASE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Updated main sort order change handler that avoids conflicts
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_from_index INT;
|
||||
_to_index INT;
|
||||
_task_id UUID;
|
||||
_project_id UUID;
|
||||
_from_group UUID;
|
||||
_to_group UUID;
|
||||
_group_by TEXT;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
_project_id = (_body ->> 'project_id')::UUID;
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_from_index = (_body ->> 'from_index')::INT;
|
||||
_to_index = (_body ->> 'to_index')::INT;
|
||||
_from_group = (_body ->> 'from_group')::UUID;
|
||||
_to_group = (_body ->> 'to_group')::UUID;
|
||||
_group_by = (_body ->> 'group_by')::TEXT;
|
||||
|
||||
-- Get the appropriate sort column
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Handle group changes first
|
||||
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
|
||||
IF (_group_by = 'status') THEN
|
||||
UPDATE tasks
|
||||
SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'priority') THEN
|
||||
UPDATE tasks
|
||||
SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'phase') THEN
|
||||
IF (is_null_or_empty(_to_group) IS FALSE) THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_task_id, _to_group)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
|
||||
ELSE
|
||||
DELETE FROM task_phase WHERE task_id = _task_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Handle sort order changes for the grouping-specific column only
|
||||
IF (_from_index <> _to_index) THEN
|
||||
-- Update the grouping-specific sort order (no unique constraint issues)
|
||||
IF (_to_index > _from_index) THEN
|
||||
-- Moving down: decrease sort order for items between old and new position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
|
||||
EXECUTE _sql USING _project_id, _from_index, _to_index;
|
||||
ELSE
|
||||
-- Moving up: increase sort order for items between new and old position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
|
||||
EXECUTE _sql USING _project_id, _to_index, _from_index;
|
||||
END IF;
|
||||
|
||||
-- Set the new sort order for the moved task
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2';
|
||||
EXECUTE _sql USING _to_index, _task_id;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Migration: Add survey tables for account setup questionnaire
|
||||
-- Date: 2025-07-24
|
||||
-- Description: Creates tables to store survey questions and user responses for account setup flow
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Create surveys table to define different types of surveys
|
||||
CREATE TABLE IF NOT EXISTS surveys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL, -- 'account_setup', 'onboarding', 'feedback'
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Create survey_questions table to store individual questions
|
||||
CREATE TABLE IF NOT EXISTS survey_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||
question_key VARCHAR(100) NOT NULL, -- Used for localization keys
|
||||
question_type VARCHAR(50) NOT NULL, -- 'single_choice', 'multiple_choice', 'text'
|
||||
is_required BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
options JSONB, -- For choice questions, store options as JSON array
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Create survey_responses table to track user responses to surveys
|
||||
CREATE TABLE IF NOT EXISTS survey_responses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
is_completed BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
started_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
completed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Create survey_answers table to store individual question answers
|
||||
CREATE TABLE IF NOT EXISTS survey_answers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL,
|
||||
question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL,
|
||||
answer_text TEXT,
|
||||
answer_json JSONB, -- For multiple choice answers stored as array
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Add performance indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
|
||||
|
||||
-- Add constraints
|
||||
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0);
|
||||
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text'));
|
||||
|
||||
-- Add unique constraint to prevent duplicate responses per user per survey
|
||||
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
|
||||
|
||||
-- Add unique constraint to prevent duplicate answers per question per response
|
||||
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);
|
||||
|
||||
-- Insert the default account setup survey
|
||||
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
|
||||
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Get the survey ID for inserting questions
|
||||
DO $$
|
||||
DECLARE
|
||||
survey_uuid UUID;
|
||||
BEGIN
|
||||
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
|
||||
|
||||
-- Insert survey questions
|
||||
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
|
||||
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
|
||||
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
|
||||
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
|
||||
(survey_uuid, 'previous_tools', 'text', false, 4, null),
|
||||
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
|
||||
ON CONFLICT DO NOTHING;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
72
worklenz-backend/database/pg-migrations/README.md
Normal file
72
worklenz-backend/database/pg-migrations/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Node-pg-migrate Migrations
|
||||
|
||||
This directory contains database migrations managed by node-pg-migrate.
|
||||
|
||||
## Migration Commands
|
||||
|
||||
- `npm run migrate:create -- migration-name` - Create a new migration file
|
||||
- `npm run migrate:up` - Run all pending migrations
|
||||
- `npm run migrate:down` - Rollback the last migration
|
||||
- `npm run migrate:redo` - Rollback and re-run the last migration
|
||||
|
||||
## Migration File Format
|
||||
|
||||
Migrations are JavaScript files with timestamp prefixes (e.g., `20250115000000_performance-indexes.js`).
|
||||
|
||||
Each migration file exports two functions:
|
||||
- `exports.up` - Contains the forward migration logic
|
||||
- `exports.down` - Contains the rollback logic
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use IF EXISTS/IF NOT EXISTS checks** to make migrations idempotent
|
||||
2. **Test migrations locally** before deploying to production
|
||||
3. **Include rollback logic** in the `down` function for all changes
|
||||
4. **Use descriptive names** for migration files
|
||||
5. **Keep migrations focused** - one logical change per migration
|
||||
|
||||
## Example Migration
|
||||
|
||||
```javascript
|
||||
exports.up = pgm => {
|
||||
// Create table with IF NOT EXISTS
|
||||
pgm.createTable('users', {
|
||||
id: 'id',
|
||||
name: { type: 'varchar(100)', notNull: true },
|
||||
created_at: {
|
||||
type: 'timestamp',
|
||||
notNull: true,
|
||||
default: pgm.func('current_timestamp')
|
||||
}
|
||||
}, { ifNotExists: true });
|
||||
|
||||
// Add index with IF NOT EXISTS
|
||||
pgm.createIndex('users', 'name', {
|
||||
name: 'idx_users_name',
|
||||
ifNotExists: true
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = pgm => {
|
||||
// Drop in reverse order
|
||||
pgm.dropIndex('users', 'name', {
|
||||
name: 'idx_users_name',
|
||||
ifExists: true
|
||||
});
|
||||
|
||||
pgm.dropTable('users', { ifExists: true });
|
||||
};
|
||||
```
|
||||
|
||||
## Migration History
|
||||
|
||||
The `pgmigrations` table tracks which migrations have been run. Do not modify this table manually.
|
||||
|
||||
## Converting from SQL Migrations
|
||||
|
||||
When converting SQL migrations to node-pg-migrate format:
|
||||
|
||||
1. Wrap SQL statements in `pgm.sql()` calls
|
||||
2. Use node-pg-migrate helper methods where possible (createTable, addColumns, etc.)
|
||||
3. Always include `IF EXISTS/IF NOT EXISTS` checks
|
||||
4. Ensure proper rollback logic in the `down` function
|
||||
3
worklenz-backend/database/sql/0_extensions.sql
Normal file
3
worklenz-backend/database/sql/0_extensions.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "unaccent";
|
||||
2356
worklenz-backend/database/sql/1_tables.sql
Normal file
2356
worklenz-backend/database/sql/1_tables.sql
Normal file
File diff suppressed because it is too large
Load Diff
166
worklenz-backend/database/sql/2_dml.sql
Normal file
166
worklenz-backend/database/sql/2_dml.sql
Normal file
@@ -0,0 +1,166 @@
|
||||
CREATE OR REPLACE FUNCTION sys_insert_task_priorities() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO task_priorities (name, value, color_code, color_code_dark) VALUES ('Medium', 1, '#fbc84c', '#FFC227');
|
||||
INSERT INTO task_priorities (name, value, color_code, color_code_dark) VALUES ('Low', 0, '#75c997', '#46D980');
|
||||
INSERT INTO task_priorities (name, value, color_code, color_code_dark) VALUES ('High', 2, '#f37070', '#FF4141');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_access_levels() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO project_access_levels (name, key)
|
||||
VALUES ('Admin', 'ADMIN');
|
||||
INSERT INTO project_access_levels (name, key)
|
||||
VALUES ('Member', 'MEMBER');
|
||||
INSERT INTO project_access_levels (name, key)
|
||||
VALUES ('Project Manager', 'PROJECT_MANAGER');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_task_status_categories() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO public.sys_task_status_categories (name, color_code, index, is_todo, is_doing, is_done, description,
|
||||
color_code_dark)
|
||||
VALUES ('To do', '#a9a9a9', 1, TRUE, FALSE, FALSE,
|
||||
'For tasks that have not been started.', '#989898');
|
||||
INSERT INTO public.sys_task_status_categories (name, color_code, index, is_todo, is_doing, is_done, description,
|
||||
color_code_dark)
|
||||
VALUES ('Doing', '#70a6f3', 2, FALSE, TRUE, FALSE,
|
||||
'For tasks that have been started.', '#4190FF');
|
||||
INSERT INTO public.sys_task_status_categories (name, color_code, index, is_todo, is_doing, is_done, description,
|
||||
color_code_dark)
|
||||
VALUES ('Done', '#75c997', 3, FALSE, FALSE, TRUE,
|
||||
'For tasks that have been completed.', '#46D980');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_statuses() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO public.sys_project_statuses (name, color_code, icon, sort_order, is_default)
|
||||
VALUES ('Cancelled', '#f37070', 'close-circle', 0, FALSE),
|
||||
('Blocked', '#cbc8a1', 'stop', 1, FALSE),
|
||||
('On Hold', '#cbc8a1', 'stop', 2, FALSE),
|
||||
('Proposed', '#cbc8a1', 'clock-circle', 3, TRUE),
|
||||
('In Planning', '#cbc8a1', 'clock-circle', 4, FALSE),
|
||||
('In Progress', '#80ca79', 'clock-circle', 5, FALSE),
|
||||
('Completed', '#80ca79', 'check-circle', 6, FALSE),
|
||||
('Continuous', '#80ca79', 'clock-circle', 7, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_healths() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('Not Set', '#a9a9a9', 0, TRUE);
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('Needs Attention', '#fbc84c', 1, FALSE);
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('At Risk', '#f37070', 2, FALSE);
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('Good', '#75c997', 3, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_license_types() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO public.sys_license_types (name, key)
|
||||
VALUES ('Custom Subscription', 'CUSTOM'),
|
||||
('Free Trial', 'TRIAL'),
|
||||
('Paddle Subscription', 'PADDLE'),
|
||||
('Credit Subscription', 'CREDIT'),
|
||||
('Free Plan', 'FREE'),
|
||||
('Life Time Deal', 'LIFE_TIME_DEAL'),
|
||||
('Self Hosted', 'SELF_HOSTED');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_templates() RETURNS VOID AS
|
||||
$$
|
||||
DECLARE
|
||||
medium_priority_id UUID;
|
||||
todo_category_id UUID;
|
||||
doing_category_id UUID;
|
||||
done_category_id UUID;
|
||||
BEGIN
|
||||
-- Fetch IDs to avoid repeated subqueries
|
||||
SELECT id INTO medium_priority_id FROM task_priorities WHERE name = 'Medium' LIMIT 1;
|
||||
SELECT id INTO todo_category_id FROM public.sys_task_status_categories WHERE name = 'To do' LIMIT 1;
|
||||
SELECT id INTO doing_category_id FROM public.sys_task_status_categories WHERE name = 'Doing' LIMIT 1;
|
||||
SELECT id INTO done_category_id FROM public.sys_task_status_categories WHERE name = 'Done' LIMIT 1;
|
||||
|
||||
INSERT INTO public.pt_project_templates (id, name, key, description, phase_label, image_url, color_code)
|
||||
VALUES ('39db59be-1dba-448b-87f4-3b955ea699d2', 'Bug Tracking', 'BT', 'The "Bug Tracking" project template is a versatile solution meticulously designed to streamline and enhance the bug management processes of businesses across diverse industries. This template is especially valuable for organizations that rely on software development, IT services, or digital product management. It provides a structured and efficient approach to tracking, resolving, and improving software issues.', 'Phase', 'https://worklenz.s3.amazonaws.com/project-template-gifs/bug-tracking.gif', '#3b7ad4');
|
||||
|
||||
INSERT INTO public.pt_statuses (id, name, template_id, category_id)
|
||||
VALUES ('c3242606-5a24-48aa-8320-cc90a05c2589', 'To Do', '39db59be-1dba-448b-87f4-3b955ea699d2', todo_category_id),
|
||||
('05ed8d04-92b1-4c44-bd06-abee29641f31', 'Doing', '39db59be-1dba-448b-87f4-3b955ea699d2', doing_category_id),
|
||||
('66e80bc8-6b29-4e72-a484-1593eb1fb44b', 'Done', '39db59be-1dba-448b-87f4-3b955ea699d2', done_category_id);
|
||||
|
||||
INSERT INTO public.pt_tasks (id, name, description, total_minutes, sort_order, priority_id, template_id, parent_task_id, status_id)
|
||||
VALUES ('a75993d9-3fb3-4d0b-a5d4-cab53b60462c', 'Testing and Verification', NULL, 0, 0, medium_priority_id, '39db59be-1dba-448b-87f4-3b955ea699d2', NULL, 'c3242606-5a24-48aa-8320-cc90a05c2589'),
|
||||
('3fdb6801-bc09-4d71-8273-987cd3d1e0f6', 'Bug Prioritization', NULL, 0, 6, medium_priority_id, '39db59be-1dba-448b-87f4-3b955ea699d2', NULL, '05ed8d04-92b1-4c44-bd06-abee29641f31'),
|
||||
('ca64f247-a186-4edb-affd-738f1c2a4d60', 'Bug reporting', NULL, 0, 2, medium_priority_id, '39db59be-1dba-448b-87f4-3b955ea699d2', NULL, 'c3242606-5a24-48aa-8320-cc90a05c2589'),
|
||||
('1e493de8-38cf-4e6e-8f0b-5e1f6f3b07f4', 'Bug Assignment', NULL, 0, 5, medium_priority_id, '39db59be-1dba-448b-87f4-3b955ea699d2', NULL, '05ed8d04-92b1-4c44-bd06-abee29641f31'),
|
||||
('67b2ab3c-53e5-428c-bbad-8bdc19dc88de', 'Bug Closure', NULL, 0, 4, medium_priority_id, '39db59be-1dba-448b-87f4-3b955ea699d2', NULL, '66e80bc8-6b29-4e72-a484-1593eb1fb44b'),
|
||||
('9311ff84-1052-4989-8192-0fea20204fbe', 'Documentation', NULL, 0, 3, medium_priority_id, '39db59be-1dba-448b-87f4-3b955ea699d2', NULL, '66e80bc8-6b29-4e72-a484-1593eb1fb44b'),
|
||||
('7d0697cd-868c-4b41-9f4f-f9a8c1131b24', 'Reporting', NULL, 0, 1, medium_priority_id, '39db59be-1dba-448b-87f4-3b955ea699d2', NULL, '66e80bc8-6b29-4e72-a484-1593eb1fb44b');
|
||||
|
||||
INSERT INTO public.pt_task_phases (task_id, phase_id)
|
||||
VALUES ('a75993d9-3fb3-4d0b-a5d4-cab53b60462c', '4b4a8fe0-4f35-464a-a337-848e5b432ab5'),
|
||||
('3fdb6801-bc09-4d71-8273-987cd3d1e0f6', '557b58ca-3335-4b41-9880-fdd0f990deb9'),
|
||||
('ca64f247-a186-4edb-affd-738f1c2a4d60', '62097027-979f-4b00-afb8-f70fba533f80'),
|
||||
('1e493de8-38cf-4e6e-8f0b-5e1f6f3b07f4', 'e3128891-4873-4795-ad8a-880474280045'),
|
||||
('67b2ab3c-53e5-428c-bbad-8bdc19dc88de', '77204bf3-fcb3-4e39-a843-14458b2f659d'),
|
||||
('9311ff84-1052-4989-8192-0fea20204fbe', '62097027-979f-4b00-afb8-f70fba533f80'),
|
||||
('7d0697cd-868c-4b41-9f4f-f9a8c1131b24', '62097027-979f-4b00-afb8-f70fba533f80');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
SELECT sys_insert_task_priorities();
|
||||
SELECT sys_insert_project_access_levels();
|
||||
SELECT sys_insert_task_status_categories();
|
||||
SELECT sys_insert_project_statuses();
|
||||
SELECT sys_insert_project_healths();
|
||||
SELECT sys_insert_license_types();
|
||||
-- SELECT sys_insert_project_templates();
|
||||
|
||||
DROP FUNCTION sys_insert_task_priorities();
|
||||
DROP FUNCTION sys_insert_project_access_levels();
|
||||
DROP FUNCTION sys_insert_task_status_categories();
|
||||
DROP FUNCTION sys_insert_project_statuses();
|
||||
DROP FUNCTION sys_insert_project_healths();
|
||||
DROP FUNCTION sys_insert_license_types();
|
||||
-- DROP FUNCTION sys_insert_project_templates();
|
||||
|
||||
INSERT INTO timezones (name, abbrev, utc_offset)
|
||||
SELECT name, abbrev, utc_offset
|
||||
FROM pg_timezone_names;
|
||||
|
||||
-- Insert default account setup survey
|
||||
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
|
||||
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert survey questions for account setup survey
|
||||
DO $$
|
||||
DECLARE
|
||||
survey_uuid UUID;
|
||||
BEGIN
|
||||
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
|
||||
|
||||
-- Insert survey questions
|
||||
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
|
||||
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
|
||||
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
|
||||
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
|
||||
(survey_uuid, 'previous_tools', 'text', false, 4, null),
|
||||
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
|
||||
ON CONFLICT DO NOTHING;
|
||||
END $$;
|
||||
68
worklenz-backend/database/sql/3_views.sql
Normal file
68
worklenz-backend/database/sql/3_views.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
CREATE OR REPLACE VIEW task_labels_view(name, task_id, label_id) AS
|
||||
SELECT (SELECT team_labels.name
|
||||
FROM team_labels
|
||||
WHERE team_labels.id = task_labels.label_id) AS name,
|
||||
task_labels.task_id,
|
||||
task_labels.label_id
|
||||
FROM task_labels;
|
||||
|
||||
CREATE OR REPLACE VIEW tasks_with_status_view(task_id, parent_task_id, is_todo, is_doing, is_done) AS
|
||||
SELECT tasks.id AS task_id,
|
||||
tasks.parent_task_id,
|
||||
stsc.is_todo,
|
||||
stsc.is_doing,
|
||||
stsc.is_done
|
||||
FROM tasks
|
||||
JOIN task_statuses ts ON tasks.status_id = ts.id
|
||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||
WHERE tasks.archived IS FALSE;
|
||||
|
||||
CREATE OR REPLACE VIEW team_member_info_view(avatar_url, email, name, user_id, team_member_id, team_id, active) AS
|
||||
SELECT u.avatar_url,
|
||||
COALESCE(u.email, (SELECT email_invitations.email
|
||||
FROM email_invitations
|
||||
WHERE email_invitations.team_member_id = team_members.id)) AS email,
|
||||
COALESCE(u.name, (SELECT email_invitations.name
|
||||
FROM email_invitations
|
||||
WHERE email_invitations.team_member_id = team_members.id)) AS name,
|
||||
u.id AS user_id,
|
||||
team_members.id AS team_member_id,
|
||||
team_members.team_id,
|
||||
team_members.active
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Create materialized view for team member info
|
||||
-- This pre-calculates the expensive joins and subqueries from team_member_info_view
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS team_member_info_mv AS
|
||||
SELECT
|
||||
u.avatar_url,
|
||||
COALESCE(u.email, ei.email) AS email,
|
||||
COALESCE(u.name, ei.name) AS name,
|
||||
u.id AS user_id,
|
||||
tm.id AS team_member_id,
|
||||
tm.team_id,
|
||||
tm.active,
|
||||
u.socket_id
|
||||
FROM team_members tm
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
LEFT JOIN email_invitations ei ON ei.team_member_id = tm.id
|
||||
WHERE tm.active = TRUE;
|
||||
|
||||
-- Create unique index on the materialized view for fast lookups
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_member_info_mv_team_member_id
|
||||
ON team_member_info_mv(team_member_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_member_info_mv_team_user
|
||||
ON team_member_info_mv(team_id, user_id);
|
||||
|
||||
-- Function to refresh the materialized view
|
||||
CREATE OR REPLACE FUNCTION refresh_team_member_info_mv()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY team_member_info_mv;
|
||||
END;
|
||||
$$;
|
||||
|
||||
6650
worklenz-backend/database/sql/4_functions.sql
Normal file
6650
worklenz-backend/database/sql/4_functions.sql
Normal file
File diff suppressed because it is too large
Load Diff
31
worklenz-backend/database/sql/5_database_user.sql
Normal file
31
worklenz-backend/database/sql/5_database_user.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
CREATE ROLE worklenz_client;
|
||||
|
||||
GRANT CONNECT ON DATABASE worklenz_db TO worklenz_client;
|
||||
GRANT INSERT, SELECT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO worklenz_client;
|
||||
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON task_priorities FROM worklenz_client;
|
||||
GRANT SELECT ON task_priorities TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON project_access_levels FROM worklenz_client;
|
||||
GRANT SELECT ON project_access_levels TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON timezones FROM worklenz_client;
|
||||
GRANT SELECT ON timezones TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON worklenz_alerts FROM worklenz_client;
|
||||
GRANT SELECT ON worklenz_alerts TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON sys_task_status_categories FROM worklenz_client;
|
||||
GRANT SELECT ON sys_task_status_categories TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON sys_project_statuses FROM worklenz_client;
|
||||
GRANT SELECT ON sys_project_statuses TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON sys_project_healths FROM worklenz_client;
|
||||
GRANT SELECT ON sys_project_healths TO worklenz_client;
|
||||
|
||||
CREATE USER worklenz_backend WITH PASSWORD 'n?&bb24=aWmnw+G@';
|
||||
GRANT worklenz_client TO worklenz_backend;
|
||||
270
worklenz-backend/database/sql/indexes.sql
Normal file
270
worklenz-backend/database/sql/indexes.sql
Normal file
@@ -0,0 +1,270 @@
|
||||
-- Indexes
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS permissions_name_uindex
|
||||
ON permissions (name);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS bounced_emails_email_uindex
|
||||
ON bounced_emails (email);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS clients_id_team_id_index
|
||||
ON clients (id, team_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS clients_name_team_id_uindex
|
||||
ON clients (name, team_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS cpt_phases_name_project_uindex
|
||||
ON cpt_phases (name, template_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS cpt_task_phase_cpt_task_phase_uindex
|
||||
ON cpt_task_phases (task_id, phase_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS cpt_task_phase_task_id_uindex
|
||||
ON cpt_task_phases (task_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS cpt_task_statuses_template_id_name_uindex
|
||||
ON cpt_task_statuses (template_id, name);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS custom_project_templates_name_team_id_uindex
|
||||
ON custom_project_templates (name, team_id);
|
||||
|
||||
-- Create index on expire field
|
||||
CREATE INDEX IF NOT EXISTS idx_pg_sessions_expire
|
||||
ON pg_sessions (expire);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS job_titles_name_team_id_uindex
|
||||
ON job_titles (name, team_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS job_titles_team_id_index
|
||||
ON job_titles (team_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS licensing_admin_users_name_uindex
|
||||
ON licensing_admin_users (name);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS licensing_admin_users_phone_no_uindex
|
||||
ON licensing_admin_users (phone_no);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS licensing_admin_users_username_uindex
|
||||
ON licensing_admin_users (username);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS licensing_coupon_codes_coupon_code_uindex
|
||||
ON licensing_coupon_codes (coupon_code);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS licensing_coupon_codes_redeemed_by_index
|
||||
ON licensing_coupon_codes (redeemed_by);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS licensing_pricing_plans_uindex
|
||||
ON licensing_pricing_plans (id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS licensing_user_plans_uindex
|
||||
ON licensing_user_subscriptions (id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS licensing_user_subscriptions_user_id_index
|
||||
ON licensing_user_subscriptions (user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS notification_settings_team_user_id_index
|
||||
ON notification_settings (team_id, user_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS personal_todo_list_index_uindex
|
||||
ON personal_todo_list (user_id, index);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS project_access_levels_key_uindex
|
||||
ON project_access_levels (key);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS project_access_levels_name_uindex
|
||||
ON project_access_levels (name);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS project_categories_name_team_id_uindex
|
||||
ON project_categories (name, team_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS project_comments_project_id_index
|
||||
ON project_comments (project_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS project_folders_team_id_key_uindex
|
||||
ON project_folders (team_id, key);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS project_folders_team_id_name_uindex
|
||||
ON project_folders (team_id, name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS project_members_project_id_index
|
||||
ON project_members (project_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS project_members_project_id_member_id_index
|
||||
ON project_members (project_id, team_member_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS project_members_team_member_id_index
|
||||
ON project_members (team_member_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS project_members_team_member_project_uindex
|
||||
ON project_members (team_member_id, project_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS project_phases_name_project_uindex
|
||||
ON project_phases (name, project_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS project_subscribers_user_task_team_member_uindex
|
||||
ON project_subscribers (user_id, project_id, team_member_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS project_task_list_cols_index
|
||||
ON project_task_list_cols (project_id, index);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS project_task_list_cols_key_project_uindex
|
||||
ON project_task_list_cols (key, project_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_folder_id_index
|
||||
ON projects (folder_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_id_team_id_index
|
||||
ON projects (id, team_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS projects_key_team_id_uindex
|
||||
ON projects (key, team_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_name_index
|
||||
ON projects (name);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS projects_name_team_id_uindex
|
||||
ON projects (name, team_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_team_id_folder_id_index
|
||||
ON projects (team_id, folder_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_team_id_index
|
||||
ON projects (team_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_team_id_name_index
|
||||
ON projects (team_id, name);
|
||||
|
||||
-- Performance indexes for optimized tasks queries
|
||||
-- From migration: 20250115000000-performance-indexes.sql
|
||||
|
||||
-- Composite index for main task filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
|
||||
ON tasks(project_id, archived, parent_task_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for status joins
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
|
||||
ON tasks(status_id, project_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for assignees lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
|
||||
ON tasks_assignees(task_id, team_member_id);
|
||||
|
||||
-- Index for phase lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
|
||||
ON task_phase(task_id, phase_id);
|
||||
|
||||
-- Index for subtask counting
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
|
||||
ON tasks(parent_task_id, archived)
|
||||
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
|
||||
|
||||
-- Index for labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
|
||||
ON task_labels(task_id, label_id);
|
||||
|
||||
-- Index for comments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
|
||||
ON task_comments(task_id);
|
||||
|
||||
-- Index for attachments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
|
||||
ON task_attachments(task_id);
|
||||
|
||||
-- Index for work log aggregation
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
|
||||
ON task_work_log(task_id);
|
||||
|
||||
-- Index for subscribers check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
|
||||
ON task_subscribers(task_id);
|
||||
|
||||
-- Index for dependencies check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
|
||||
ON task_dependencies(task_id);
|
||||
|
||||
-- Index for timers lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
|
||||
ON task_timers(task_id, user_id);
|
||||
|
||||
-- Index for custom columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
|
||||
ON cc_column_values(task_id);
|
||||
|
||||
-- Index for team member info view optimization
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
|
||||
ON team_members(team_id, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Index for notification settings
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
|
||||
ON notification_settings(user_id, team_id);
|
||||
|
||||
-- Index for task status categories
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
|
||||
ON task_statuses(category_id, project_id);
|
||||
|
||||
-- Index for project phases
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
|
||||
ON project_phases(project_id, sort_index);
|
||||
|
||||
-- Index for task priorities
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
|
||||
ON task_priorities(value);
|
||||
|
||||
-- Index for team labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
|
||||
ON team_labels(team_id);
|
||||
|
||||
-- Advanced performance indexes for task optimization
|
||||
|
||||
-- Composite index for task main query optimization (covers most WHERE conditions)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
|
||||
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for sorting by sort_order with project filter
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
|
||||
ON tasks(project_id, sort_order)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for email_invitations to optimize team_member_info_view
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
|
||||
ON email_invitations(team_member_id);
|
||||
|
||||
-- Covering index for task status with category information
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
|
||||
ON task_statuses(id, category_id, project_id);
|
||||
|
||||
-- Index for task aggregation queries (parent task progress calculation)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
|
||||
ON tasks(parent_task_id, status_id, archived)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for project team member filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
|
||||
ON team_members(team_id, active, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Covering index for tasks with frequently accessed columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
|
||||
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task search functionality
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
|
||||
ON tasks USING gin(to_tsvector('english', name))
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for date-based filtering (if used)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
|
||||
ON tasks(project_id, start_date, end_date)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task timers with user filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
|
||||
ON task_timers(user_id, task_id);
|
||||
|
||||
-- Index for sys_task_status_categories lookups
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
|
||||
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);
|
||||
|
||||
47
worklenz-backend/database/sql/text_length_checks.sql
Normal file
47
worklenz-backend/database/sql/text_length_checks.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
ALTER TABLE teams
|
||||
ADD CONSTRAINT teams_name_check CHECK (CHAR_LENGTH(name) <= 55);
|
||||
|
||||
ALTER TABLE clients
|
||||
ADD CONSTRAINT clients_name_check CHECK (CHAR_LENGTH(name) <= 60);
|
||||
|
||||
ALTER TABLE job_titles
|
||||
ADD CONSTRAINT job_titles_name_check CHECK (CHAR_LENGTH(name) <= 55);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_name_check CHECK (CHAR_LENGTH(name) <= 55);
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_email_check CHECK (CHAR_LENGTH(email) <= 255);
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT projects_name_check CHECK (CHAR_LENGTH(name) <= 100);
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT projects_notes_check CHECK (CHAR_LENGTH(notes) <= 500);
|
||||
|
||||
ALTER TABLE task_statuses
|
||||
ADD CONSTRAINT task_statuses_name_check CHECK (CHAR_LENGTH(name) <= 50);
|
||||
|
||||
ALTER TABLE tasks
|
||||
ADD CONSTRAINT tasks_name_check CHECK (CHAR_LENGTH(name) <= 500);
|
||||
|
||||
ALTER TABLE tasks
|
||||
ADD CONSTRAINT tasks_description_check CHECK (CHAR_LENGTH(description) <= 500000);
|
||||
|
||||
ALTER TABLE team_labels
|
||||
ADD CONSTRAINT team_labels_name_check CHECK (CHAR_LENGTH(name) <= 40);
|
||||
|
||||
ALTER TABLE personal_todo_list
|
||||
ADD CONSTRAINT personal_todo_list_name_check CHECK (CHAR_LENGTH(name) <= 100);
|
||||
|
||||
ALTER TABLE personal_todo_list
|
||||
ADD CONSTRAINT personal_todo_list_description_check CHECK (CHAR_LENGTH(description) <= 200);
|
||||
|
||||
ALTER TABLE task_work_log
|
||||
ADD CONSTRAINT task_work_log_description_check CHECK (CHAR_LENGTH(description) <= 500);
|
||||
|
||||
ALTER TABLE task_comment_contents
|
||||
ADD CONSTRAINT task_comment_contents_name_check CHECK (CHAR_LENGTH(text_content) <= 500);
|
||||
|
||||
ALTER TABLE task_attachments
|
||||
ADD CONSTRAINT task_attachments_name_check CHECK (CHAR_LENGTH(name) <= 110);
|
||||
75
worklenz-backend/database/sql/truncate.sql
Normal file
75
worklenz-backend/database/sql/truncate.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
TRUNCATE TABLE public.pg_sessions RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.email_invitations RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_labels RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.team_labels RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.tasks_assignees RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.project_members RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.project_access_levels RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.role_permissions RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.permissions RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.project_logs RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.personal_todo_list RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.user_notifications RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_work_log RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_comment_contents RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_comments RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.team_members RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.job_titles RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.roles RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_attachments RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.worklenz_alerts RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.favorite_projects RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.archived_projects RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.shared_projects RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_templates_tasks RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_templates RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.notification_settings RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_updates RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_timers RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.tasks RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_priorities RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.task_statuses RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.sys_task_status_categories RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.project_task_list_cols RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.projects RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.clients RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.teams, public.users RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.timezones RESTART IDENTITY CASCADE;
|
||||
|
||||
TRUNCATE TABLE public.sys_project_statuses RESTART IDENTITY CASCADE;
|
||||
2533
worklenz-backend/database/worklenz_db_revision_1.svg
Normal file
2533
worklenz-backend/database/worklenz_db_revision_1.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 265 KiB |
7392
worklenz-backend/database/worklenz_db_revision_2.svg
Normal file
7392
worklenz-backend/database/worklenz_db_revision_2.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 737 KiB |
@@ -1,28 +0,0 @@
|
||||
module.exports = {
|
||||
brotli_js: {
|
||||
options: {
|
||||
mode: "brotli",
|
||||
brotli: {
|
||||
mode: 1
|
||||
}
|
||||
},
|
||||
expand: true,
|
||||
cwd: "build/public",
|
||||
src: ["**/*.js"],
|
||||
dest: "build/public",
|
||||
extDot: "last",
|
||||
ext: ".js.br"
|
||||
},
|
||||
gzip_js: {
|
||||
options: {
|
||||
mode: "gzip"
|
||||
},
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build/public",
|
||||
src: ["**/*.js"],
|
||||
dest: "build/public",
|
||||
ext: ".js.gz"
|
||||
}]
|
||||
}
|
||||
};
|
||||
12712
worklenz-backend/package-lock.json
generated
12712
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,22 +4,37 @@
|
||||
"private": true,
|
||||
"engines": {
|
||||
"npm": ">=8.11.0",
|
||||
"node": ">=16.13.0",
|
||||
"node": ">=20.0.0",
|
||||
"yarn": "WARNING: Please use npm package manager instead of yarn"
|
||||
},
|
||||
"main": "build/bin/www",
|
||||
"repository": "GITHUB_REPO_HERE",
|
||||
"author": "worklenz.com",
|
||||
"scripts": {
|
||||
"start": "node ./build/bin/www",
|
||||
"tcs": "grunt build:tsc",
|
||||
"build": "grunt build",
|
||||
"watch": "grunt watch",
|
||||
"es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist",
|
||||
"copy": "grunt copy",
|
||||
"test": "jest",
|
||||
"start": "node build/bin/www.js",
|
||||
"dev": "npm run build:dev && npm run watch",
|
||||
"build": "npm run clean && npm run compile && npm run copy && npm run compress",
|
||||
"build:dev": "npm run clean && npm run compile:dev && npm run copy",
|
||||
"build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress",
|
||||
"clean": "rimraf build",
|
||||
"compile": "tsc --build tsconfig.prod.json",
|
||||
"compile:dev": "tsc --build tsconfig.json",
|
||||
"compile:prod": "tsc --build tsconfig.prod.json",
|
||||
"copy": "npm run copy:assets && npm run copy:views && npm run copy:config && npm run copy:shared",
|
||||
"copy:assets": "npx cpx2 \"src/public/**\" build/public",
|
||||
"copy:views": "npx cpx2 \"src/views/**\" build/views",
|
||||
"copy:config": "npx cpx2 \".env\" build && npx cpx2 \"package.json\" build",
|
||||
"copy:shared": "npx cpx2 \"src/shared/postgresql-error-codes.json\" build/shared && npx cpx2 \"src/shared/sample-data.json\" build/shared && npx cpx2 \"src/shared/templates/**\" build/shared/templates",
|
||||
"watch": "concurrently \"npm run watch:ts\" \"npm run watch:assets\"",
|
||||
"watch:ts": "tsc --build tsconfig.json --watch",
|
||||
"watch:assets": "npx cpx2 \"src/{public,views}/**\" build --watch",
|
||||
"minify": "terser build/**/*.js --compress --mangle --output-dir build",
|
||||
"compress": "node scripts/compress.js",
|
||||
"swagger": "node ./cli/swagger",
|
||||
"inline-queries": "node ./cli/inline-queries",
|
||||
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
||||
"tsc": "tsc",
|
||||
"test": "jest --setupFiles dotenv/config",
|
||||
"test:watch": "jest --watch --setupFiles dotenv/config"
|
||||
},
|
||||
"jestSonar": {
|
||||
@@ -32,15 +47,19 @@
|
||||
"@aws-sdk/client-ses": "^3.378.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.378.0",
|
||||
"@aws-sdk/util-format-url": "^3.357.0",
|
||||
"@azure/storage-blob": "^12.27.0",
|
||||
"axios": "^1.6.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"bluebird": "^3.7.2",
|
||||
"chartjs-to-image": "^1.2.1",
|
||||
"compression": "^1.7.4",
|
||||
"connect-flash": "^0.1.1",
|
||||
"connect-pg-simple": "^7.0.0",
|
||||
"cookie-parser": "~1.4.4",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^2.4.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"csrf-sync": "^4.2.1",
|
||||
"csurf": "^1.11.0",
|
||||
"debug": "^4.3.4",
|
||||
"dotenv": "^16.3.1",
|
||||
@@ -60,13 +79,12 @@
|
||||
"moment-timezone": "^0.5.43",
|
||||
"morgan": "^1.10.0",
|
||||
"nanoid": "^3.3.6",
|
||||
"passport": "^0.5.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.11.1",
|
||||
"pg-native": "^3.0.1",
|
||||
"pg": "^8.14.1",
|
||||
"pug": "^3.0.2",
|
||||
"redis": "^4.6.7",
|
||||
"sanitize-html": "^2.11.0",
|
||||
@@ -74,8 +92,10 @@
|
||||
"sharp": "^0.32.6",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.7.1",
|
||||
"tinymce": "^7.8.0",
|
||||
"uglify-js": "^3.17.4",
|
||||
"winston": "^3.10.0",
|
||||
"worklenz-backend": "file:",
|
||||
"xss-filters": "^1.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -83,17 +103,18 @@
|
||||
"@babel/preset-typescript": "^7.22.5",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bluebird": "^3.5.38",
|
||||
"@types/body-parser": "^1.19.2",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/connect-flash": "^0.0.37",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/csurf": "^1.11.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-brute": "^1.0.2",
|
||||
"@types/express-brute-redis": "^0.0.4",
|
||||
"@types/express-rate-limit": "^6.0.0",
|
||||
"@types/express-serve-static-core": "^4.17.34",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"@types/express-validator": "^3.0.0",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/hpp": "^0.2.2",
|
||||
"@types/http-errors": "^1.8.2",
|
||||
@@ -103,15 +124,13 @@
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/morgan": "^1.9.4",
|
||||
"@types/node": "^18.17.1",
|
||||
"@types/passport": "^1.0.12",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
"@types/passport-local": "^1.0.35",
|
||||
"@types/pg": "^8.10.2",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-google-oauth20": "^2.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/pug": "^2.0.6",
|
||||
"@types/redis": "^4.0.11",
|
||||
"@types/sanitize-html": "^2.9.0",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/socket.io": "^3.0.2",
|
||||
"@types/swagger-jsdoc": "^6.0.1",
|
||||
"@types/toobusy-js": "^0.5.2",
|
||||
"@types/uglify-js": "^3.17.1",
|
||||
@@ -119,26 +138,22 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"concurrently": "^9.1.2",
|
||||
"cpx2": "^8.0.0",
|
||||
"esbuild": "^0.17.19",
|
||||
"esbuild-envfile-plugin": "^1.0.5",
|
||||
"esbuild-node-externals": "^1.8.0",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-security": "^1.7.1",
|
||||
"fs-extra": "^10.1.0",
|
||||
"grunt": "^1.6.1",
|
||||
"grunt-cli": "^1.4.3",
|
||||
"grunt-contrib-clean": "^2.0.1",
|
||||
"grunt-contrib-compress": "^2.0.0",
|
||||
"grunt-contrib-copy": "^1.0.0",
|
||||
"grunt-contrib-uglify": "^5.2.2",
|
||||
"grunt-contrib-watch": "^1.1.0",
|
||||
"grunt-shell": "^4.0.0",
|
||||
"grunt-sync": "^0.8.2",
|
||||
"highcharts": "^11.1.0",
|
||||
"jest": "^28.1.3",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"ncp": "^2.0.0",
|
||||
"nodeman": "^1.1.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"terser": "^5.40.0",
|
||||
"ts-jest": "^28.0.8",
|
||||
"ts-node": "^10.9.1",
|
||||
"tslint": "^6.1.3",
|
||||
|
||||
@@ -1 +1 @@
|
||||
901
|
||||
954
|
||||
53
worklenz-backend/scripts/compress.js
Normal file
53
worklenz-backend/scripts/compress.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createGzip } = require('zlib');
|
||||
const { pipeline } = require('stream');
|
||||
|
||||
async function compressFile(inputPath, outputPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const gzip = createGzip();
|
||||
const source = fs.createReadStream(inputPath);
|
||||
const destination = fs.createWriteStream(outputPath);
|
||||
|
||||
pipeline(source, gzip, destination, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function compressDirectory(dir) {
|
||||
const files = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) {
|
||||
await compressDirectory(fullPath);
|
||||
} else if (file.name.endsWith('.js') || file.name.endsWith('.css')) {
|
||||
const gzPath = fullPath + '.gz';
|
||||
await compressFile(fullPath, gzPath);
|
||||
console.log(`Compressed: ${fullPath} -> ${gzPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const buildDir = path.join(__dirname, '../build');
|
||||
if (fs.existsSync(buildDir)) {
|
||||
await compressDirectory(buildDir);
|
||||
console.log('Compression complete!');
|
||||
} else {
|
||||
console.log('Build directory not found. Run build first.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Compression failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,109 +1,172 @@
|
||||
import createError from "http-errors";
|
||||
import express, {NextFunction, Request, Response} from "express";
|
||||
import express, { NextFunction, Request, Response } from "express";
|
||||
import path from "path";
|
||||
import cookieParser from "cookie-parser";
|
||||
import logger from "morgan";
|
||||
import helmet from "helmet";
|
||||
import compression from "compression";
|
||||
import passport from "passport";
|
||||
import csurf from "csurf";
|
||||
import { csrfSync } from "csrf-sync";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import cors from "cors";
|
||||
import uglify from "uglify-js";
|
||||
import flash from "connect-flash";
|
||||
import hpp from "hpp";
|
||||
|
||||
import passportConfig from "./passport";
|
||||
import indexRouter from "./routes/index";
|
||||
import apiRouter from "./routes/apis";
|
||||
import authRouter from "./routes/auth";
|
||||
import emailTemplatesRouter from "./routes/email-templates";
|
||||
|
||||
import public_router from "./routes/public";
|
||||
import {isInternalServer, isProduction} from "./shared/utils";
|
||||
import { isInternalServer, isProduction } from "./shared/utils";
|
||||
import sessionMiddleware from "./middlewares/session-middleware";
|
||||
import {send_to_slack} from "./shared/slack";
|
||||
import {CSP_POLICIES} from "./shared/csp";
|
||||
import safeControllerFunction from "./shared/safe-controller-function";
|
||||
import AwsSesController from "./controllers/aws-ses-controller";
|
||||
import { CSP_POLICIES } from "./shared/csp";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(compression());
|
||||
app.use(helmet({crossOriginResourcePolicy: false, crossOriginEmbedderPolicy: false}));
|
||||
// Trust first proxy if behind reverse proxy
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
// Basic middleware setup
|
||||
app.use(compression());
|
||||
app.use(logger("dev"));
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
app.use(express.urlencoded({ extended: false, limit: "50mb" }));
|
||||
app.use(cookieParser(process.env.COOKIE_SECRET));
|
||||
app.use(hpp());
|
||||
|
||||
// Helmet security headers
|
||||
app.use(helmet({
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: false,
|
||||
}));
|
||||
|
||||
// Custom security headers
|
||||
app.use((_req: Request, res: Response, next: NextFunction) => {
|
||||
res.setHeader("X-XSS-Protection", "1; mode=block");
|
||||
res.removeHeader("server");
|
||||
res.setHeader("Content-Security-Policy", CSP_POLICIES);
|
||||
next();
|
||||
});
|
||||
|
||||
// CORS configuration
|
||||
const allowedOrigins = [
|
||||
isProduction()
|
||||
? [
|
||||
`http://localhost:5000`,
|
||||
`http://127.0.0.1:5000`,
|
||||
process.env.SERVER_CORS || "", // Add hostname from env
|
||||
process.env.FRONTEND_URL || "" // Support FRONTEND_URL as well
|
||||
].filter(Boolean) // Remove empty strings
|
||||
: [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5000",
|
||||
`http://localhost:5000`,
|
||||
process.env.SERVER_CORS || "", // Add hostname from env
|
||||
process.env.FRONTEND_URL || "" // Support FRONTEND_URL as well
|
||||
].filter(Boolean) // Remove empty strings
|
||||
].flat();
|
||||
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!isProduction() || !origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
console.log("Blocked origin:", origin, process.env.NODE_ENV);
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||
allowedHeaders: [
|
||||
"Origin",
|
||||
"X-Requested-With",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"X-CSRF-Token"
|
||||
],
|
||||
exposedHeaders: ["Set-Cookie", "X-CSRF-Token"]
|
||||
}));
|
||||
|
||||
// Handle preflight requests
|
||||
app.options("*", cors());
|
||||
|
||||
// Session setup - must be before passport and CSRF
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
// Passport initialization
|
||||
passportConfig(passport);
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
// Flash messages
|
||||
app.use(flash());
|
||||
|
||||
// Auth check middleware
|
||||
function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
|
||||
return req.user ? next() : next(createError(401));
|
||||
}
|
||||
|
||||
passportConfig(passport);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
require("pug").filters = {
|
||||
/**
|
||||
* ```pug
|
||||
* script
|
||||
* :minify_js
|
||||
* // JavaScript Syntax
|
||||
* ```
|
||||
* @param {String} text
|
||||
* @param {Object} options
|
||||
*/
|
||||
minify_js(text: string) {
|
||||
if (!text) return;
|
||||
// return text;
|
||||
return uglify.minify({"script.js": text}).code;
|
||||
}
|
||||
};
|
||||
|
||||
// view engine setup
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.set("view engine", "pug");
|
||||
|
||||
app.use(logger("dev"));
|
||||
app.use(express.json({limit: "50mb"}));
|
||||
app.use(express.urlencoded({extended: false, limit: "50mb"}));
|
||||
// Prevent HTTP Parameter Pollution
|
||||
app.use(hpp());
|
||||
app.use(cookieParser(process.env.COOKIE_SECRET));
|
||||
|
||||
app.use(cors({
|
||||
origin: [`https://${process.env.HOSTNAME}`],
|
||||
methods: "GET,PUT,POST,DELETE",
|
||||
preflightContinue: false,
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
app.post("/-/csp", (req: express.Request, res: express.Response) => {
|
||||
send_to_slack({
|
||||
type: "⚠️ CSP Report",
|
||||
body: req.body
|
||||
});
|
||||
res.sendStatus(200);
|
||||
// CSRF configuration using csrf-sync for session-based authentication
|
||||
const {
|
||||
invalidCsrfTokenError,
|
||||
generateToken,
|
||||
csrfSynchronisedProtection,
|
||||
} = csrfSync({
|
||||
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
|
||||
});
|
||||
|
||||
// Apply CSRF selectively (exclude webhooks and public routes)
|
||||
app.use((req, res, next) => {
|
||||
if (
|
||||
req.path.startsWith("/webhook/") ||
|
||||
req.path.startsWith("/secure/") ||
|
||||
req.path.startsWith("/api/") ||
|
||||
req.path.startsWith("/public/")
|
||||
) {
|
||||
next();
|
||||
} else {
|
||||
csrfSynchronisedProtection(req, res, next);
|
||||
}
|
||||
});
|
||||
|
||||
// Set CSRF token method on request object for compatibility
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
// Add csrfToken method to request object for compatibility
|
||||
if (!req.csrfToken && generateToken) {
|
||||
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// CSRF token refresh endpoint
|
||||
app.get("/csrf-token", (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = generateToken(req);
|
||||
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
|
||||
} catch (error) {
|
||||
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
|
||||
}
|
||||
});
|
||||
|
||||
// Webhook endpoints (no CSRF required)
|
||||
app.post("/webhook/emails/bounce", safeControllerFunction(AwsSesController.handleBounceResponse));
|
||||
app.post("/webhook/emails/complaints", safeControllerFunction(AwsSesController.handleComplaintResponse));
|
||||
app.post("/webhook/emails/reply", safeControllerFunction(AwsSesController.handleReplies));
|
||||
|
||||
app.use(flash());
|
||||
app.use(csurf({cookie: true}));
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
res.setHeader("Content-Security-Policy", CSP_POLICIES);
|
||||
const token = req.csrfToken();
|
||||
res.cookie("XSRF-TOKEN", token);
|
||||
res.locals.csrf = token;
|
||||
next();
|
||||
});
|
||||
|
||||
// Static file serving
|
||||
if (isProduction()) {
|
||||
app.use(express.static(path.join(__dirname, "build"), {
|
||||
maxAge: "1y",
|
||||
etag: false,
|
||||
}));
|
||||
|
||||
// Handle compressed files
|
||||
app.get("*.js", (req, res, next) => {
|
||||
if (req.header("Accept-Encoding")?.includes("br")) {
|
||||
req.url = `${req.url}.br`;
|
||||
@@ -116,61 +179,62 @@ if (isProduction()) {
|
||||
}
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
}
|
||||
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
app.set("trust proxy", 1);
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
// API rate limiting
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 1500, // Limit each IP to 2000 requests per `window` (here, per 15 minutes)
|
||||
standardHeaders: false, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const {send} = res;
|
||||
res.send = function (obj) {
|
||||
if (req.headers.accept?.includes("application/json"))
|
||||
return send.call(this, `)]}',\n${JSON.stringify(obj)}`);
|
||||
return send.call(this, obj);
|
||||
};
|
||||
next();
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 1500,
|
||||
standardHeaders: false,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use("/api/v1", apiLimiter, isLoggedIn, apiRouter);
|
||||
app.use("/secure", authRouter);
|
||||
app.use("/public", public_router);
|
||||
app.use("/api/v1", isLoggedIn, apiRouter);
|
||||
app.use("/", indexRouter);
|
||||
|
||||
if (isInternalServer())
|
||||
if (isInternalServer()) {
|
||||
app.use("/email-templates", emailTemplatesRouter);
|
||||
}
|
||||
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use((req: Request, res: Response) => {
|
||||
res.locals.error_title = "404 Not Found.";
|
||||
res.locals.error_message = `The requested URL ${req.url} was not found on this server.`;
|
||||
res.locals.error_image = "/assets/images/404.webp";
|
||||
res.status(400);
|
||||
res.render("error");
|
||||
// CSRF error handler
|
||||
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
||||
if (err === invalidCsrfTokenError) {
|
||||
return res.status(403).json({
|
||||
done: false,
|
||||
message: "Invalid CSRF token",
|
||||
body: null
|
||||
});
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
|
||||
// error handler
|
||||
app.use((err: { message: string; status: number; }, _req: Request, res: Response) => {
|
||||
// set locals, only providing error in development
|
||||
res.locals.error_title = "500 Internal Server Error.";
|
||||
res.locals.error_message = "Oops, something went wrong.";
|
||||
res.locals.error_message2 = "Try to refresh this page or feel free to contact us if the problem persists.";
|
||||
res.locals.error_image = "/assets/images/500.png";
|
||||
|
||||
// render the error page
|
||||
res.status(err.status || 500);
|
||||
res.render("error");
|
||||
// React app handling - serve index.html for all non-API routes
|
||||
app.get("*", (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path.startsWith("/api/")) return next();
|
||||
res.sendFile(path.join(__dirname, isProduction() ? "build" : "public", "index.html"));
|
||||
});
|
||||
|
||||
export default app;
|
||||
// Global error handler
|
||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const status = err.status || 500;
|
||||
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(status);
|
||||
|
||||
// Send structured error response
|
||||
res.json({
|
||||
done: false,
|
||||
message: isProduction() ? "Internal Server Error" : err.message,
|
||||
body: null,
|
||||
...(process.env.NODE_ENV === "development" ? { stack: err.stack } : {})
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -95,8 +95,7 @@ function onListening() {
|
||||
? `pipe ${addr}`
|
||||
: `port ${addr.port}`;
|
||||
|
||||
startCronJobs();
|
||||
// TODO - uncomment initRedis()
|
||||
process.env.ENABLE_EMAIL_CRONJOBS === "true" && startCronJobs();
|
||||
// void initRedis();
|
||||
FileConstants.init();
|
||||
void DbTaskStatusChangeListener.connect();
|
||||
|
||||
@@ -5,8 +5,19 @@ import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {getColor} from "../shared/utils";
|
||||
import {calculateMonthDays, getColor, log_error, megabytesToBytes} from "../shared/utils";
|
||||
import moment from "moment";
|
||||
import {calculateStorage} from "../shared/s3";
|
||||
import {checkTeamSubscriptionStatus, getActiveTeamMemberCount, getCurrentProjectsCount, getFreePlanSettings, getOwnerIdByTeam, getTeamMemberCount, getUsedStorage} from "../shared/paddle-utils";
|
||||
import {
|
||||
addModifier,
|
||||
cancelSubscription,
|
||||
changePlan,
|
||||
generatePayLinkRequest,
|
||||
pauseOrResumeSubscription,
|
||||
updateUsers
|
||||
} from "../shared/paddle-requests";
|
||||
import {statusExclude} from "../shared/constants";
|
||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||
import {SocketEvents} from "../socket.io/events";
|
||||
import {IO} from "../shared/io";
|
||||
@@ -221,7 +232,11 @@ export default class AdminCenterController extends WorklenzControllerBase {
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
role_id,
|
||||
r.name AS role_name
|
||||
r.name AS role_name,
|
||||
EXISTS(SELECT email
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = tm.id
|
||||
AND email_invitations.team_id = tm.team_id) AS pending_invitation
|
||||
FROM team_members tm
|
||||
LEFT JOIN users u on tm.user_id = u.id
|
||||
LEFT JOIN roles r on tm.role_id = r.id
|
||||
@@ -244,22 +259,411 @@ export default class AdminCenterController extends WorklenzControllerBase {
|
||||
const {id} = req.params;
|
||||
const {name, teamMembers} = req.body;
|
||||
|
||||
const updateNameQuery = `UPDATE teams
|
||||
SET name = $1
|
||||
WHERE id = $2;`;
|
||||
await db.query(updateNameQuery, [name, id]);
|
||||
try {
|
||||
// Update team name
|
||||
const updateNameQuery = `UPDATE teams SET name = $1 WHERE id = $2 RETURNING id;`;
|
||||
const nameResult = await db.query(updateNameQuery, [name, id]);
|
||||
|
||||
if (!nameResult.rows.length) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Team not found"));
|
||||
}
|
||||
|
||||
if (teamMembers.length) {
|
||||
teamMembers.forEach(async (element: { role_name: string; user_id: string; }) => {
|
||||
const q = `UPDATE team_members
|
||||
SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2)
|
||||
WHERE user_id = $3
|
||||
AND team_id = $1;`;
|
||||
await db.query(q, [id, element.role_name, element.user_id]);
|
||||
});
|
||||
// Update team member roles if provided
|
||||
if (teamMembers?.length) {
|
||||
// Use Promise.all to handle all role updates concurrently
|
||||
await Promise.all(teamMembers.map(async (member: { role_name: string; user_id: string; }) => {
|
||||
const roleQuery = `
|
||||
UPDATE team_members
|
||||
SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2)
|
||||
WHERE user_id = $3 AND team_id = $1
|
||||
RETURNING id;`;
|
||||
await db.query(roleQuery, [id, member.role_name, member.user_id]);
|
||||
}));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, null, "Team updated successfully"));
|
||||
} catch (error) {
|
||||
log_error("Error updating team:", error);
|
||||
return res.status(500).send(new ServerResponse(false, null, "Failed to update team"));
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getBillingInfo(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT get_billing_info($1) AS billing_info;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const validTillDate = moment(data.billing_info.trial_expire_date);
|
||||
|
||||
const daysDifference = validTillDate.diff(moment(), "days");
|
||||
const dateString = calculateMonthDays(moment().format("YYYY-MM-DD"), data.billing_info.trial_expire_date);
|
||||
|
||||
data.billing_info.expire_date_string = dateString;
|
||||
|
||||
if (daysDifference < 0) {
|
||||
data.billing_info.expire_date_string = `Your trial plan expired ${dateString} ago`;
|
||||
} else if (daysDifference === 0 && daysDifference < 7) {
|
||||
data.billing_info.expire_date_string = `Your trial plan expires today`;
|
||||
} else {
|
||||
data.billing_info.expire_date_string = `Your trial plan expires in ${dateString}.`;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, [], "Team updated successfully"));
|
||||
if (data.billing_info.billing_type === "year") data.billing_info.unit_price_per_month = data.billing_info.unit_price / 12;
|
||||
|
||||
const teamMemberData = await getTeamMemberCount(req.user?.owner_id ?? "");
|
||||
const subscriptionData = await checkTeamSubscriptionStatus(req.user?.team_id ?? "");
|
||||
|
||||
data.billing_info.total_used = teamMemberData.user_count;
|
||||
data.billing_info.total_seats = subscriptionData.quantity;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.billing_info));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getBillingTransactions(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT subscription_payment_id,
|
||||
event_time::date,
|
||||
(next_bill_date::DATE - INTERVAL '1 day')::DATE AS next_bill_date,
|
||||
currency,
|
||||
receipt_url,
|
||||
payment_method,
|
||||
status,
|
||||
payment_status
|
||||
FROM licensing_payment_details
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getBillingCharges(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT (SELECT name FROM licensing_pricing_plans lpp WHERE id = lus.plan_id),
|
||||
unit_price::numeric,
|
||||
currency,
|
||||
status,
|
||||
quantity,
|
||||
unit_price::numeric * quantity AS amount,
|
||||
(SELECT event_time
|
||||
FROM licensing_payment_details lpd
|
||||
WHERE lpd.user_id = lus.user_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1)::DATE AS start_date,
|
||||
(next_bill_date::DATE - INTERVAL '1 day')::DATE AS end_date
|
||||
FROM licensing_user_subscriptions lus
|
||||
WHERE user_id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
|
||||
const countQ = `SELECT subscription_id
|
||||
FROM licensing_user_subscription_modifiers
|
||||
WHERE subscription_id = (SELECT subscription_id
|
||||
FROM licensing_user_subscriptions
|
||||
WHERE user_id = $1
|
||||
AND status != 'deleted'
|
||||
LIMIT 1)::INT;`;
|
||||
const countResult = await db.query(countQ, [req.user?.owner_id]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {plan_charges: result.rows, modifiers: countResult.rows}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getBillingModifiers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT created_at
|
||||
FROM licensing_user_subscription_modifiers
|
||||
WHERE subscription_id = (SELECT subscription_id
|
||||
FROM licensing_user_subscriptions
|
||||
WHERE user_id = $1
|
||||
AND status != 'deleted'
|
||||
LIMIT 1)::INT;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getBillingConfiguration(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT name,
|
||||
email,
|
||||
organization_name AS company_name,
|
||||
contact_number AS phone,
|
||||
address_line_1,
|
||||
address_line_2,
|
||||
city,
|
||||
state,
|
||||
postal_code,
|
||||
country
|
||||
FROM organizations
|
||||
LEFT JOIN users u ON organizations.user_id = u.id
|
||||
WHERE u.id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateBillingConfiguration(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {company_name, phone, address_line_1, address_line_2, city, state, postal_code, country} = req.body;
|
||||
const q = `UPDATE organizations
|
||||
SET organization_name = $1,
|
||||
contact_number = $2,
|
||||
address_line_1 = $3,
|
||||
address_line_2 = $4,
|
||||
city = $5,
|
||||
state = $6,
|
||||
postal_code = $7,
|
||||
country = $8
|
||||
WHERE user_id = $9;`;
|
||||
const result = await db.query(q, [company_name, phone, address_line_1, address_line_2, city, state, postal_code, country, req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data, "Configuration Updated"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async upgradePlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {plan} = req.query;
|
||||
|
||||
const obj = await getTeamMemberCount(req.user?.owner_id ?? "");
|
||||
const axiosResponse = await generatePayLinkRequest(obj, plan as string, req.user?.owner_id, req.user?.id);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getPlans(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT
|
||||
ls.default_monthly_plan AS monthly_plan_id,
|
||||
lp_monthly.name AS monthly_plan_name,
|
||||
ls.default_annual_plan AS annual_plan_id,
|
||||
lp_monthly.recurring_price AS monthly_price,
|
||||
lp_annual.name AS annual_plan_name,
|
||||
lp_annual.recurring_price AS annual_price,
|
||||
ls.team_member_limit,
|
||||
ls.projects_limit,
|
||||
ls.free_tier_storage
|
||||
FROM
|
||||
licensing_settings ls
|
||||
JOIN
|
||||
licensing_pricing_plans lp_monthly ON ls.default_monthly_plan = lp_monthly.id
|
||||
JOIN
|
||||
licensing_pricing_plans lp_annual ON ls.default_annual_plan = lp_annual.id;`;
|
||||
const result = await db.query(q, []);
|
||||
const [data] = result.rows;
|
||||
|
||||
const obj = await getTeamMemberCount(req.user?.owner_id ?? "");
|
||||
|
||||
data.team_member_limit = data.team_member_limit === 0 ? "Unlimited" : data.team_member_limit;
|
||||
data.projects_limit = data.projects_limit === 0 ? "Unlimited" : data.projects_limit;
|
||||
data.free_tier_storage = `${data.free_tier_storage}MB`;
|
||||
data.current_user_count = obj.user_count;
|
||||
data.annual_price = (data.annual_price / 12).toFixed(2);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async purchaseStorage(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT subscription_id
|
||||
FROM licensing_user_subscriptions lus
|
||||
WHERE user_id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
await addModifier(data.subscription_id);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async changePlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {plan} = req.query;
|
||||
|
||||
const q = `SELECT subscription_id
|
||||
FROM licensing_user_subscriptions lus
|
||||
WHERE user_id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const axiosResponse = await changePlan(plan as string, data.subscription_id);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async cancelPlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.owner_id) return res.status(200).send(new ServerResponse(false, "Invalid Request."));
|
||||
|
||||
const q = `SELECT subscription_id
|
||||
FROM licensing_user_subscriptions lus
|
||||
WHERE user_id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const axiosResponse = await cancelSubscription(data.subscription_id, req.user?.owner_id);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async pauseSubscription(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.owner_id) return res.status(200).send(new ServerResponse(false, "Invalid Request."));
|
||||
|
||||
const q = `SELECT subscription_id
|
||||
FROM licensing_user_subscriptions lus
|
||||
WHERE user_id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const axiosResponse = await pauseOrResumeSubscription(data.subscription_id, req.user?.owner_id, true);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async resumeSubscription(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.owner_id) return res.status(200).send(new ServerResponse(false, "Invalid Request."));
|
||||
|
||||
const q = `SELECT subscription_id
|
||||
FROM licensing_user_subscriptions lus
|
||||
WHERE user_id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const axiosResponse = await pauseOrResumeSubscription(data.subscription_id, req.user?.owner_id, false);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getBillingStorageInfo(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT trial_in_progress,
|
||||
trial_expire_date,
|
||||
ud.storage,
|
||||
(SELECT name AS plan_name FROM licensing_pricing_plans WHERE id = lus.plan_id),
|
||||
(SELECT default_trial_storage FROM licensing_settings),
|
||||
(SELECT storage_addon_size FROM licensing_settings),
|
||||
(SELECT storage_addon_price FROM licensing_settings)
|
||||
FROM organizations ud
|
||||
LEFT JOIN users u ON ud.user_id = u.id
|
||||
LEFT JOIN licensing_user_subscriptions lus ON u.id = lus.user_id
|
||||
WHERE ud.user_id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getAccountStorage(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamsQ = `SELECT id
|
||||
FROM teams
|
||||
WHERE user_id = $1;`;
|
||||
const teamsResponse = await db.query(teamsQ, [req.user?.owner_id]);
|
||||
|
||||
const storageQ = `SELECT storage
|
||||
FROM organizations
|
||||
WHERE user_id = $1;`;
|
||||
const result = await db.query(storageQ, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const storage: any = {};
|
||||
storage.used = 0;
|
||||
storage.total = data.storage;
|
||||
|
||||
for (const team of teamsResponse.rows) {
|
||||
storage.used += await calculateStorage(team.id);
|
||||
}
|
||||
|
||||
storage.remaining = (storage.total * 1024 * 1024 * 1024) - storage.used;
|
||||
storage.used_percent = Math.ceil((storage.used / (storage.total * 1024 * 1024 * 1024)) * 10000) / 100;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, storage));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getCountries(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name, code
|
||||
FROM countries
|
||||
ORDER BY name;`;
|
||||
const result = await db.query(q, []);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async switchToFreePlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id: teamId } = req.params;
|
||||
|
||||
const limits = await getFreePlanSettings();
|
||||
const ownerId = await getOwnerIdByTeam(teamId);
|
||||
|
||||
if (limits && ownerId) {
|
||||
if (parseInt(limits.team_member_limit) !== 0) {
|
||||
const teamMemberCount = await getTeamMemberCount(ownerId);
|
||||
if (parseInt(teamMemberCount) > parseInt(limits.team_member_limit)) {
|
||||
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${limits.team_member_limit} members.`));
|
||||
}
|
||||
}
|
||||
|
||||
const projectsCount = await getCurrentProjectsCount(ownerId);
|
||||
if (parseInt(projectsCount) > parseInt(limits.projects_limit)) {
|
||||
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${limits.projects_limit} projects.`));
|
||||
}
|
||||
|
||||
const usedStorage = await getUsedStorage(ownerId);
|
||||
if (parseInt(usedStorage) > megabytesToBytes(parseInt(limits.free_tier_storage))) {
|
||||
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot exceed ${limits.free_tier_storage}MB of storage.`));
|
||||
}
|
||||
|
||||
const update_q = `UPDATE organizations
|
||||
SET license_type_id = (SELECT id FROM sys_license_types WHERE key = 'FREE'),
|
||||
trial_in_progress = FALSE,
|
||||
subscription_status = 'free',
|
||||
storage = (SELECT free_tier_storage FROM licensing_settings)
|
||||
WHERE user_id = $1;`;
|
||||
await db.query(update_q, [ownerId]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, [], "Your plan has been successfully switched to the Free Plan."));
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(false, [], "Failed to switch to the Free Plan. Please try again later."));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async redeem(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { code } = req.body;
|
||||
|
||||
const q = `SELECT * FROM licensing_coupon_codes WHERE coupon_code = $1 AND is_redeemed IS FALSE AND is_refunded IS FALSE;`;
|
||||
const result = await db.query(q, [code]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!result.rows.length)
|
||||
return res.status(200).send(new ServerResponse(false, [], "Redeem Code verification Failed! Please try again."));
|
||||
|
||||
const checkQ = `SELECT sum(team_members_limit) AS team_member_total FROM licensing_coupon_codes WHERE redeemed_by = $1 AND is_redeemed IS TRUE;`;
|
||||
const checkResult = await db.query(checkQ, [req.user?.owner_id]);
|
||||
const [total] = checkResult.rows;
|
||||
|
||||
if (parseInt(total.team_member_total) > 50)
|
||||
return res.status(200).send(new ServerResponse(false, [], "Maximum number of codes redeemed!"));
|
||||
|
||||
const updateQ = `UPDATE licensing_coupon_codes
|
||||
SET is_redeemed = TRUE, redeemed_at = CURRENT_TIMESTAMP,
|
||||
redeemed_by = $1
|
||||
WHERE id = $2;`;
|
||||
await db.query(updateQ, [req.user?.owner_id, data.id]);
|
||||
|
||||
const updateQ2 = `UPDATE organizations
|
||||
SET subscription_status = 'life_time_deal',
|
||||
trial_in_progress = FALSE,
|
||||
storage = (SELECT sum(storage_limit) FROM licensing_coupon_codes WHERE redeemed_by = $1),
|
||||
license_type_id = (SELECT id FROM sys_license_types WHERE key = 'LIFE_TIME_DEAL')
|
||||
WHERE user_id = $1;`;
|
||||
await db.query(updateQ2, [req.user?.owner_id]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, [], "Code redeemed successfully!"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -284,6 +688,11 @@ export default class AdminCenterController extends WorklenzControllerBase {
|
||||
|
||||
if (!id || !teamId) return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
|
||||
|
||||
// check subscription status
|
||||
const subscriptionData = await checkTeamSubscriptionStatus(teamId);
|
||||
if (statusExclude.includes(subscriptionData.subscription_status)) {
|
||||
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
|
||||
}
|
||||
|
||||
const q = `SELECT remove_team_member($1, $2, $3) AS member;`;
|
||||
const result = await db.query(q, [id, req.user?.id, teamId]);
|
||||
@@ -291,6 +700,22 @@ export default class AdminCenterController extends WorklenzControllerBase {
|
||||
|
||||
const message = `You have been removed from <b>${req.user?.team_name}</b> by <b>${req.user?.name}</b>`;
|
||||
|
||||
// if (subscriptionData.status === "trialing") break;
|
||||
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
|
||||
if (subscriptionData.subscription_status === "active" && subscriptionData.quantity > 0) {
|
||||
|
||||
const obj = await getActiveTeamMemberCount(req.user?.owner_id ?? "");
|
||||
|
||||
const userActiveInOtherTeams = await this.checkIfUserActiveInOtherTeams(req.user?.owner_id as string, req.query?.email as string);
|
||||
|
||||
if (!userActiveInOtherTeams) {
|
||||
const response = await updateUsers(subscriptionData.subscription_id, obj.user_count);
|
||||
if (!response.body.subscription_id) return res.status(200).send(new ServerResponse(false, response.message || "Please check your subscription."));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
NotificationsService.sendNotification({
|
||||
receiver_socket_id: data.socket_id,
|
||||
message,
|
||||
@@ -305,5 +730,49 @@ export default class AdminCenterController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getFreePlanLimits(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const limits = await getFreePlanSettings();
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, limits || {}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOrganizationProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery, size, offset } = this.toPaginationOptions(req.query, ["p.name"]);
|
||||
|
||||
const countQ = `SELECT COUNT(*) AS total
|
||||
FROM projects p
|
||||
JOIN teams t ON p.team_id = t.id
|
||||
JOIN organizations o ON t.organization_id = o.id
|
||||
WHERE o.user_id = $1;`;
|
||||
const countResult = await db.query(countQ, [req.user?.owner_id]);
|
||||
|
||||
// Query to get the project data
|
||||
const dataQ = `SELECT p.id,
|
||||
p.name,
|
||||
t.name AS team_name,
|
||||
p.created_at,
|
||||
pm.member_count
|
||||
FROM projects p
|
||||
JOIN teams t ON p.team_id = t.id
|
||||
JOIN organizations o ON t.organization_id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT project_id, COUNT(*) AS member_count
|
||||
FROM project_members
|
||||
GROUP BY project_id
|
||||
) pm ON p.id = pm.project_id
|
||||
WHERE o.user_id = $1 ${searchQuery}
|
||||
ORDER BY p.name
|
||||
OFFSET $2 LIMIT $3;`;
|
||||
|
||||
const result = await db.query(dataQ, [req.user?.owner_id, offset, size]);
|
||||
|
||||
const response = {
|
||||
total: countResult.rows[0]?.total ?? 0,
|
||||
data: result.rows ?? []
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import { humanFileSize, log_error, smallId } from "../shared/utils";
|
||||
import { humanFileSize, smallId } from "../shared/utils";
|
||||
import { getStorageUrl } from "../shared/constants";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import {
|
||||
createPresignedUrlWithClient,
|
||||
@@ -12,16 +13,10 @@ import {
|
||||
getRootDir,
|
||||
uploadBase64,
|
||||
uploadBuffer
|
||||
} from "../shared/s3";
|
||||
} from "../shared/storage";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
const {S3_URL} = process.env;
|
||||
|
||||
if (!S3_URL) {
|
||||
log_error("Invalid S3_URL. Please check .env file.");
|
||||
}
|
||||
|
||||
export default class AttachmentController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -42,7 +37,7 @@ export default class AttachmentController extends WorklenzControllerBase {
|
||||
req.user?.id,
|
||||
size,
|
||||
type,
|
||||
`${S3_URL}/${getRootDir()}`
|
||||
`${getStorageUrl()}/${getRootDir()}`
|
||||
]);
|
||||
const [data] = result.rows;
|
||||
|
||||
@@ -86,7 +81,7 @@ export default class AttachmentController extends WorklenzControllerBase {
|
||||
FROM task_attachments
|
||||
WHERE task_id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, `${S3_URL}/${getRootDir()}`]);
|
||||
const result = await db.query(q, [req.params.id, `${getStorageUrl()}/${getRootDir()}`]);
|
||||
|
||||
for (const item of result.rows)
|
||||
item.size = humanFileSize(item.size);
|
||||
@@ -121,7 +116,7 @@ export default class AttachmentController extends WorklenzControllerBase {
|
||||
LEFT JOIN tasks t ON task_attachments.task_id = t.id
|
||||
WHERE task_attachments.project_id = $1) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, `${S3_URL}/${getRootDir()}`, size, offset]);
|
||||
const result = await db.query(q, [req.params.id, `${getStorageUrl()}/${getRootDir()}`, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const item of data?.attachments.data || [])
|
||||
@@ -135,26 +130,29 @@ export default class AttachmentController extends WorklenzControllerBase {
|
||||
const q = `DELETE
|
||||
FROM task_attachments
|
||||
WHERE id = $1
|
||||
RETURNING CONCAT($2::TEXT, '/', team_id, '/', project_id, '/', id, '.', type) AS key;`;
|
||||
const result = await db.query(q, [req.params.id, getRootDir()]);
|
||||
RETURNING team_id, project_id, id, type;`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data?.key)
|
||||
void deleteObject(data.key);
|
||||
if (data) {
|
||||
const key = getKey(data.team_id, data.project_id, data.id, data.type);
|
||||
void deleteObject(key);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async download(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT CONCAT($2::TEXT, '/', team_id, '/', project_id, '/', id, '.', type) AS key
|
||||
const q = `SELECT team_id, project_id, id, type
|
||||
FROM task_attachments
|
||||
WHERE id = $1;`;
|
||||
const result = await db.query(q, [req.query.id, getRootDir()]);
|
||||
const result = await db.query(q, [req.query.id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data?.key) {
|
||||
const url = await createPresignedUrlWithClient(data.key, req.query.file as string);
|
||||
if (data) {
|
||||
const key = getKey(data.team_id, data.project_id, data.id, data.type);
|
||||
const url = await createPresignedUrlWithClient(key, req.query.file as string);
|
||||
return res.status(200).send(new ServerResponse(true, url));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {PasswordStrengthChecker} from "../shared/password-strength-check";
|
||||
import FileConstants from "../shared/file-constants";
|
||||
import axios from "axios";
|
||||
import {log_error} from "../shared/utils";
|
||||
import {DEFAULT_ERROR_MESSAGE} from "../shared/constants";
|
||||
|
||||
export default class AuthController extends WorklenzControllerBase {
|
||||
/** This just send ok response to the client when the request came here through the sign-up-validator */
|
||||
@@ -32,8 +35,18 @@ export default class AuthController extends WorklenzControllerBase {
|
||||
const auth_error = errors.length > 0 ? errors[0] : null;
|
||||
const message = messages.length > 0 ? messages[0] : null;
|
||||
|
||||
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
||||
const title = req.query.strategy ? midTitle : null;
|
||||
// Determine title based on authentication status and strategy
|
||||
let title = null;
|
||||
if (req.query.strategy) {
|
||||
if (auth_error) {
|
||||
// Show failure title only when there's an actual error
|
||||
title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
||||
} else if (req.isAuthenticated() && message) {
|
||||
// Show success title when authenticated and there's a success message
|
||||
title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!";
|
||||
}
|
||||
// If no error and not authenticated, don't show any title (this might be a redirect without completion)
|
||||
}
|
||||
|
||||
if (req.user)
|
||||
req.user.build_v = FileConstants.getRelease();
|
||||
@@ -42,11 +55,20 @@ export default class AuthController extends WorklenzControllerBase {
|
||||
}
|
||||
|
||||
public static logout(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
req.logout(() => true);
|
||||
req.session.destroy(() => {
|
||||
res.redirect("/");
|
||||
req.logout((err) => {
|
||||
if (err) {
|
||||
console.error("Logout error:", err);
|
||||
return res.status(500).send(new AuthResponse(null, true, {}, "Logout failed", null));
|
||||
}
|
||||
|
||||
req.session.destroy((destroyErr) => {
|
||||
if (destroyErr) {
|
||||
console.error("Session destroy error:", destroyErr);
|
||||
}
|
||||
res.status(200).send(new AuthResponse(null, req.isAuthenticated(), {}, null, null));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async destroyOtherSessions(userId: string, sessionId: string) {
|
||||
try {
|
||||
@@ -138,4 +160,25 @@ export default class AuthController extends WorklenzControllerBase {
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(false, null, "Invalid Request. Please try again."));
|
||||
}
|
||||
|
||||
@HandleExceptions({logWithError: "body"})
|
||||
public static async verifyCaptcha(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const {token} = req.body;
|
||||
const secretKey = process.env.GOOGLE_CAPTCHA_SECRET_KEY;
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`https://www.google.com/recaptcha/api/siteverify?secret=${secretKey}&response=${token}`
|
||||
);
|
||||
|
||||
const {success, score} = response.data;
|
||||
|
||||
if (success && score > 0.5) {
|
||||
return res.status(200).send(new ServerResponse(true, null, null));
|
||||
}
|
||||
return res.status(400).send(new ServerResponse(false, null, "Please try again later.").withTitle("Error"));
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
res.status(500).send(new ServerResponse(false, null, DEFAULT_ERROR_MESSAGE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
288
worklenz-backend/src/controllers/billing-controller.ts
Normal file
288
worklenz-backend/src/controllers/billing-controller.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { getTeamMemberCount } from "../shared/paddle-utils";
|
||||
import { generatePayLinkRequest, updateUsers } from "../shared/paddle-requests";
|
||||
|
||||
import CryptoJS from "crypto-js";
|
||||
import moment from "moment";
|
||||
import axios from "axios";
|
||||
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log_error } from "../shared/utils";
|
||||
import { sendEmail } from "../shared/email";
|
||||
|
||||
export default class BillingController extends WorklenzControllerBase {
|
||||
public static async getInitialCharge(count: number) {
|
||||
if (!count) throw new Error("No selected plan detected.");
|
||||
|
||||
const baseRate = 4990;
|
||||
const firstTier = 15;
|
||||
const secondTierEnd = 200;
|
||||
|
||||
if (count <= firstTier) {
|
||||
return baseRate;
|
||||
} else if (count <= secondTierEnd) {
|
||||
return baseRate + (count - firstTier) * 300;
|
||||
}
|
||||
return baseRate + (secondTierEnd - firstTier) * 300 + (count - secondTierEnd) * 200;
|
||||
|
||||
}
|
||||
|
||||
public static async getBillingMonth() {
|
||||
const startDate = moment().format("YYYYMMDD");
|
||||
const endDate = moment().add(1, "month").subtract(1, "day").format("YYYYMMDD");
|
||||
|
||||
return `${startDate} - ${endDate}`;
|
||||
}
|
||||
|
||||
public static async chargeInitialPayment(signature: string, data: any) {
|
||||
const config = {
|
||||
method: "post",
|
||||
maxBodyLength: Infinity,
|
||||
url: process.env.DP_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Signature": signature,
|
||||
"x-api-key": process.env.DP_API_KEY
|
||||
},
|
||||
data
|
||||
};
|
||||
|
||||
axios.request(config)
|
||||
.then((response) => {
|
||||
console.log(JSON.stringify(response.data));
|
||||
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
|
||||
public static async saveLocalTransaction(signature: string, data: any) {
|
||||
try {
|
||||
const q = `INSERT INTO transactions (status, transaction_id, transaction_status, description, date_time, reference, amount, card_number)
|
||||
VALUES ($1, $2, $3);`;
|
||||
const result = await db.query(q, []);
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async upgradeToPaidPlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { plan, seatCount } = req.query;
|
||||
|
||||
const teamMemberData = await getTeamMemberCount(req.user?.owner_id ?? "");
|
||||
teamMemberData.user_count = seatCount as string;
|
||||
const axiosResponse = await generatePayLinkRequest(teamMemberData, plan as string, req.user?.owner_id, req.user?.id);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async addMoreSeats(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { seatCount } = req.body;
|
||||
|
||||
const q = `SELECT subscription_id
|
||||
FROM licensing_user_subscriptions lus
|
||||
WHERE user_id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const response = await updateUsers(data.subscription_id, seatCount);
|
||||
|
||||
if (!response.body.subscription_id) {
|
||||
return res.status(200).send(new ServerResponse(false, null, response.message || "Please check your subscription."));
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(true, null, "Your purchase has been successfully completed!").withTitle("Done"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getDirectPayObject(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { seatCount } = req.query;
|
||||
if (!seatCount) return res.status(200).send(new ServerResponse(false, null));
|
||||
const email = req.user?.email;
|
||||
const name = req.user?.name;
|
||||
const amount = await this.getInitialCharge(parseInt(seatCount as string));
|
||||
const uniqueTimestamp = moment().format("YYYYMMDDHHmmss");
|
||||
const billingMonth = await this.getBillingMonth();
|
||||
|
||||
const { DP_MERCHANT_ID, DP_SECRET_KEY, DP_STAGE } = process.env;
|
||||
|
||||
const payload = {
|
||||
merchant_id: DP_MERCHANT_ID,
|
||||
amount: 10,
|
||||
type: "RECURRING",
|
||||
order_id: `WORKLENZ_${email}_${uniqueTimestamp}`,
|
||||
currency: "LKR",
|
||||
return_url: null,
|
||||
response_url: null,
|
||||
first_name: name,
|
||||
last_name: null,
|
||||
phone: null,
|
||||
email,
|
||||
description: `${name} (${email})`,
|
||||
page_type: "IN_APP",
|
||||
logo: "https://app.worklenz.com/assets/icons/icon-96x96.png",
|
||||
start_date: moment().format("YYYY-MM-DD"),
|
||||
do_initial_payment: 1,
|
||||
interval: 1,
|
||||
};
|
||||
|
||||
const encodePayload = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(JSON.stringify(payload)));
|
||||
const signature = CryptoJS.HmacSHA256(encodePayload, DP_SECRET_KEY as string);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { signature: signature.toString(CryptoJS.enc.Hex), dataString: encodePayload, stage: DP_STAGE }));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async saveTransactionData(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { status, card, transaction, seatCount } = req.body;
|
||||
const { DP_MERCHANT_ID, DP_STAGE } = process.env;
|
||||
|
||||
const email = req.user?.email;
|
||||
|
||||
const amount = await this.getInitialCharge(parseInt(seatCount as string));
|
||||
const uniqueTimestamp = moment().format("YYYYMMDDHHmmss");
|
||||
const billingMonth = await this.getBillingMonth();
|
||||
|
||||
const values = [
|
||||
status,
|
||||
card?.id,
|
||||
card?.number,
|
||||
card?.brand,
|
||||
card?.type,
|
||||
card?.issuer,
|
||||
card?.expiry?.year,
|
||||
card?.expiry?.month,
|
||||
card?.walletId,
|
||||
transaction?.id,
|
||||
transaction?.status,
|
||||
transaction?.amount || 0,
|
||||
transaction?.currency || null,
|
||||
transaction?.channel || null,
|
||||
transaction?.dateTime || null,
|
||||
transaction?.message || null,
|
||||
transaction?.description || null,
|
||||
req.user?.id,
|
||||
req.user?.owner_id,
|
||||
];
|
||||
|
||||
const q = `INSERT INTO licensing_lkr_payments (
|
||||
status, card_id, card_number, card_brand, card_type, card_issuer,
|
||||
card_expiry_year, card_expiry_month, wallet_id,
|
||||
transaction_id, transaction_status, transaction_amount,
|
||||
transaction_currency, transaction_channel, transaction_datetime,
|
||||
transaction_message, transaction_description, user_id, owner_id
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19
|
||||
);`;
|
||||
await db.query(q, values);
|
||||
|
||||
if (transaction.status === "SUCCESS") {
|
||||
const payload = {
|
||||
"merchantId": DP_MERCHANT_ID,
|
||||
"reference": `WORKLENZ_${email}_${uniqueTimestamp}`,
|
||||
"type": "CARD_PAY",
|
||||
"cardId": card.id,
|
||||
"refCode": req.user?.id,
|
||||
amount,
|
||||
"currency": "LKR"
|
||||
};
|
||||
const dataString = Object.values(payload).join("");
|
||||
const { DP_STAGE } = process.env;
|
||||
|
||||
const pemFile = DP_STAGE === "PROD" ? "src/keys/PRIVATE_KEY_PROD.pem" : `src/keys/PRIVATE_KEY_DEV.pem`;
|
||||
|
||||
const privateKeyTest = fs.readFileSync(path.resolve(pemFile), "utf8");
|
||||
const sign = crypto.createSign("SHA256");
|
||||
sign.update(dataString);
|
||||
sign.end();
|
||||
|
||||
const signature = sign.sign(privateKeyTest);
|
||||
const byteArray = new Uint8Array(signature);
|
||||
let byteString = "";
|
||||
for (let i = 0; i < byteArray.byteLength; i++) {
|
||||
byteString += String.fromCharCode(byteArray[i]);
|
||||
}
|
||||
const base64Signature = btoa(byteString);
|
||||
|
||||
this.chargeInitialPayment(base64Signature, payload);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, null, "Your purchase has been successfully completed!").withTitle("Done"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getCardList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const payload = {
|
||||
"merchantId": "RT02300",
|
||||
"reference": "1234",
|
||||
"type": "LIST_CARD"
|
||||
};
|
||||
|
||||
const { DP_STAGE } = process.env;
|
||||
|
||||
const dataString = `RT023001234LIST_CARD`;
|
||||
const pemFile = DP_STAGE === "PROD" ? "src/keys/PRIVATE_KEY_PROD.pem" : `src/keys/PRIVATE_KEY_DEV.pem`;
|
||||
|
||||
const privateKeyTest = fs.readFileSync(path.resolve(pemFile), "utf8");
|
||||
const sign = crypto.createSign("SHA256");
|
||||
sign.update(dataString);
|
||||
sign.end();
|
||||
|
||||
const signature = sign.sign(privateKeyTest);
|
||||
const byteArray = new Uint8Array(signature);
|
||||
let byteString = "";
|
||||
for (let i = 0; i < byteArray.byteLength; i++) {
|
||||
byteString += String.fromCharCode(byteArray[i]);
|
||||
}
|
||||
const base64Signature = btoa(byteString);
|
||||
// const signature = CryptoJS.HmacSHA256(dataString, DP_SECRET_KEY as string).toString(CryptoJS.enc.Hex);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { signature: base64Signature, dataString }));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async contactUs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { contactNo } = req.query;
|
||||
|
||||
if (!contactNo) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Contact number is required!"));
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Worklenz Local Billing - Contact Information</title>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<h1 style="text-align: center; margin-bottom: 20px;">Worklenz Local Billing - Contact Information</h1>
|
||||
<p><strong>Name:</strong> ${req.user?.name}</p>
|
||||
<p><strong>Contact No:</strong> ${contactNo as string}</p>
|
||||
<p><strong>Email:</strong> ${req.user?.email}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
const to = [process.env.CONTACT_US_EMAIL || "chamika@ceydigital.com"];
|
||||
|
||||
sendEmail({
|
||||
to,
|
||||
subject: "Worklenz - Local billing contact.",
|
||||
html
|
||||
});
|
||||
return res.status(200).send(new ServerResponse(true, null, "Your contact information has been sent successfully."));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export default class ClientsController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `INSERT INTO clients (name, team_id) VALUES ($1, $2);`;
|
||||
const q = `INSERT INTO clients (name, team_id) VALUES ($1, $2) RETURNING id, name;`;
|
||||
const result = await db.query(q, [req.body.name, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
|
||||
531
worklenz-backend/src/controllers/custom-columns-controller.ts
Normal file
531
worklenz-backend/src/controllers/custom-columns-controller.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class CustomcolumnsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async create(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const {
|
||||
project_id,
|
||||
name,
|
||||
key,
|
||||
field_type,
|
||||
width = 150,
|
||||
is_visible = true,
|
||||
configuration,
|
||||
} = req.body;
|
||||
|
||||
// Start a transaction since we're inserting into multiple tables
|
||||
const client = await db.pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. Insert the main custom column
|
||||
const columnQuery = `
|
||||
INSERT INTO cc_custom_columns (
|
||||
project_id, name, key, field_type, width, is_visible, is_custom_column
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, true)
|
||||
RETURNING id;
|
||||
`;
|
||||
const columnResult = await client.query(columnQuery, [
|
||||
project_id,
|
||||
name,
|
||||
key,
|
||||
field_type,
|
||||
width,
|
||||
is_visible,
|
||||
]);
|
||||
const columnId = columnResult.rows[0].id;
|
||||
|
||||
// 2. Insert the column configuration
|
||||
const configQuery = `
|
||||
INSERT INTO cc_column_configurations (
|
||||
column_id, field_title, field_type, number_type,
|
||||
decimals, label, label_position, preview_value,
|
||||
expression, first_numeric_column_key, second_numeric_column_key
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id;
|
||||
`;
|
||||
await client.query(configQuery, [
|
||||
columnId,
|
||||
configuration.field_title,
|
||||
configuration.field_type,
|
||||
configuration.number_type || null,
|
||||
configuration.decimals || null,
|
||||
configuration.label || null,
|
||||
configuration.label_position || null,
|
||||
configuration.preview_value || null,
|
||||
configuration.expression || null,
|
||||
configuration.first_numeric_column_key || null,
|
||||
configuration.second_numeric_column_key || null,
|
||||
]);
|
||||
|
||||
// 3. Insert selection options if present
|
||||
if (
|
||||
configuration.selections_list &&
|
||||
configuration.selections_list.length > 0
|
||||
) {
|
||||
const selectionQuery = `
|
||||
INSERT INTO cc_selection_options (
|
||||
column_id, selection_id, selection_name, selection_color, selection_order
|
||||
) VALUES ($1, $2, $3, $4, $5);
|
||||
`;
|
||||
for (const [
|
||||
index,
|
||||
selection,
|
||||
] of configuration.selections_list.entries()) {
|
||||
await client.query(selectionQuery, [
|
||||
columnId,
|
||||
selection.selection_id,
|
||||
selection.selection_name,
|
||||
selection.selection_color,
|
||||
index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Insert label options if present
|
||||
if (configuration.labels_list && configuration.labels_list.length > 0) {
|
||||
const labelQuery = `
|
||||
INSERT INTO cc_label_options (
|
||||
column_id, label_id, label_name, label_color, label_order
|
||||
) VALUES ($1, $2, $3, $4, $5);
|
||||
`;
|
||||
for (const [index, label] of configuration.labels_list.entries()) {
|
||||
await client.query(labelQuery, [
|
||||
columnId,
|
||||
label.label_id,
|
||||
label.label_name,
|
||||
label.label_color,
|
||||
index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
// Fetch the complete column data
|
||||
const getColumnQuery = `
|
||||
SELECT
|
||||
cc.*,
|
||||
cf.field_title,
|
||||
cf.number_type,
|
||||
cf.decimals,
|
||||
cf.label,
|
||||
cf.label_position,
|
||||
cf.preview_value,
|
||||
cf.expression,
|
||||
cf.first_numeric_column_key,
|
||||
cf.second_numeric_column_key,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'selection_id', so.selection_id,
|
||||
'selection_name', so.selection_name,
|
||||
'selection_color', so.selection_color
|
||||
)
|
||||
)
|
||||
FROM cc_selection_options so
|
||||
WHERE so.column_id = cc.id
|
||||
) as selections_list,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'label_id', lo.label_id,
|
||||
'label_name', lo.label_name,
|
||||
'label_color', lo.label_color
|
||||
)
|
||||
)
|
||||
FROM cc_label_options lo
|
||||
WHERE lo.column_id = cc.id
|
||||
) as labels_list
|
||||
FROM cc_custom_columns cc
|
||||
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
|
||||
WHERE cc.id = $1;
|
||||
`;
|
||||
const result = await client.query(getColumnQuery, [columnId]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { project_id } = req.query;
|
||||
|
||||
const q = `
|
||||
SELECT
|
||||
cc.*,
|
||||
cf.field_title,
|
||||
cf.number_type,
|
||||
cf.decimals,
|
||||
cf.label,
|
||||
cf.label_position,
|
||||
cf.preview_value,
|
||||
cf.expression,
|
||||
cf.first_numeric_column_key,
|
||||
cf.second_numeric_column_key,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'selection_id', so.selection_id,
|
||||
'selection_name', so.selection_name,
|
||||
'selection_color', so.selection_color
|
||||
)
|
||||
)
|
||||
FROM cc_selection_options so
|
||||
WHERE so.column_id = cc.id
|
||||
) as selections_list,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'label_id', lo.label_id,
|
||||
'label_name', lo.label_name,
|
||||
'label_color', lo.label_color
|
||||
)
|
||||
)
|
||||
FROM cc_label_options lo
|
||||
WHERE lo.column_id = cc.id
|
||||
) as labels_list
|
||||
FROM cc_custom_columns cc
|
||||
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
|
||||
WHERE cc.project_id = $1
|
||||
ORDER BY cc.created_at DESC;
|
||||
`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
|
||||
const q = `
|
||||
SELECT
|
||||
cc.*,
|
||||
cf.field_title,
|
||||
cf.number_type,
|
||||
cf.decimals,
|
||||
cf.label,
|
||||
cf.label_position,
|
||||
cf.preview_value,
|
||||
cf.expression,
|
||||
cf.first_numeric_column_key,
|
||||
cf.second_numeric_column_key,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'selection_id', so.selection_id,
|
||||
'selection_name', so.selection_name,
|
||||
'selection_color', so.selection_color
|
||||
)
|
||||
)
|
||||
FROM cc_selection_options so
|
||||
WHERE so.column_id = cc.id
|
||||
) as selections_list,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'label_id', lo.label_id,
|
||||
'label_name', lo.label_name,
|
||||
'label_color', lo.label_color
|
||||
)
|
||||
)
|
||||
FROM cc_label_options lo
|
||||
WHERE lo.column_id = cc.id
|
||||
) as labels_list
|
||||
FROM cc_custom_columns cc
|
||||
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
|
||||
WHERE cc.id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const { name, field_type, width, is_visible, configuration } = req.body;
|
||||
|
||||
const client = await db.pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. Update the main custom column
|
||||
const columnQuery = `
|
||||
UPDATE cc_custom_columns
|
||||
SET name = $1, field_type = $2, width = $3, is_visible = $4, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5
|
||||
RETURNING id;
|
||||
`;
|
||||
await client.query(columnQuery, [
|
||||
name,
|
||||
field_type,
|
||||
width,
|
||||
is_visible,
|
||||
id,
|
||||
]);
|
||||
|
||||
// 2. Update the configuration
|
||||
const configQuery = `
|
||||
UPDATE cc_column_configurations
|
||||
SET
|
||||
field_title = $1,
|
||||
field_type = $2,
|
||||
number_type = $3,
|
||||
decimals = $4,
|
||||
label = $5,
|
||||
label_position = $6,
|
||||
preview_value = $7,
|
||||
expression = $8,
|
||||
first_numeric_column_key = $9,
|
||||
second_numeric_column_key = $10,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE column_id = $11;
|
||||
`;
|
||||
await client.query(configQuery, [
|
||||
configuration.field_title,
|
||||
configuration.field_type,
|
||||
configuration.number_type || null,
|
||||
configuration.decimals || null,
|
||||
configuration.label || null,
|
||||
configuration.label_position || null,
|
||||
configuration.preview_value || null,
|
||||
configuration.expression || null,
|
||||
configuration.first_numeric_column_key || null,
|
||||
configuration.second_numeric_column_key || null,
|
||||
id,
|
||||
]);
|
||||
|
||||
// 3. Update selections if present
|
||||
if (configuration.selections_list) {
|
||||
// Delete existing selections
|
||||
await client.query(
|
||||
"DELETE FROM cc_selection_options WHERE column_id = $1",
|
||||
[id]
|
||||
);
|
||||
|
||||
// Insert new selections
|
||||
if (configuration.selections_list.length > 0) {
|
||||
const selectionQuery = `
|
||||
INSERT INTO cc_selection_options (
|
||||
column_id, selection_id, selection_name, selection_color, selection_order
|
||||
) VALUES ($1, $2, $3, $4, $5);
|
||||
`;
|
||||
for (const [
|
||||
index,
|
||||
selection,
|
||||
] of configuration.selections_list.entries()) {
|
||||
await client.query(selectionQuery, [
|
||||
id,
|
||||
selection.selection_id,
|
||||
selection.selection_name,
|
||||
selection.selection_color,
|
||||
index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Update labels if present
|
||||
if (configuration.labels_list) {
|
||||
// Delete existing labels
|
||||
await client.query("DELETE FROM cc_label_options WHERE column_id = $1", [
|
||||
id,
|
||||
]);
|
||||
|
||||
// Insert new labels
|
||||
if (configuration.labels_list.length > 0) {
|
||||
const labelQuery = `
|
||||
INSERT INTO cc_label_options (
|
||||
column_id, label_id, label_name, label_color, label_order
|
||||
) VALUES ($1, $2, $3, $4, $5);
|
||||
`;
|
||||
for (const [index, label] of configuration.labels_list.entries()) {
|
||||
await client.query(labelQuery, [
|
||||
id,
|
||||
label.label_id,
|
||||
label.label_name,
|
||||
label.label_color,
|
||||
index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
// Fetch the updated column data
|
||||
const getColumnQuery = `
|
||||
SELECT
|
||||
cc.*,
|
||||
cf.field_title,
|
||||
cf.number_type,
|
||||
cf.decimals,
|
||||
cf.label,
|
||||
cf.label_position,
|
||||
cf.preview_value,
|
||||
cf.expression,
|
||||
cf.first_numeric_column_key,
|
||||
cf.second_numeric_column_key,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'selection_id', so.selection_id,
|
||||
'selection_name', so.selection_name,
|
||||
'selection_color', so.selection_color
|
||||
)
|
||||
)
|
||||
FROM cc_selection_options so
|
||||
WHERE so.column_id = cc.id
|
||||
) as selections_list,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'label_id', lo.label_id,
|
||||
'label_name', lo.label_name,
|
||||
'label_color', lo.label_color
|
||||
)
|
||||
)
|
||||
FROM cc_label_options lo
|
||||
WHERE lo.column_id = cc.id
|
||||
) as labels_list
|
||||
FROM cc_custom_columns cc
|
||||
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
|
||||
WHERE cc.id = $1;
|
||||
`;
|
||||
const result = await client.query(getColumnQuery, [id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
|
||||
const q = `
|
||||
DELETE FROM cc_custom_columns
|
||||
WHERE id = $1
|
||||
RETURNING id;
|
||||
`;
|
||||
const result = await db.query(q, [id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectColumns(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id } = req.params;
|
||||
|
||||
const q = `
|
||||
WITH column_data AS (
|
||||
SELECT
|
||||
cc.id,
|
||||
cc.key,
|
||||
cc.name,
|
||||
cc.field_type,
|
||||
cc.width,
|
||||
cc.is_visible,
|
||||
cf.field_title,
|
||||
cf.number_type,
|
||||
cf.decimals,
|
||||
cf.label,
|
||||
cf.label_position,
|
||||
cf.preview_value,
|
||||
cf.expression,
|
||||
cf.first_numeric_column_key,
|
||||
cf.second_numeric_column_key,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'selection_id', so.selection_id,
|
||||
'selection_name', so.selection_name,
|
||||
'selection_color', so.selection_color
|
||||
)
|
||||
)
|
||||
FROM cc_selection_options so
|
||||
WHERE so.column_id = cc.id
|
||||
) as selections_list,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'label_id', lo.label_id,
|
||||
'label_name', lo.label_name,
|
||||
'label_color', lo.label_color
|
||||
)
|
||||
)
|
||||
FROM cc_label_options lo
|
||||
WHERE lo.column_id = cc.id
|
||||
) as labels_list
|
||||
FROM cc_custom_columns cc
|
||||
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
|
||||
WHERE cc.project_id = $1
|
||||
)
|
||||
SELECT
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'key', cd.key,
|
||||
'id', cd.id,
|
||||
'name', cd.name,
|
||||
'width', cd.width,
|
||||
'pinned', cd.is_visible,
|
||||
'custom_column', true,
|
||||
'custom_column_obj', json_build_object(
|
||||
'fieldType', cd.field_type,
|
||||
'fieldTitle', cd.field_title,
|
||||
'numberType', cd.number_type,
|
||||
'decimals', cd.decimals,
|
||||
'label', cd.label,
|
||||
'labelPosition', cd.label_position,
|
||||
'previewValue', cd.preview_value,
|
||||
'expression', cd.expression,
|
||||
'firstNumericColumnKey', cd.first_numeric_column_key,
|
||||
'secondNumericColumnKey', cd.second_numeric_column_key,
|
||||
'selectionsList', COALESCE(cd.selections_list, '[]'::json),
|
||||
'labelsList', COALESCE(cd.labels_list, '[]'::json)
|
||||
)
|
||||
)
|
||||
) as columns
|
||||
FROM column_data cd;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [project_id]);
|
||||
const columns = result.rows[0]?.columns || [];
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, columns));
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export default class HomePageController extends WorklenzControllerBase {
|
||||
p.team_id,
|
||||
p.name AS project_name,
|
||||
p.color_code AS project_color,
|
||||
(SELECT id FROM task_statuses WHERE id = t.status_id) AS status,
|
||||
(SELECT name FROM task_statuses WHERE id = t.status_id) AS status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||
@@ -137,6 +137,10 @@ export default class HomePageController extends WorklenzControllerBase {
|
||||
WHERE category_id NOT IN (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS FALSE))
|
||||
AND NOT EXISTS(SELECT project_id
|
||||
FROM archived_projects
|
||||
WHERE project_id = p.id
|
||||
AND user_id = $2)
|
||||
${groupByClosure}
|
||||
ORDER BY t.end_date ASC`;
|
||||
|
||||
@@ -158,9 +162,13 @@ export default class HomePageController extends WorklenzControllerBase {
|
||||
WHERE category_id NOT IN (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS FALSE))
|
||||
AND NOT EXISTS(SELECT project_id
|
||||
FROM archived_projects
|
||||
WHERE project_id = p.id
|
||||
AND user_id = $3)
|
||||
${groupByClosure}`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId]);
|
||||
const result = await db.query(q, [teamId, userId, userId]);
|
||||
const [row] = result.rows;
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default class IndexController extends WorklenzControllerBase {
|
||||
if (req.user && !req.user.is_member)
|
||||
return res.redirect("/teams");
|
||||
|
||||
return res.redirect("/auth");
|
||||
return res.redirect(301, "/auth");
|
||||
}
|
||||
|
||||
public static redirectToLogin(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
|
||||
@@ -80,6 +80,37 @@ export default class LabelsController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateLabel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const updates = [];
|
||||
const values = [req.params.id, req.user?.team_id];
|
||||
let paramIndex = 3;
|
||||
|
||||
if (req.body.name) {
|
||||
updates.push(`name = $${paramIndex++}`);
|
||||
values.push(req.body.name);
|
||||
}
|
||||
|
||||
if (req.body.color) {
|
||||
if (!WorklenzColorCodes.includes(req.body.color))
|
||||
return res.status(400).send(new ServerResponse(false, null));
|
||||
updates.push(`color_code = $${paramIndex++}`);
|
||||
values.push(req.body.color);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).send(new ServerResponse(false, "No valid fields to update"));
|
||||
}
|
||||
|
||||
const q = `UPDATE team_labels
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $1
|
||||
AND team_id = $2;`;
|
||||
|
||||
const result = await db.query(q, values);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE
|
||||
|
||||
@@ -195,7 +195,7 @@ export default class ProjectCommentsController extends WorklenzControllerBase {
|
||||
pc.created_at,
|
||||
pc.updated_at
|
||||
FROM project_comments pc
|
||||
WHERE pc.project_id = $1 ORDER BY pc.updated_at DESC
|
||||
WHERE pc.project_id = $1 ORDER BY pc.updated_at
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ export default class ProjectInsightsController extends WorklenzControllerBase {
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees
|
||||
FROM tasks
|
||||
JOIN work_log ON work_log.task_id = tasks.id
|
||||
WHERE project_id = $1
|
||||
WHERE project_id = $1 AND total_minutes <> 0 AND (total_minutes * 60) <> work_log.total_time_spent
|
||||
AND CASE
|
||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
|
||||
@@ -7,6 +7,9 @@ import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {getColor} from "../shared/utils";
|
||||
import TeamMembersController from "./team-members-controller";
|
||||
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
|
||||
import {updateUsers} from "../shared/paddle-requests";
|
||||
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
|
||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||
|
||||
export default class ProjectMembersController extends WorklenzControllerBase {
|
||||
@@ -69,6 +72,81 @@ export default class ProjectMembersController extends WorklenzControllerBase {
|
||||
|
||||
if (!req.user?.team_id) return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
|
||||
|
||||
// check the subscription status
|
||||
const subscriptionData = await checkTeamSubscriptionStatus(req.user?.team_id);
|
||||
|
||||
const userExists = await this.checkIfUserAlreadyExists(req.user?.owner_id as string, req.body.email);
|
||||
|
||||
// Return error if user already exists
|
||||
if (userExists) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "User already exists in the team."));
|
||||
}
|
||||
|
||||
// Handle self-hosted subscriptions differently
|
||||
if (subscriptionData.subscription_type === 'SELF_HOSTED') {
|
||||
// Adding as a team member
|
||||
const teamMemberReq: { team_id?: string; emails: string[], project_id?: string; } = {
|
||||
team_id: req.user?.team_id,
|
||||
emails: [req.body.email]
|
||||
};
|
||||
|
||||
if (req.body.project_id)
|
||||
teamMemberReq.project_id = req.body.project_id;
|
||||
|
||||
const [member] = await TeamMembersController.createOrInviteMembers(teamMemberReq, req.user);
|
||||
|
||||
if (!member)
|
||||
return res.status(200).send(new ServerResponse(true, null, "Failed to add the member to the project. Please try again."));
|
||||
|
||||
// Adding to the project
|
||||
const projectMemberReq = {
|
||||
team_member_id: member.team_member_id,
|
||||
team_id: req.user?.team_id,
|
||||
project_id: req.body.project_id,
|
||||
user_id: req.user?.id,
|
||||
access_level: req.body.access_level ? req.body.access_level : "MEMBER"
|
||||
};
|
||||
const data = await this.createOrInviteMembers(projectMemberReq);
|
||||
return res.status(200).send(new ServerResponse(true, data.member));
|
||||
}
|
||||
|
||||
if (statusExclude.includes(subscriptionData.subscription_status)) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Unable to add user! Please check your subscription status."));
|
||||
}
|
||||
|
||||
if (!userExists && subscriptionData.is_ltd && subscriptionData.current_count && (parseInt(subscriptionData.current_count) + 1 > parseInt(subscriptionData.ltd_users))) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks trial user team member limit
|
||||
*/
|
||||
if (subscriptionData.subscription_status === "trialing") {
|
||||
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||
|
||||
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||
}
|
||||
}
|
||||
|
||||
// if (subscriptionData.status === "trialing") break;
|
||||
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
|
||||
// if (subscriptionData.subscription_status === "active") {
|
||||
// const response = await updateUsers(subscriptionData.subscription_id, (subscriptionData.quantity + 1));
|
||||
// if (!response.body.subscription_id) return res.status(200).send(new ServerResponse(false, null, response.message || "Unable to add user! Please check your subscription."));
|
||||
// }
|
||||
const updatedCount = parseInt(subscriptionData.current_count) + 1;
|
||||
const requiredSeats = updatedCount - subscriptionData.quantity;
|
||||
if (updatedCount > subscriptionData.quantity) {
|
||||
const obj = {
|
||||
seats_enough: false,
|
||||
required_count: requiredSeats,
|
||||
current_seat_amount: subscriptionData.quantity
|
||||
};
|
||||
return res.status(200).send(new ServerResponse(false, obj, null));
|
||||
}
|
||||
}
|
||||
|
||||
// Adding as a team member
|
||||
const teamMemberReq: { team_id?: string; emails: string[], project_id?: string; } = {
|
||||
team_id: req.user?.team_id,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { templateData } from "./project-templates";
|
||||
import ProjectTemplatesControllerBase from "./project-templates-base";
|
||||
import { LOG_DESCRIPTIONS, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../../shared/constants";
|
||||
import { IO } from "../../shared/io";
|
||||
import { getCurrentProjectsCount, getFreePlanSettings } from "../../shared/paddle-utils";
|
||||
|
||||
export default class ProjectTemplatesController extends ProjectTemplatesControllerBase {
|
||||
|
||||
@@ -46,10 +47,10 @@ export default class ProjectTemplatesController extends ProjectTemplatesControll
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getDefaultProjectHealth() {
|
||||
const q = `SELECT id FROM sys_project_healths WHERE is_default IS TRUE`;
|
||||
const result = await db.query(q, []);
|
||||
const [data] = result.rows;
|
||||
return data.id;
|
||||
const q = `SELECT id FROM sys_project_healths WHERE is_default IS TRUE`;
|
||||
const result = await db.query(q, []);
|
||||
const [data] = result.rows;
|
||||
return data.id;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -92,6 +93,16 @@ export default class ProjectTemplatesController extends ProjectTemplatesControll
|
||||
|
||||
@HandleExceptions()
|
||||
public static async importTemplates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (req.user?.subscription_status === "free" && req.user?.owner_id) {
|
||||
const limits = await getFreePlanSettings();
|
||||
const projectsCount = await getCurrentProjectsCount(req.user.owner_id);
|
||||
const projectsLimit = parseInt(limits.projects_limit);
|
||||
|
||||
if (parseInt(projectsCount) >= projectsLimit) {
|
||||
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
|
||||
}
|
||||
}
|
||||
|
||||
const { template_id } = req.body;
|
||||
let project_id: string | null = null;
|
||||
|
||||
@@ -202,6 +213,16 @@ export default class ProjectTemplatesController extends ProjectTemplatesControll
|
||||
|
||||
@HandleExceptions()
|
||||
public static async importCustomTemplate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (req.user?.subscription_status === "free" && req.user?.owner_id) {
|
||||
const limits = await getFreePlanSettings();
|
||||
const projectsCount = await getCurrentProjectsCount(req.user.owner_id);
|
||||
const projectsLimit = parseInt(limits.projects_limit);
|
||||
|
||||
if (parseInt(projectsCount) >= projectsLimit) {
|
||||
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
|
||||
}
|
||||
}
|
||||
|
||||
const { template_id } = req.body;
|
||||
let project_id: string | null = null;
|
||||
|
||||
@@ -223,8 +244,8 @@ export default class ProjectTemplatesController extends ProjectTemplatesControll
|
||||
await this.deleteDefaultStatusForProject(project_id as string);
|
||||
await this.insertTeamLabels(data.labels, req.user?.team_id);
|
||||
await this.insertProjectPhases(data.phases, project_id as string);
|
||||
await this.insertProjectStatuses(data.status, project_id as string, data.team_id );
|
||||
await this.insertProjectTasksFromCustom(data.tasks, data.team_id, project_id as string, data.user_id, IO.getSocketById(req.user?.socket_id as string));
|
||||
await this.insertProjectStatuses(data.status, project_id as string, data.team_id);
|
||||
await this.insertProjectTasksFromCustom(data.tasks, data.team_id, project_id as string, data.user_id, IO.getSocketById(req.user?.socket_id as string));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { project_id }));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { NotificationsService } from "../services/notifications/notifications.se
|
||||
import { IPassportSession } from "../interfaces/passport-session";
|
||||
import { SocketEvents } from "../socket.io/events";
|
||||
import { IO } from "../shared/io";
|
||||
import { getCurrentProjectsCount, getFreePlanSettings } from "../shared/paddle-utils";
|
||||
|
||||
export default class ProjectsController extends WorklenzControllerBase {
|
||||
|
||||
@@ -61,6 +62,16 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
}
|
||||
})
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (req.user?.subscription_status === "free" && req.user?.owner_id) {
|
||||
const limits = await getFreePlanSettings();
|
||||
const projectsCount = await getCurrentProjectsCount(req.user.owner_id);
|
||||
const projectsLimit = parseInt(limits.projects_limit);
|
||||
|
||||
if (parseInt(projectsCount) >= projectsLimit) {
|
||||
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
|
||||
}
|
||||
}
|
||||
|
||||
const q = `SELECT create_project($1) AS project`;
|
||||
|
||||
req.body.team_id = req.user?.team_id || null;
|
||||
@@ -306,65 +317,58 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||
const search = (req.query.search || "").toString().trim();
|
||||
|
||||
let searchFilter = "";
|
||||
const params = [req.params.id, req.user?.team_id ?? null, size, offset];
|
||||
if (search) {
|
||||
searchFilter = `
|
||||
AND (
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
||||
OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
||||
)
|
||||
`;
|
||||
params.push(search);
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS members
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT project_members.id,
|
||||
team_member_id,
|
||||
(SELECT name
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT email
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||
u.avatar_url,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = project_members.project_id
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = project_members.project_id
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.project_member_id = project_members.id)
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE category_id = (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
EXISTS(SELECT email
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = project_members.team_member_id
|
||||
AND email_invitations.team_id = $2) AS pending_invitation,
|
||||
(SELECT project_access_levels.name
|
||||
FROM project_access_levels
|
||||
WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
||||
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
||||
FROM project_members
|
||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE project_id = $1
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $3 OFFSET $4) t) AS data
|
||||
FROM project_members
|
||||
WHERE project_id = $1) rec;
|
||||
WITH filtered_members AS (
|
||||
SELECT project_members.id,
|
||||
team_member_id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name,
|
||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||
u.avatar_url,
|
||||
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
||||
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation,
|
||||
(SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
||||
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
||||
FROM project_members
|
||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE project_id = $1
|
||||
${search ? searchFilter : ""}
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM filtered_members) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT * FROM filtered_members
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $3 OFFSET $4
|
||||
) t
|
||||
) AS data
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
|
||||
|
||||
const result = await db.query(q, params);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const member of data?.members.data || []) {
|
||||
for (const member of data?.data || []) {
|
||||
member.progress = member.all_tasks_count > 0
|
||||
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct));
|
||||
return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -397,6 +401,9 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
sps.color_code AS status_color,
|
||||
sps.icon AS status_icon,
|
||||
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
|
||||
projects.use_manual_progress,
|
||||
projects.use_weighted_progress,
|
||||
projects.use_time_progress,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
|
||||
FROM (SELECT team_member_id AS id,
|
||||
@@ -689,7 +696,8 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
public static async toggleArchiveAll(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT toggle_archive_all_projects($1);`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data.toggle_archive_all_projects || []));
|
||||
}
|
||||
|
||||
public static async getProjectManager(projectId: string) {
|
||||
@@ -698,4 +706,229 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
return result.rows || [];
|
||||
}
|
||||
|
||||
public static async updateExistPhaseColors() {
|
||||
const q = `SELECT id, name FROM project_phases`;
|
||||
const phases = await db.query(q);
|
||||
|
||||
phases.rows.forEach((phase) => {
|
||||
phase.color_code = getColor(phase.name);
|
||||
});
|
||||
|
||||
const body = {
|
||||
phases: phases.rows
|
||||
};
|
||||
|
||||
const q2 = `SELECT update_existing_phase_colors($1)`;
|
||||
await db.query(q2, [JSON.stringify(body)]);
|
||||
|
||||
}
|
||||
|
||||
public static async updateExistSortOrder() {
|
||||
const q = `SELECT id, project_id FROM project_phases ORDER BY name`;
|
||||
const phases = await db.query(q);
|
||||
|
||||
const sortNumbers: any = {};
|
||||
|
||||
phases.rows.forEach(phase => {
|
||||
const projectId = phase.project_id;
|
||||
|
||||
if (!sortNumbers[projectId]) {
|
||||
sortNumbers[projectId] = 0;
|
||||
}
|
||||
|
||||
phase.sort_number = sortNumbers[projectId]++;
|
||||
});
|
||||
|
||||
const body = {
|
||||
phases: phases.rows
|
||||
};
|
||||
|
||||
const q2 = `SELECT update_existing_phase_sort_order($1)`;
|
||||
await db.query(q2, [JSON.stringify(body)]);
|
||||
// return phases;
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// Use qualified field name for projects to avoid ambiguity
|
||||
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]);
|
||||
const groupBy = req.query.groupBy as string || "category";
|
||||
|
||||
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
|
||||
` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : "";
|
||||
|
||||
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
|
||||
const isArchived = req.query.filter === "2"
|
||||
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
|
||||
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
|
||||
const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string);
|
||||
const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string);
|
||||
|
||||
// Determine grouping field and join based on groupBy parameter
|
||||
let groupField = "";
|
||||
let groupName = "";
|
||||
let groupColor = "";
|
||||
let groupJoin = "";
|
||||
let groupByFields = "";
|
||||
let groupOrderBy = "";
|
||||
|
||||
switch (groupBy) {
|
||||
case "client":
|
||||
groupField = "COALESCE(projects.client_id::text, 'no-client')";
|
||||
groupName = "COALESCE(clients.name, 'No Client')";
|
||||
groupColor = "'#688'";
|
||||
groupJoin = "LEFT JOIN clients ON projects.client_id = clients.id";
|
||||
groupByFields = "projects.client_id, clients.name";
|
||||
groupOrderBy = "COALESCE(clients.name, 'No Client')";
|
||||
break;
|
||||
case "status":
|
||||
groupField = "COALESCE(projects.status_id::text, 'no-status')";
|
||||
groupName = "COALESCE(sys_project_statuses.name, 'No Status')";
|
||||
groupColor = "COALESCE(sys_project_statuses.color_code, '#888')";
|
||||
groupJoin = "LEFT JOIN sys_project_statuses ON projects.status_id = sys_project_statuses.id";
|
||||
groupByFields = "projects.status_id, sys_project_statuses.name, sys_project_statuses.color_code";
|
||||
groupOrderBy = "COALESCE(sys_project_statuses.name, 'No Status')";
|
||||
break;
|
||||
case "category":
|
||||
default:
|
||||
groupField = "COALESCE(projects.category_id::text, 'uncategorized')";
|
||||
groupName = "COALESCE(project_categories.name, 'Uncategorized')";
|
||||
groupColor = "COALESCE(project_categories.color_code, '#888')";
|
||||
groupJoin = "LEFT JOIN project_categories ON projects.category_id = project_categories.id";
|
||||
groupByFields = "projects.category_id, project_categories.name, project_categories.color_code";
|
||||
groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')";
|
||||
}
|
||||
|
||||
// Ensure sortField is properly qualified for the inner project query
|
||||
let qualifiedSortField = sortField;
|
||||
if (Array.isArray(sortField)) {
|
||||
qualifiedSortField = sortField[0]; // Take the first field if it's an array
|
||||
}
|
||||
// Replace "projects." with "p2." for the inner query
|
||||
const innerSortField = qualifiedSortField.replace("projects.", "p2.");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS groups
|
||||
FROM (
|
||||
SELECT COUNT(DISTINCT ${groupField}) AS total_groups,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(group_data))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT ${groupField} AS group_key,
|
||||
${groupName} AS group_name,
|
||||
${groupColor} AS group_color,
|
||||
COUNT(*) AS project_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(project_data))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT p2.id,
|
||||
p2.name,
|
||||
(SELECT sys_project_statuses.name FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status,
|
||||
(SELECT sys_project_statuses.color_code FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_color,
|
||||
(SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon,
|
||||
EXISTS(SELECT user_id
|
||||
FROM favorite_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = p2.id) AS favorite,
|
||||
EXISTS(SELECT user_id
|
||||
FROM archived_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = p2.id) AS archived,
|
||||
p2.color_code,
|
||||
p2.start_date,
|
||||
p2.end_date,
|
||||
p2.category_id,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p2.id) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p2.id
|
||||
AND status_id IN (SELECT task_statuses.id
|
||||
FROM task_statuses
|
||||
WHERE task_statuses.project_id = p2.id
|
||||
AND task_statuses.category_id IN
|
||||
(SELECT sys_task_status_categories.id FROM sys_task_status_categories WHERE sys_task_status_categories.is_done IS TRUE))) AS completed_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM project_members
|
||||
WHERE project_members.project_id = p2.id) AS members_count,
|
||||
(SELECT get_project_members(p2.id)) AS names,
|
||||
(SELECT clients.name FROM clients WHERE clients.id = p2.client_id) AS client_name,
|
||||
(SELECT users.name FROM users WHERE users.id = p2.owner_id) AS project_owner,
|
||||
(SELECT project_categories.name FROM project_categories WHERE project_categories.id = p2.category_id) AS category_name,
|
||||
(SELECT project_categories.color_code
|
||||
FROM project_categories
|
||||
WHERE project_categories.id = p2.category_id) AS category_color,
|
||||
((SELECT project_members.team_member_id as team_member_id
|
||||
FROM project_members
|
||||
WHERE project_members.project_id = p2.id
|
||||
AND project_members.project_access_level_id = (SELECT project_access_levels.id FROM project_access_levels WHERE project_access_levels.key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id,
|
||||
(SELECT project_members.default_view
|
||||
FROM project_members
|
||||
WHERE project_members.project_id = p2.id
|
||||
AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view,
|
||||
(SELECT CASE
|
||||
WHEN ((SELECT MAX(tasks.updated_at)
|
||||
FROM tasks
|
||||
WHERE tasks.archived IS FALSE
|
||||
AND tasks.project_id = p2.id) >
|
||||
p2.updated_at)
|
||||
THEN (SELECT MAX(tasks.updated_at)
|
||||
FROM tasks
|
||||
WHERE tasks.archived IS FALSE
|
||||
AND tasks.project_id = p2.id)
|
||||
ELSE p2.updated_at END) AS updated_at
|
||||
FROM projects p2
|
||||
${groupJoin.replace("projects.", "p2.")}
|
||||
WHERE p2.team_id = $1
|
||||
AND ${groupField.replace("projects.", "p2.")} = ${groupField}
|
||||
${categories.replace("projects.", "p2.")}
|
||||
${statuses.replace("projects.", "p2.")}
|
||||
${isArchived.replace("projects.", "p2.")}
|
||||
${isFavorites.replace("projects.", "p2.")}
|
||||
${filterByMember.replace("projects.", "p2.")}
|
||||
${searchQuery.replace("projects.", "p2.")}
|
||||
ORDER BY ${innerSortField} ${sortOrder}
|
||||
) project_data
|
||||
) AS projects
|
||||
FROM projects
|
||||
${groupJoin}
|
||||
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
||||
GROUP BY ${groupByFields}
|
||||
ORDER BY ${groupOrderBy}
|
||||
LIMIT $2 OFFSET $3
|
||||
) group_data
|
||||
) AS data
|
||||
FROM projects
|
||||
${groupJoin}
|
||||
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
||||
) rec;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
// Process the grouped data
|
||||
for (const group of data?.groups.data || []) {
|
||||
for (const project of group.projects || []) {
|
||||
project.progress = project.all_tasks_count > 0
|
||||
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
|
||||
project.updated_at_string = moment(project.updated_at).fromNow();
|
||||
|
||||
project.names = this.createTagList(project?.names);
|
||||
project.names.map((a: any) => a.color_code = getColor(a.name));
|
||||
|
||||
if (project.project_manager_team_member_id) {
|
||||
project.project_manager = {
|
||||
id: project.project_manager_team_member_id
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data?.groups || { total_groups: 0, data: [] }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IChartObject } from "./overview/reporting-overview-base";
|
||||
import * as Highcharts from "highcharts";
|
||||
|
||||
export interface IDuration {
|
||||
label: string;
|
||||
@@ -34,7 +34,7 @@ export interface IOverviewStatistics {
|
||||
}
|
||||
|
||||
export interface IChartData {
|
||||
chart: IChartObject[];
|
||||
chart: Highcharts.PointOptionsObject[];
|
||||
}
|
||||
|
||||
export interface ITasksByStatus extends IChartData {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import db from "../../../config/db";
|
||||
import * as Highcharts from "highcharts";
|
||||
import { ITasksByDue, ITasksByPriority, ITasksByStatus } from "../interfaces";
|
||||
import ReportingControllerBase from "../reporting-controller-base";
|
||||
import {
|
||||
@@ -15,36 +16,33 @@ import {
|
||||
TASK_STATUS_TODO_COLOR
|
||||
} from "../../../shared/constants";
|
||||
import { formatDuration, int } from "../../../shared/utils";
|
||||
import PointOptionsObject from "../point-options-object";
|
||||
import moment from "moment";
|
||||
|
||||
export interface IChartObject {
|
||||
name: string,
|
||||
color: string,
|
||||
y: number
|
||||
}
|
||||
|
||||
export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
private static createChartObject(name: string, color: string, y: number) {
|
||||
return {
|
||||
name,
|
||||
color,
|
||||
y
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getTeamsCounts(teamId: string | null, archivedQuery = "") {
|
||||
|
||||
const q = `
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'teams', (SELECT COUNT(*) FROM teams WHERE in_organization(id, $1)),
|
||||
'projects',
|
||||
(SELECT COUNT(*) FROM projects WHERE in_organization(team_id, $1) ${archivedQuery}),
|
||||
'team_members', (SELECT COUNT(DISTINCT email)
|
||||
FROM team_member_info_view
|
||||
WHERE in_organization(team_id, $1))
|
||||
) AS counts;
|
||||
`;
|
||||
WITH team_count AS (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM teams
|
||||
WHERE in_organization(id, $1)
|
||||
),
|
||||
project_count AS (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM projects
|
||||
WHERE in_organization(team_id, $1) ${archivedQuery}
|
||||
),
|
||||
team_member_count AS (
|
||||
SELECT COUNT(DISTINCT email) AS count
|
||||
FROM team_member_info_view
|
||||
WHERE in_organization(team_id, $1)
|
||||
)
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'teams', (SELECT count FROM team_count),
|
||||
'projects', (SELECT count FROM project_count),
|
||||
'team_members', (SELECT count FROM team_member_count)
|
||||
) AS counts;`;
|
||||
|
||||
const res = await db.query(q, [teamId]);
|
||||
const [data] = res.rows;
|
||||
@@ -173,7 +171,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
const doing = int(data?.counts.doing);
|
||||
const done = int(data?.counts.done);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
const chart: Highcharts.PointOptionsObject[] = [];
|
||||
|
||||
return {
|
||||
all,
|
||||
@@ -209,7 +207,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
const medium = int(data?.counts.medium);
|
||||
const high = int(data?.counts.high);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
const chart: Highcharts.PointOptionsObject[] = [];
|
||||
|
||||
return {
|
||||
all: 0,
|
||||
@@ -237,7 +235,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
const res = await db.query(q, [projectId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
const chart: Highcharts.PointOptionsObject[] = [];
|
||||
|
||||
return {
|
||||
all: 0,
|
||||
@@ -251,26 +249,26 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
protected static createByStatusChartData(body: ITasksByStatus) {
|
||||
body.chart = [
|
||||
this.createChartObject("Todo", TASK_STATUS_TODO_COLOR, body.todo),
|
||||
this.createChartObject("Doing", TASK_STATUS_DOING_COLOR, body.doing),
|
||||
this.createChartObject("Done", TASK_STATUS_DONE_COLOR, body.done),
|
||||
new PointOptionsObject("Todo", TASK_STATUS_TODO_COLOR, body.todo),
|
||||
new PointOptionsObject("Doing", TASK_STATUS_DOING_COLOR, body.doing),
|
||||
new PointOptionsObject("Done", TASK_STATUS_DONE_COLOR, body.done),
|
||||
];
|
||||
}
|
||||
|
||||
protected static createByPriorityChartData(body: ITasksByPriority) {
|
||||
body.chart = [
|
||||
this.createChartObject("Low", TASK_PRIORITY_LOW_COLOR, body.low),
|
||||
this.createChartObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, body.medium),
|
||||
this.createChartObject("High", TASK_PRIORITY_HIGH_COLOR, body.high),
|
||||
new PointOptionsObject("Low", TASK_PRIORITY_LOW_COLOR, body.low),
|
||||
new PointOptionsObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, body.medium),
|
||||
new PointOptionsObject("High", TASK_PRIORITY_HIGH_COLOR, body.high),
|
||||
];
|
||||
}
|
||||
|
||||
protected static createByDueDateChartData(body: ITasksByDue) {
|
||||
body.chart = [
|
||||
this.createChartObject("Completed", TASK_DUE_COMPLETED_COLOR, body.completed),
|
||||
this.createChartObject("Upcoming", TASK_DUE_UPCOMING_COLOR, body.upcoming),
|
||||
this.createChartObject("Overdue", TASK_DUE_OVERDUE_COLOR, body.overdue),
|
||||
this.createChartObject("No due date", TASK_DUE_NO_DUE_COLOR, body.no_due),
|
||||
new PointOptionsObject("Completed", TASK_DUE_COMPLETED_COLOR, body.completed),
|
||||
new PointOptionsObject("Upcoming", TASK_DUE_UPCOMING_COLOR, body.upcoming),
|
||||
new PointOptionsObject("Overdue", TASK_DUE_OVERDUE_COLOR, body.overdue),
|
||||
new PointOptionsObject("No due date", TASK_DUE_NO_DUE_COLOR, body.no_due),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -581,7 +579,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
`;
|
||||
const result = await db.query(q, [teamMemberId]);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
const chart: Highcharts.PointOptionsObject[] = [];
|
||||
|
||||
const total = result.rows.reduce((accumulator: number, current: {
|
||||
count: number
|
||||
@@ -589,7 +587,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
for (const project of result.rows) {
|
||||
project.count = int(project.count);
|
||||
chart.push(this.createChartObject(project.label, project.color, project.count));
|
||||
chart.push(new PointOptionsObject(project.label, project.color, project.count));
|
||||
}
|
||||
|
||||
return { chart, total, data: result.rows };
|
||||
@@ -635,7 +633,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
`;
|
||||
const result = await db.query(q, [teamMemberId]);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
const chart: Highcharts.PointOptionsObject[] = [];
|
||||
|
||||
const total = result.rows.reduce((accumulator: number, current: {
|
||||
count: number
|
||||
@@ -643,7 +641,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
for (const project of result.rows) {
|
||||
project.count = int(project.count);
|
||||
chart.push(this.createChartObject(project.label, project.color, project.count));
|
||||
chart.push(new PointOptionsObject(project.label, project.color, project.count));
|
||||
}
|
||||
|
||||
return { chart, total, data: result.rows };
|
||||
@@ -673,10 +671,10 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
const total = int(d.low) + int(d.medium) + int(d.high);
|
||||
|
||||
const chart = [
|
||||
this.createChartObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
|
||||
this.createChartObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
|
||||
this.createChartObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
|
||||
const chart: Highcharts.PointOptionsObject[] = [
|
||||
new PointOptionsObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
|
||||
new PointOptionsObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
|
||||
new PointOptionsObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
|
||||
];
|
||||
|
||||
const data = [
|
||||
@@ -730,10 +728,10 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
const total = int(d.low) + int(d.medium) + int(d.high);
|
||||
|
||||
const chart = [
|
||||
this.createChartObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
|
||||
this.createChartObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
|
||||
this.createChartObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
|
||||
const chart: Highcharts.PointOptionsObject[] = [
|
||||
new PointOptionsObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
|
||||
new PointOptionsObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
|
||||
new PointOptionsObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
|
||||
];
|
||||
|
||||
const data = [
|
||||
@@ -784,10 +782,10 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
const total = int(d.total);
|
||||
|
||||
const chart = [
|
||||
this.createChartObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
|
||||
this.createChartObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
|
||||
this.createChartObject("Done", TASK_STATUS_DONE_COLOR, d.done),
|
||||
const chart: Highcharts.PointOptionsObject[] = [
|
||||
new PointOptionsObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
|
||||
new PointOptionsObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
|
||||
new PointOptionsObject("Done", TASK_STATUS_DONE_COLOR, d.done),
|
||||
];
|
||||
|
||||
const data = [
|
||||
@@ -826,10 +824,10 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
const total = int(d.todo) + int(d.doing) + int(d.done);
|
||||
|
||||
const chart = [
|
||||
this.createChartObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
|
||||
this.createChartObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
|
||||
this.createChartObject("Done", TASK_STATUS_DONE_COLOR, d.done),
|
||||
const chart: Highcharts.PointOptionsObject[] = [
|
||||
new PointOptionsObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
|
||||
new PointOptionsObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
|
||||
new PointOptionsObject("Done", TASK_STATUS_DONE_COLOR, d.done),
|
||||
];
|
||||
|
||||
const data = [
|
||||
@@ -878,7 +876,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
const in_progress = int(data?.counts.in_progress);
|
||||
const completed = int(data?.counts.completed);
|
||||
|
||||
const chart : IChartObject[] = [];
|
||||
const chart: Highcharts.PointOptionsObject[] = [];
|
||||
|
||||
return {
|
||||
all,
|
||||
@@ -908,7 +906,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
`;
|
||||
const result = await db.query(q, [teamId]);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
const chart: Highcharts.PointOptionsObject[] = [];
|
||||
|
||||
const total = result.rows.reduce((accumulator: number, current: {
|
||||
count: number
|
||||
@@ -916,11 +914,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
for (const category of result.rows) {
|
||||
category.count = int(category.count);
|
||||
chart.push({
|
||||
name: category.label,
|
||||
color: category.color,
|
||||
y: category.count
|
||||
});
|
||||
chart.push(new PointOptionsObject(category.label, category.color, category.count));
|
||||
}
|
||||
|
||||
return { chart, total, data: result.rows };
|
||||
@@ -956,7 +950,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
const at_risk = int(data?.counts.at_risk);
|
||||
const good = int(data?.counts.good);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
const chart: Highcharts.PointOptionsObject[] = [];
|
||||
|
||||
return {
|
||||
not_set,
|
||||
@@ -971,22 +965,22 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
// Team Overview
|
||||
protected static createByProjectStatusChartData(body: any) {
|
||||
body.chart = [
|
||||
this.createChartObject("Cancelled", "#f37070", body.cancelled),
|
||||
this.createChartObject("Blocked", "#cbc8a1", body.blocked),
|
||||
this.createChartObject("On Hold", "#cbc8a1", body.on_hold),
|
||||
this.createChartObject("Proposed", "#cbc8a1", body.proposed),
|
||||
this.createChartObject("In Planning", "#cbc8a1", body.in_planning),
|
||||
this.createChartObject("In Progress", "#80ca79", body.in_progress),
|
||||
this.createChartObject("Completed", "#80ca79", body.completed)
|
||||
new PointOptionsObject("Cancelled", "#f37070", body.cancelled),
|
||||
new PointOptionsObject("Blocked", "#cbc8a1", body.blocked),
|
||||
new PointOptionsObject("On Hold", "#cbc8a1", body.on_hold),
|
||||
new PointOptionsObject("Proposed", "#cbc8a1", body.proposed),
|
||||
new PointOptionsObject("In Planning", "#cbc8a1", body.in_planning),
|
||||
new PointOptionsObject("In Progress", "#80ca79", body.in_progress),
|
||||
new PointOptionsObject("Completed", "#80ca79", body.completed),
|
||||
];
|
||||
}
|
||||
|
||||
protected static createByProjectHealthChartData(body: any) {
|
||||
body.chart = [
|
||||
this.createChartObject("Not Set", "#a9a9a9", body.not_set),
|
||||
this.createChartObject("Needs Attention", "#f37070", body.needs_attention),
|
||||
this.createChartObject("At Risk", "#fbc84c", body.at_risk),
|
||||
this.createChartObject("Good", "#75c997", body.good)
|
||||
new PointOptionsObject("Not Set", "#a9a9a9", body.not_set),
|
||||
new PointOptionsObject("Needs Attention", "#f37070", body.needs_attention),
|
||||
new PointOptionsObject("At Risk", "#fbc84c", body.at_risk),
|
||||
new PointOptionsObject("Good", "#75c997", body.good)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as Highcharts from "highcharts";
|
||||
|
||||
export default class PointOptionsObject implements Highcharts.PointOptionsObject {
|
||||
name!: string;
|
||||
color!: string;
|
||||
y!: number;
|
||||
|
||||
constructor(name: string, color: string, y: number) {
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Example of updated getMemberTimeSheets method with timezone support
|
||||
// This shows the key changes needed to handle timezones properly
|
||||
|
||||
import moment from "moment-timezone";
|
||||
import db from "../../config/db";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { DATE_RANGES } from "../../shared/constants";
|
||||
|
||||
export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const archived = req.query.archived === "true";
|
||||
const teams = (req.body.teams || []) as string[];
|
||||
const teamIds = teams.map(id => `'${id}'`).join(",");
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
const {billable} = req.body;
|
||||
|
||||
// Get user timezone from request or database
|
||||
const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || "");
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
// Calculate date range with timezone support
|
||||
let startDate: moment.Moment;
|
||||
let endDate: moment.Moment;
|
||||
|
||||
if (date_range && date_range.length === 2) {
|
||||
// Convert user's local dates to their timezone's start/end of day
|
||||
startDate = moment.tz(date_range[0], userTimezone).startOf("day");
|
||||
endDate = moment.tz(date_range[1], userTimezone).endOf("day");
|
||||
} else if (duration === DATE_RANGES.ALL_TIME) {
|
||||
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
|
||||
const minDateResult = await db.query(minDateQuery, []);
|
||||
const minDate = minDateResult.rows[0]?.min_date;
|
||||
startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz("2000-01-01", userTimezone);
|
||||
endDate = moment.tz(userTimezone);
|
||||
} else {
|
||||
// Calculate ranges based on user's timezone
|
||||
const now = moment.tz(userTimezone);
|
||||
|
||||
switch (duration) {
|
||||
case DATE_RANGES.YESTERDAY:
|
||||
startDate = now.clone().subtract(1, "day").startOf("day");
|
||||
endDate = now.clone().subtract(1, "day").endOf("day");
|
||||
break;
|
||||
case DATE_RANGES.LAST_WEEK:
|
||||
startDate = now.clone().subtract(1, "week").startOf("isoWeek");
|
||||
endDate = now.clone().subtract(1, "week").endOf("isoWeek");
|
||||
break;
|
||||
case DATE_RANGES.LAST_MONTH:
|
||||
startDate = now.clone().subtract(1, "month").startOf("month");
|
||||
endDate = now.clone().subtract(1, "month").endOf("month");
|
||||
break;
|
||||
case DATE_RANGES.LAST_QUARTER:
|
||||
startDate = now.clone().subtract(3, "months").startOf("day");
|
||||
endDate = now.clone().endOf("day");
|
||||
break;
|
||||
default:
|
||||
startDate = now.clone().startOf("day");
|
||||
endDate = now.clone().endOf("day");
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to UTC for database queries
|
||||
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
// Calculate working days in user's timezone
|
||||
const totalDays = endDate.diff(startDate, "days") + 1;
|
||||
let workingDays = 0;
|
||||
|
||||
const current = startDate.clone();
|
||||
while (current.isSameOrBefore(endDate, "day")) {
|
||||
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
||||
workingDays++;
|
||||
}
|
||||
current.add(1, "day");
|
||||
}
|
||||
|
||||
// Updated SQL query with proper timezone handling
|
||||
const billableQuery = buildBillableQuery(billable);
|
||||
const archivedClause = archived ? "" : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}')`;
|
||||
|
||||
const q = `
|
||||
WITH project_hours AS (
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(hours_per_day, 8) as hours_per_day
|
||||
FROM projects
|
||||
WHERE id IN (${projectIds})
|
||||
),
|
||||
total_working_hours AS (
|
||||
SELECT
|
||||
SUM(hours_per_day) * ${workingDays} as total_hours
|
||||
FROM project_hours
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
tm.name,
|
||||
tm.color_code,
|
||||
COALESCE(SUM(twl.time_spent), 0) as logged_time,
|
||||
COALESCE(SUM(twl.time_spent), 0) / 3600.0 as value,
|
||||
(SELECT total_hours FROM total_working_hours) as total_working_hours,
|
||||
CASE
|
||||
WHEN (SELECT total_hours FROM total_working_hours) > 0
|
||||
THEN ROUND((COALESCE(SUM(twl.time_spent), 0) / 3600.0) / (SELECT total_hours FROM total_working_hours) * 100, 2)
|
||||
ELSE 0
|
||||
END as utilization_percent,
|
||||
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0, 2) as utilized_hours,
|
||||
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0 - (SELECT total_hours FROM total_working_hours), 2) as over_under_utilized_hours,
|
||||
'${userTimezone}' as user_timezone,
|
||||
'${startDate.format("YYYY-MM-DD")}' as report_start_date,
|
||||
'${endDate.format("YYYY-MM-DD")}' as report_end_date
|
||||
FROM team_members tm
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
LEFT JOIN task_work_log twl ON twl.user_id = u.id
|
||||
LEFT JOIN tasks t ON twl.task_id = t.id ${billableQuery}
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
WHERE tm.team_id IN (${teamIds})
|
||||
AND (
|
||||
twl.id IS NULL
|
||||
OR (
|
||||
p.id IN (${projectIds})
|
||||
AND twl.created_at >= '${startUtc}'::TIMESTAMP
|
||||
AND twl.created_at <= '${endUtc}'::TIMESTAMP
|
||||
${archivedClause}
|
||||
)
|
||||
)
|
||||
GROUP BY u.id, u.email, tm.name, tm.color_code
|
||||
ORDER BY logged_time DESC`;
|
||||
|
||||
const result = await db.query(q, []);
|
||||
|
||||
// Add timezone context to response
|
||||
const response = {
|
||||
data: result.rows,
|
||||
timezone_info: {
|
||||
user_timezone: userTimezone,
|
||||
report_period: {
|
||||
start: startDate.format("YYYY-MM-DD HH:mm:ss z"),
|
||||
end: endDate.format("YYYY-MM-DD HH:mm:ss z"),
|
||||
working_days: workingDays,
|
||||
total_days: totalDays
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, response));
|
||||
}
|
||||
|
||||
async function getUserTimezone(userId: string): Promise<string> {
|
||||
const q = `SELECT tz.name as timezone
|
||||
FROM users u
|
||||
JOIN timezones tz ON u.timezone_id = tz.id
|
||||
WHERE u.id = $1`;
|
||||
const result = await db.query(q, [userId]);
|
||||
return result.rows[0]?.timezone || "UTC";
|
||||
}
|
||||
|
||||
function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string {
|
||||
if (!billable) return "";
|
||||
|
||||
const { billable: isBillable, nonBillable } = billable;
|
||||
|
||||
if (isBillable && nonBillable) {
|
||||
return "";
|
||||
} else if (isBillable) {
|
||||
return " AND tasks.billable IS TRUE";
|
||||
} else if (nonBillable) {
|
||||
return " AND tasks.billable IS FALSE";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import { getColor, int, log_error } from "../../shared/utils";
|
||||
import ReportingControllerBase from "./reporting-controller-base";
|
||||
import { DATE_RANGES } from "../../shared/constants";
|
||||
import Excel from "exceljs";
|
||||
import ChartJsImage from "chartjs-to-image";
|
||||
|
||||
enum IToggleOptions {
|
||||
'WORKING_DAYS' = 'WORKING_DAYS', 'MAN_DAYS' = 'MAN_DAYS'
|
||||
}
|
||||
|
||||
export default class ReportingAllocationController extends ReportingControllerBase {
|
||||
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = ""): Promise<any> {
|
||||
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise<any> {
|
||||
try {
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
const userIds = users.map(u => `'${u}'`).join(",");
|
||||
@@ -24,8 +25,10 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
? ""
|
||||
: `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${user_id}') `;
|
||||
|
||||
const projectTimeLogs = await this.getTotalTimeLogsByProject(archived, duration, projectIds, userIds, archivedClause);
|
||||
const userTimeLogs = await this.getTotalTimeLogsByUser(archived, duration, projectIds, userIds);
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
const projectTimeLogs = await this.getTotalTimeLogsByProject(archived, duration, projectIds, userIds, archivedClause, billableQuery);
|
||||
const userTimeLogs = await this.getTotalTimeLogsByUser(archived, duration, projectIds, userIds, billableQuery);
|
||||
|
||||
const format = (seconds: number) => {
|
||||
if (seconds === 0) return "-";
|
||||
@@ -65,7 +68,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
return [];
|
||||
}
|
||||
|
||||
private static async getTotalTimeLogsByProject(archived: boolean, duration: string, projectIds: string, userIds: string, archivedClause = "") {
|
||||
private static async getTotalTimeLogsByProject(archived: boolean, duration: string, projectIds: string, userIds: string, archivedClause = "", billableQuery = '') {
|
||||
try {
|
||||
const q = `SELECT projects.name,
|
||||
projects.color_code,
|
||||
@@ -74,12 +77,12 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
sps.icon AS status_icon,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery}
|
||||
AND project_id = projects.id) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
|
||||
AND project_id = projects.id
|
||||
AND project_id = projects.id ${billableQuery}
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE project_id = projects.id
|
||||
@@ -91,10 +94,10 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
SELECT name,
|
||||
(SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
LEFT JOIN tasks t ON task_work_log.task_id = t.id
|
||||
WHERE user_id = users.id
|
||||
AND CASE WHEN ($1 IS TRUE) THEN t.project_id IS NOT NULL ELSE t.archived = FALSE END
|
||||
AND t.project_id = projects.id
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
|
||||
WHERE user_id = users.id ${billableQuery}
|
||||
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
|
||||
AND tasks.project_id = projects.id
|
||||
${duration}) AS time_logged
|
||||
FROM users
|
||||
WHERE id IN (${userIds})
|
||||
@@ -113,15 +116,15 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
}
|
||||
}
|
||||
|
||||
private static async getTotalTimeLogsByUser(archived: boolean, duration: string, projectIds: string, userIds: string) {
|
||||
private static async getTotalTimeLogsByUser(archived: boolean, duration: string, projectIds: string, userIds: string, billableQuery = "") {
|
||||
try {
|
||||
const q = `(SELECT id,
|
||||
(SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
LEFT JOIN tasks t ON task_work_log.task_id = t.id
|
||||
LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery}
|
||||
WHERE user_id = users.id
|
||||
AND CASE WHEN ($1 IS TRUE) THEN t.project_id IS NOT NULL ELSE t.archived = FALSE END
|
||||
AND t.project_id IN (${projectIds})
|
||||
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
|
||||
AND tasks.project_id IN (${projectIds})
|
||||
${duration}) AS time_logged
|
||||
FROM users
|
||||
WHERE id IN (${userIds})
|
||||
@@ -154,6 +157,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
@HandleExceptions()
|
||||
public static async getAllocation(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teams = (req.body.teams || []) as string[]; // ids
|
||||
const billable = req.body.billable;
|
||||
|
||||
const teamIds = teams.map(id => `'${id}'`).join(",");
|
||||
const projectIds = (req.body.projects || []) as string[];
|
||||
@@ -164,7 +168,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const users = await this.getUserIds(teamIds);
|
||||
const userIds = users.map((u: any) => u.id);
|
||||
|
||||
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, req.body.duration, req.body.date_range, (req.query.archived === "true"), req.user?.id);
|
||||
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, req.body.duration, req.body.date_range, (req.query.archived === "true"), req.user?.id, billable);
|
||||
|
||||
for (const [i, user] of users.entries()) {
|
||||
user.total_time = userTimeLogs[i].time_logged;
|
||||
@@ -184,6 +188,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
public static async export(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const teams = (req.query.teams as string)?.split(",");
|
||||
const teamIds = teams.map(t => `'${t}'`).join(",");
|
||||
const billable = req.body.billable ? req.body.billable : { billable: req.query.billable === "true", nonBillable: req.query.nonBillable === "true" };
|
||||
|
||||
const projectIds = (req.query.projects as string)?.split(",");
|
||||
|
||||
@@ -218,7 +223,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const users = await this.getUserIds(teamIds);
|
||||
const userIds = users.map((u: any) => u.id);
|
||||
|
||||
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, duration as string, dateRange, (req.query.include_archived === "true"), req.user?.id);
|
||||
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, duration as string, dateRange, (req.query.include_archived === "true"), req.user?.id, billable);
|
||||
|
||||
for (const [i, user] of users.entries()) {
|
||||
user.total_time = userTimeLogs[i].time_logged;
|
||||
@@ -341,6 +346,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
const billable = req.body.billable;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
|
||||
@@ -352,6 +359,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
@@ -359,8 +368,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
SUM(total_minutes) AS estimated,
|
||||
color_code
|
||||
FROM projects p
|
||||
LEFT JOIN tasks t ON t.project_id = p.id
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = t.id
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
@@ -372,7 +381,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
project.value = project.logged_time ? parseFloat(moment.duration(project.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||
project.estimated_value = project.estimated ? parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) : 0;
|
||||
|
||||
if (project.value > 0 ) {
|
||||
if (project.value > 0) {
|
||||
data.push(project);
|
||||
}
|
||||
|
||||
@@ -392,22 +401,85 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
const billable = req.body.billable;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
// Calculate the date range (start and end)
|
||||
let startDate: moment.Moment;
|
||||
let endDate: moment.Moment;
|
||||
if (date_range && date_range.length === 2) {
|
||||
startDate = moment(date_range[0]);
|
||||
endDate = moment(date_range[1]);
|
||||
} else if (duration === DATE_RANGES.ALL_TIME) {
|
||||
// Fetch the earliest start_date (or created_at if null) from selected projects
|
||||
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
|
||||
const minDateResult = await db.query(minDateQuery, []);
|
||||
const minDate = minDateResult.rows[0]?.min_date;
|
||||
startDate = minDate ? moment(minDate) : moment('2000-01-01');
|
||||
endDate = moment();
|
||||
} else {
|
||||
switch (duration) {
|
||||
case DATE_RANGES.YESTERDAY:
|
||||
startDate = moment().subtract(1, "day");
|
||||
endDate = moment().subtract(1, "day");
|
||||
break;
|
||||
case DATE_RANGES.LAST_WEEK:
|
||||
startDate = moment().subtract(1, "week").startOf("isoWeek");
|
||||
endDate = moment().subtract(1, "week").endOf("isoWeek");
|
||||
break;
|
||||
case DATE_RANGES.LAST_MONTH:
|
||||
startDate = moment().subtract(1, "month").startOf("month");
|
||||
endDate = moment().subtract(1, "month").endOf("month");
|
||||
break;
|
||||
case DATE_RANGES.LAST_QUARTER:
|
||||
startDate = moment().subtract(3, "months").startOf("quarter");
|
||||
endDate = moment().subtract(1, "quarter").endOf("quarter");
|
||||
break;
|
||||
default:
|
||||
startDate = moment().startOf("day");
|
||||
endDate = moment().endOf("day");
|
||||
}
|
||||
}
|
||||
|
||||
// Count only weekdays (Mon-Fri) in the period
|
||||
let workingDays = 0;
|
||||
let current = startDate.clone();
|
||||
while (current.isSameOrBefore(endDate, 'day')) {
|
||||
const day = current.isoWeekday();
|
||||
if (day >= 1 && day <= 5) workingDays++;
|
||||
current.add(1, 'day');
|
||||
}
|
||||
|
||||
// Get hours_per_day for all selected projects
|
||||
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
|
||||
const projectHoursResult = await db.query(projectHoursQuery, []);
|
||||
const projectHoursMap: Record<string, number> = {};
|
||||
for (const row of projectHoursResult.rows) {
|
||||
projectHoursMap[row.id] = row.hours_per_day || 8;
|
||||
}
|
||||
// Sum total working hours for all selected projects
|
||||
let totalWorkingHours = 0;
|
||||
for (const pid of Object.keys(projectHoursMap)) {
|
||||
totalWorkingHours += workingDays * projectHoursMap[pid];
|
||||
}
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
const q = `
|
||||
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
|
||||
FROM team_member_info_view tmiv
|
||||
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
|
||||
LEFT JOIN tasks t ON t.id = task_work_log.task_id
|
||||
LEFT JOIN projects p ON p.id = t.project_id AND p.team_id = tmiv.team_id
|
||||
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
|
||||
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
|
||||
WHERE p.id IN (${projectIds})
|
||||
${durationClause} ${archivedClause}
|
||||
GROUP BY tmiv.email, tmiv.name
|
||||
@@ -417,12 +489,75 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
for (const member of result.rows) {
|
||||
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||
member.color_code = getColor(member.name);
|
||||
member.total_working_hours = totalWorkingHours;
|
||||
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
|
||||
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||
// Over/under utilized hours: utilized_hours - total_working_hours
|
||||
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
|
||||
member.over_under_utilized_hours = overUnder.toFixed(2);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async exportTest(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const archived = req.query.archived === "true";
|
||||
const teamId = this.getCurrentTeamId(req);
|
||||
const { duration, date_range } = req.query;
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration as string || DATE_RANGES.LAST_WEEK, date_range as string[]);
|
||||
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
(SELECT SUM(time_spent)) AS logged_time,
|
||||
SUM(total_minutes) AS estimated,
|
||||
color_code
|
||||
FROM projects p
|
||||
LEFT JOIN tasks t ON t.project_id = p.id
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = t.id
|
||||
WHERE in_organization(p.team_id, $1)
|
||||
${durationClause} ${archivedClause}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY p.name ASC;`;
|
||||
const result = await db.query(q, [teamId]);
|
||||
|
||||
const labelsX = [];
|
||||
const dataX = [];
|
||||
|
||||
for (const project of result.rows) {
|
||||
project.value = project.logged_time ? parseFloat(moment.duration(project.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||
project.estimated_value = project.estimated ? parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) : 0;
|
||||
labelsX.push(project.name);
|
||||
dataX.push(project.value || 0);
|
||||
}
|
||||
|
||||
const chart = new ChartJsImage();
|
||||
chart.setConfig({
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: labelsX,
|
||||
datasets: [
|
||||
{ label: "", data: dataX }
|
||||
]
|
||||
},
|
||||
});
|
||||
chart.setWidth(1920).setHeight(1080).setBackgroundColor("transparent");
|
||||
const url = chart.getUrl();
|
||||
chart.toFile("test.png");
|
||||
return res.status(200).send(new ServerResponse(true, url));
|
||||
}
|
||||
|
||||
private static getEstimated(project: any, type: string) {
|
||||
// if (project.estimated_man_days === 0 || project.estimated_working_days === 0) {
|
||||
// return (parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) / int(project.hours_per_day)).toFixed(2)
|
||||
// }
|
||||
|
||||
switch (type) {
|
||||
case IToggleOptions.MAN_DAYS:
|
||||
@@ -445,7 +580,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
const { type } = req.body;
|
||||
const { type, billable } = req.body;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
@@ -458,6 +593,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
@@ -471,8 +608,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
WHERE project_id = p.id) AS estimated,
|
||||
color_code
|
||||
FROM projects p
|
||||
LEFT JOIN tasks t ON t.project_id = p.id
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = t.id
|
||||
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
@@ -491,7 +628,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
project.estimated_working_days = project.estimated_working_days ?? 0;
|
||||
project.hours_per_day = project.hours_per_day ?? 0;
|
||||
|
||||
if (project.value > 0 || project.estimated_value > 0 ) {
|
||||
if (project.value > 0 || project.estimated_value > 0) {
|
||||
data.push(project);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import db from "../../config/db";
|
||||
import moment from "moment-timezone";
|
||||
import { DATE_RANGES } from "../../shared/constants";
|
||||
|
||||
export default abstract class ReportingControllerBaseWithTimezone extends WorklenzControllerBase {
|
||||
|
||||
/**
|
||||
* Get the user's timezone from the database or request
|
||||
* @param userId - The user ID
|
||||
* @returns The user's timezone or 'UTC' as default
|
||||
*/
|
||||
protected static async getUserTimezone(userId: string): Promise<string> {
|
||||
const q = `SELECT tz.name as timezone
|
||||
FROM users u
|
||||
JOIN timezones tz ON u.timezone_id = tz.id
|
||||
WHERE u.id = $1`;
|
||||
const result = await db.query(q, [userId]);
|
||||
return result.rows[0]?.timezone || "UTC";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate date range clause with timezone support
|
||||
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
|
||||
* @param dateRange - Array of date strings
|
||||
* @param userTimezone - User's timezone (e.g., 'America/New_York')
|
||||
* @returns SQL clause for date filtering
|
||||
*/
|
||||
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
|
||||
// For custom date ranges
|
||||
if (dateRange.length === 2) {
|
||||
try {
|
||||
// Handle different date formats that might come from frontend
|
||||
let startDate, endDate;
|
||||
|
||||
// Try to parse the date - it might be a full JS Date string or ISO string
|
||||
if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) {
|
||||
// Parse JavaScript Date toString() format
|
||||
startDate = moment(new Date(dateRange[0]));
|
||||
endDate = moment(new Date(dateRange[1]));
|
||||
} else {
|
||||
// Parse ISO format or other formats
|
||||
startDate = moment(dateRange[0]);
|
||||
endDate = moment(dateRange[1]);
|
||||
}
|
||||
|
||||
// Convert to user's timezone and get start/end of day
|
||||
const start = startDate.tz(userTimezone).startOf("day");
|
||||
const end = endDate.tz(userTimezone).endOf("day");
|
||||
|
||||
// Convert to UTC for database comparison
|
||||
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
if (start.isSame(end, "day")) {
|
||||
// Single day selection
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing date range:", error, { dateRange, userTimezone });
|
||||
// Fallback to current date if parsing fails
|
||||
const now = moment.tz(userTimezone);
|
||||
const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
}
|
||||
|
||||
// For predefined ranges, calculate based on user's timezone
|
||||
const now = moment.tz(userTimezone);
|
||||
let startDate, endDate;
|
||||
|
||||
switch (key) {
|
||||
case DATE_RANGES.YESTERDAY:
|
||||
startDate = now.clone().subtract(1, "day").startOf("day");
|
||||
endDate = now.clone().subtract(1, "day").endOf("day");
|
||||
break;
|
||||
case DATE_RANGES.LAST_WEEK:
|
||||
startDate = now.clone().subtract(1, "week").startOf("week");
|
||||
endDate = now.clone().subtract(1, "week").endOf("week");
|
||||
break;
|
||||
case DATE_RANGES.LAST_MONTH:
|
||||
startDate = now.clone().subtract(1, "month").startOf("month");
|
||||
endDate = now.clone().subtract(1, "month").endOf("month");
|
||||
break;
|
||||
case DATE_RANGES.LAST_QUARTER:
|
||||
startDate = now.clone().subtract(3, "months").startOf("day");
|
||||
endDate = now.clone().endOf("day");
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format dates for display in user's timezone
|
||||
* @param date - Date to format
|
||||
* @param userTimezone - User's timezone
|
||||
* @param format - Moment format string
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
|
||||
return moment.tz(date, userTimezone).format(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get working days count between two dates in user's timezone
|
||||
* @param startDate - Start date
|
||||
* @param endDate - End date
|
||||
* @param userTimezone - User's timezone
|
||||
* @returns Number of working days
|
||||
*/
|
||||
protected static getWorkingDaysInTimezone(startDate: string, endDate: string, userTimezone: string): number {
|
||||
const start = moment.tz(startDate, userTimezone);
|
||||
const end = moment.tz(endDate, userTimezone);
|
||||
let workingDays = 0;
|
||||
|
||||
const current = start.clone();
|
||||
while (current.isSameOrBefore(end, "day")) {
|
||||
// Monday = 1, Friday = 5
|
||||
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
||||
workingDays++;
|
||||
}
|
||||
current.add(1, "day");
|
||||
}
|
||||
|
||||
return workingDays;
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,23 @@ export default abstract class ReportingControllerBase extends WorklenzController
|
||||
return "";
|
||||
}
|
||||
|
||||
protected static buildBillableQuery(selectedStatuses: { billable: boolean; nonBillable: boolean }): string {
|
||||
const { billable, nonBillable } = selectedStatuses;
|
||||
|
||||
if (billable && nonBillable) {
|
||||
// Both are enabled, no need to filter
|
||||
return "";
|
||||
} else if (billable) {
|
||||
// Only billable is enabled
|
||||
return " AND tasks.billable IS TRUE";
|
||||
} else if (nonBillable) {
|
||||
// Only non-billable is enabled
|
||||
return " AND tasks.billable IS FALSE";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
protected static formatEndDate(endDate: string) {
|
||||
const end = moment(endDate).format("YYYY-MM-DD");
|
||||
const fEndDate = moment(end);
|
||||
@@ -173,6 +190,9 @@ export default abstract class ReportingControllerBase extends WorklenzController
|
||||
(SELECT color_code
|
||||
FROM sys_project_healths
|
||||
WHERE sys_project_healths.id = p.health_id) AS health_color,
|
||||
(SELECT name
|
||||
FROM sys_project_healths
|
||||
WHERE sys_project_healths.id = p.health_id) AS health_name,
|
||||
|
||||
pc.id AS category_id,
|
||||
pc.name AS category_name,
|
||||
|
||||
@@ -6,10 +6,69 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
|
||||
import { formatDuration, getColor, int } from "../../shared/utils";
|
||||
import ReportingControllerBase from "./reporting-controller-base";
|
||||
import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone";
|
||||
import Excel from "exceljs";
|
||||
|
||||
export default class ReportingMembersController extends ReportingControllerBase {
|
||||
export default class ReportingMembersController extends ReportingControllerBaseWithTimezone {
|
||||
|
||||
protected static getPercentage(n: number, total: number) {
|
||||
return +(n ? (n / total) * 100 : 0).toFixed();
|
||||
}
|
||||
|
||||
protected static getCurrentTeamId(req: IWorkLenzRequest): string | null {
|
||||
return req.user?.team_id ?? null;
|
||||
}
|
||||
|
||||
public static convertMinutesToHoursAndMinutes(totalMinutes: number) {
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
public static convertSecondsToHoursAndMinutes(seconds: number) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
protected static formatEndDate(endDate: string) {
|
||||
const end = moment(endDate).format("YYYY-MM-DD");
|
||||
const fEndDate = moment(end);
|
||||
return fEndDate;
|
||||
}
|
||||
|
||||
protected static formatCurrentDate() {
|
||||
const current = moment().format("YYYY-MM-DD");
|
||||
const fCurrentDate = moment(current);
|
||||
return fCurrentDate;
|
||||
}
|
||||
|
||||
protected static getDaysLeft(endDate: string): number | null {
|
||||
if (!endDate) return null;
|
||||
|
||||
const fCurrentDate = this.formatCurrentDate();
|
||||
const fEndDate = this.formatEndDate(endDate);
|
||||
|
||||
return fEndDate.diff(fCurrentDate, "days");
|
||||
}
|
||||
|
||||
protected static isOverdue(endDate: string): boolean {
|
||||
if (!endDate) return false;
|
||||
|
||||
const fCurrentDate = this.formatCurrentDate();
|
||||
const fEndDate = this.formatEndDate(endDate);
|
||||
|
||||
return fEndDate.isBefore(fCurrentDate);
|
||||
}
|
||||
|
||||
protected static isToday(endDate: string): boolean {
|
||||
if (!endDate) return false;
|
||||
|
||||
const fCurrentDate = this.formatCurrentDate();
|
||||
const fEndDate = this.formatEndDate(endDate);
|
||||
|
||||
return fEndDate.isSame(fCurrentDate);
|
||||
}
|
||||
|
||||
private static async getMembers(
|
||||
teamId: string, searchQuery = "",
|
||||
@@ -487,7 +546,9 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
dateRange = date_range.split(",");
|
||||
}
|
||||
|
||||
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl");
|
||||
// Get user timezone for proper date filtering
|
||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
|
||||
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
||||
const memberName = (req.query.member_name as string)?.trim() || null;
|
||||
|
||||
@@ -862,7 +923,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
}
|
||||
|
||||
|
||||
private static async memberTimeLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived: boolean, userId: string) {
|
||||
private static async memberTimeLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived: boolean, userId: string, billableQuery = "") {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
@@ -884,7 +945,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
FROM task_work_log twl
|
||||
WHERE twl.user_id = tmiv.user_id
|
||||
${durationClause}
|
||||
AND task_id IN (SELECT id FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1) ${archivedClause} )
|
||||
AND task_id IN (SELECT id FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1) ${archivedClause} ${billableQuery})
|
||||
ORDER BY twl.updated_at DESC) tl) AS time_logs
|
||||
${minMaxDateClause}
|
||||
FROM team_member_info_view tmiv
|
||||
@@ -1017,14 +1078,35 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
|
||||
}
|
||||
|
||||
protected static buildBillableQuery(selectedStatuses: { billable: boolean; nonBillable: boolean }): string {
|
||||
const { billable, nonBillable } = selectedStatuses;
|
||||
|
||||
if (billable && nonBillable) {
|
||||
// Both are enabled, no need to filter
|
||||
return "";
|
||||
} else if (billable) {
|
||||
// Only billable is enabled
|
||||
return " AND tasks.billable IS TRUE";
|
||||
} else if (nonBillable) {
|
||||
// Only non-billable is enabled
|
||||
return " AND tasks.billable IS FALSE";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { team_member_id, team_id, duration, date_range, archived } = req.body;
|
||||
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
|
||||
|
||||
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl");
|
||||
// Get user timezone for proper date filtering
|
||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
|
||||
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
||||
|
||||
const logGroups = await this.memberTimeLogsData(durationClause, minMaxDateClause, team_id, team_member_id, archived, req.user?.id as string);
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
const logGroups = await this.memberTimeLogsData(durationClause, minMaxDateClause, team_id, team_member_id, archived, req.user?.id as string, billableQuery);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, logGroups));
|
||||
}
|
||||
@@ -1049,6 +1131,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
const completedDurationClasue = this.completedDurationFilter(duration as string, dateRange);
|
||||
const overdueClauseByDate = this.getActivityLogsOverdue(duration as string, dateRange);
|
||||
const taskSelectorClause = this.getTaskSelectorClause();
|
||||
const durationFilter = this.memberTasksDurationFilter(duration as string, dateRange);
|
||||
|
||||
const q = `
|
||||
SELECT name AS team_member_name,
|
||||
@@ -1059,6 +1142,12 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${assignClause} ${archivedClause}) assigned) AS assigned,
|
||||
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(assigned))), '[]')
|
||||
FROM (${taskSelectorClause}
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${durationFilter} ${assignClause} ${archivedClause}) assigned) AS total,
|
||||
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(completed))), '[]')
|
||||
FROM (${taskSelectorClause}
|
||||
FROM tasks t
|
||||
@@ -1095,6 +1184,11 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
const body = {
|
||||
team_member_name: data.team_member_name,
|
||||
groups: [
|
||||
{
|
||||
name: "Total Tasks",
|
||||
color_code: "#7590c9",
|
||||
tasks: data.total ? data.total : 0
|
||||
},
|
||||
{
|
||||
name: "Tasks Assigned",
|
||||
color_code: "#7590c9",
|
||||
@@ -1114,7 +1208,7 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
name: "Tasks Ongoing",
|
||||
color_code: "#7cb5ec",
|
||||
tasks: data.ongoing ? data.ongoing : 0
|
||||
}
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1199,8 +1293,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
|
||||
row.actual_time = int(row.actual_time);
|
||||
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
|
||||
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
|
||||
row.days_left = ReportingControllerBase.getDaysLeft(row.end_date);
|
||||
row.is_overdue = ReportingControllerBase.isOverdue(row.end_date);
|
||||
row.days_left = this.getDaysLeft(row.end_date);
|
||||
row.is_overdue = this.isOverdue(row.end_date);
|
||||
if (row.days_left && row.is_overdue) {
|
||||
row.days_left = row.days_left.toString().replace(/-/g, "");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import db from "../../config/db";
|
||||
import { ParsedQs } from "qs";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants";
|
||||
import { getColor } from "../../shared/utils";
|
||||
import moment, { Moment } from "moment";
|
||||
import momentTime from "moment-timezone";
|
||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||
|
||||
interface IDateUnions {
|
||||
date_union: {
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
},
|
||||
logs_date_union: {
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
},
|
||||
allocated_date_union: {
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
interface IDatesPair {
|
||||
start_date: string | null,
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
export default class ScheduleControllerV2 extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getSettings(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// get organization working days
|
||||
const getDataq = `SELECT organization_id, array_agg(initcap(day)) AS working_days
|
||||
FROM (
|
||||
SELECT organization_id,
|
||||
unnest(ARRAY['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']) AS day,
|
||||
unnest(ARRAY[monday, tuesday, wednesday, thursday, friday, saturday, sunday]) AS is_working
|
||||
FROM public.organization_working_days
|
||||
WHERE organization_id IN (
|
||||
SELECT id FROM organizations
|
||||
WHERE user_id = $1
|
||||
)
|
||||
) t
|
||||
WHERE t.is_working
|
||||
GROUP BY organization_id LIMIT 1;`;
|
||||
|
||||
const workingDaysResults = await db.query(getDataq, [req.user?.owner_id]);
|
||||
const [workingDays] = workingDaysResults.rows;
|
||||
|
||||
// get organization working hours
|
||||
const getDataHoursq = `SELECT working_hours FROM organizations WHERE user_id = $1 GROUP BY id LIMIT 1;`;
|
||||
|
||||
const workingHoursResults = await db.query(getDataHoursq, [req.user?.owner_id]);
|
||||
|
||||
const [workingHours] = workingHoursResults.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { workingDays: workingDays?.working_days, workingHours: workingHours?.working_hours }));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateSettings(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { workingDays, workingHours } = req.body;
|
||||
|
||||
// Days of the week
|
||||
const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
|
||||
// Generate the SET clause dynamically
|
||||
const setClause = days
|
||||
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
|
||||
.join(", ");
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE public.organization_working_days
|
||||
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE organization_id IN (
|
||||
SELECT organization_id FROM organizations
|
||||
WHERE user_id = $1
|
||||
);
|
||||
`;
|
||||
|
||||
await db.query(updateQuery, [req.user?.owner_id]);
|
||||
|
||||
const getDataHoursq = `UPDATE organizations SET working_hours = $1 WHERE user_id = $2;`;
|
||||
|
||||
await db.query(getDataHoursq, [workingHours, req.user?.owner_id]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getDates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const { date, type } = req.params;
|
||||
|
||||
if (type === "week") {
|
||||
const getDataq = `WITH input_date AS (
|
||||
SELECT
|
||||
$1::DATE AS given_date,
|
||||
(SELECT id FROM organizations WHERE user_id=$2 LIMIT 1) AS organization_id
|
||||
),
|
||||
week_range AS (
|
||||
SELECT
|
||||
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7)::DATE AS start_date, -- Current week start date
|
||||
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7 + 6)::DATE AS end_date, -- Current week end date
|
||||
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7 + 7)::DATE AS next_week_start, -- Next week start date
|
||||
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7 + 13)::DATE AS next_week_end, -- Next week end date
|
||||
TO_CHAR(given_date, 'Mon YYYY') AS month_year, -- Format the month as 'Jan 2025'
|
||||
EXTRACT(DAY FROM given_date) AS day_number, -- Extract the day from the date
|
||||
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7)::DATE AS chart_start, -- First week start date
|
||||
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7 + 13)::DATE AS chart_end, -- Second week end date
|
||||
CURRENT_DATE::DATE AS today,
|
||||
organization_id
|
||||
FROM input_date
|
||||
),
|
||||
org_working_days AS (
|
||||
SELECT
|
||||
organization_id,
|
||||
monday, tuesday, wednesday, thursday, friday, saturday, sunday
|
||||
FROM organization_working_days
|
||||
WHERE organization_id = (SELECT organization_id FROM week_range)
|
||||
),
|
||||
days AS (
|
||||
SELECT
|
||||
generate_series((SELECT start_date FROM week_range), (SELECT next_week_end FROM week_range), '1 day'::INTERVAL)::DATE AS date
|
||||
),
|
||||
formatted_days AS (
|
||||
SELECT
|
||||
d.date,
|
||||
TO_CHAR(d.date, 'Dy') AS day_name,
|
||||
EXTRACT(DAY FROM d.date) AS day,
|
||||
TO_CHAR(d.date, 'Mon YYYY') AS month, -- Format the month as 'Jan 2025'
|
||||
CASE
|
||||
WHEN EXTRACT(DOW FROM d.date) = 0 THEN (SELECT sunday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 1 THEN (SELECT monday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 2 THEN (SELECT tuesday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 3 THEN (SELECT wednesday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 4 THEN (SELECT thursday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 5 THEN (SELECT friday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 6 THEN (SELECT saturday FROM org_working_days)
|
||||
END AS is_weekend,
|
||||
CASE WHEN d.date = (SELECT today FROM week_range) THEN TRUE ELSE FALSE END AS is_today
|
||||
FROM days d
|
||||
),
|
||||
aggregated_days AS (
|
||||
SELECT
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'day', day,
|
||||
'month', month, -- Include formatted month
|
||||
'name', day_name,
|
||||
'isWeekend', NOT is_weekend,
|
||||
'isToday', is_today
|
||||
) ORDER BY date
|
||||
) AS days_json
|
||||
FROM formatted_days
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'date_data', jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'month', (SELECT month_year FROM week_range), -- Formatted month-year (e.g., Jan 2025)
|
||||
'day', (SELECT day_number FROM week_range), -- Dynamic day number
|
||||
'weeks', '[]', -- Empty weeks array for now
|
||||
'days', (SELECT days_json FROM aggregated_days) -- Aggregated days data
|
||||
)
|
||||
),
|
||||
'chart_start', (SELECT chart_start FROM week_range), -- First week start date
|
||||
'chart_end', (SELECT chart_end FROM week_range) -- Second week end date
|
||||
) AS result_json;`;
|
||||
|
||||
const results = await db.query(getDataq, [date, req.user?.owner_id]);
|
||||
const [data] = results.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data.result_json));
|
||||
} else if (type === "month") {
|
||||
|
||||
const getDataq = `WITH params AS (
|
||||
SELECT
|
||||
DATE_TRUNC('month', $1::DATE)::DATE AS start_date, -- First day of the month
|
||||
(DATE_TRUNC('month', $1::DATE) + INTERVAL '1 month' - INTERVAL '1 day')::DATE AS end_date, -- Last day of the month
|
||||
CURRENT_DATE::DATE AS today,
|
||||
(SELECT id FROM organizations WHERE user_id = $2 LIMIT 1) AS org_id
|
||||
),
|
||||
days AS (
|
||||
SELECT
|
||||
generate_series(
|
||||
(SELECT start_date FROM params),
|
||||
(SELECT end_date FROM params),
|
||||
'1 day'::INTERVAL
|
||||
)::DATE AS date
|
||||
),
|
||||
org_working_days AS (
|
||||
SELECT
|
||||
monday, tuesday, wednesday, thursday, friday, saturday, sunday
|
||||
FROM organization_working_days
|
||||
WHERE organization_id = (SELECT org_id FROM params)
|
||||
LIMIT 1
|
||||
),
|
||||
formatted_days AS (
|
||||
SELECT
|
||||
d.date,
|
||||
TO_CHAR(d.date, 'Dy') AS day_name,
|
||||
EXTRACT(DAY FROM d.date) AS day,
|
||||
-- Dynamically check if the day is a weekend based on the organization's settings
|
||||
CASE
|
||||
WHEN EXTRACT(DOW FROM d.date) = 0 THEN NOT (SELECT sunday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 1 THEN NOT (SELECT monday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 2 THEN NOT (SELECT tuesday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 3 THEN NOT (SELECT wednesday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 4 THEN NOT (SELECT thursday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 5 THEN NOT (SELECT friday FROM org_working_days)
|
||||
WHEN EXTRACT(DOW FROM d.date) = 6 THEN NOT (SELECT saturday FROM org_working_days)
|
||||
END AS is_weekend,
|
||||
CASE WHEN d.date = (SELECT today FROM params) THEN TRUE ELSE FALSE END AS is_today
|
||||
FROM days d
|
||||
),
|
||||
grouped_by_month AS (
|
||||
SELECT
|
||||
TO_CHAR(date, 'Mon YYYY') AS month_name,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'day', day,
|
||||
'name', day_name,
|
||||
'isWeekend', is_weekend,
|
||||
'isToday', is_today
|
||||
) ORDER BY date
|
||||
) AS days
|
||||
FROM formatted_days
|
||||
GROUP BY month_name
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'date_data', jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'month', month_name,
|
||||
'weeks', '[]'::JSONB, -- Placeholder for weeks data
|
||||
'days', days
|
||||
) ORDER BY month_name
|
||||
),
|
||||
'chart_start', (SELECT start_date FROM params),
|
||||
'chart_end', (SELECT end_date FROM params)
|
||||
) AS result_json
|
||||
FROM grouped_by_month;`;
|
||||
|
||||
const results = await db.query(getDataq, [date, req.user?.owner_id]);
|
||||
const [data] = results.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data.result_json));
|
||||
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOrganizationMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const getDataq = `SELECT DISTINCT ON (users.email)
|
||||
team_members.id AS team_member_id,
|
||||
users.id AS id,
|
||||
users.name AS name,
|
||||
users.email AS email,
|
||||
'[]'::JSONB AS projects
|
||||
FROM team_members
|
||||
INNER JOIN users ON users.id = team_members.user_id
|
||||
WHERE team_members.team_id IN (
|
||||
SELECT id FROM teams
|
||||
WHERE organization_id IN (
|
||||
SELECT id FROM organizations
|
||||
WHERE user_id = $1
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
ORDER BY users.email ASC, users.name ASC;`;
|
||||
|
||||
const results = await db.query(getDataq, [req.user?.owner_id]);
|
||||
return res.status(200).send(new ServerResponse(true, results.rows));
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOrganizationMemberProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
const getDataq = `WITH project_dates AS (
|
||||
SELECT
|
||||
pm.project_id,
|
||||
MIN(pm.allocated_from) AS start_date,
|
||||
MAX(pm.allocated_to) AS end_date,
|
||||
MAX(pm.seconds_per_day) / 3600 AS hours_per_day, -- Convert max seconds per day to hours per day
|
||||
(
|
||||
-- Calculate total working days between start and end dates
|
||||
SELECT COUNT(*)
|
||||
FROM generate_series(MIN(pm.allocated_from), MAX(pm.allocated_to), '1 day'::interval) AS day
|
||||
JOIN public.organization_working_days owd ON owd.organization_id = t.organization_id
|
||||
WHERE
|
||||
(EXTRACT(ISODOW FROM day) = 1 AND owd.monday = true) OR
|
||||
(EXTRACT(ISODOW FROM day) = 2 AND owd.tuesday = true) OR
|
||||
(EXTRACT(ISODOW FROM day) = 3 AND owd.wednesday = true) OR
|
||||
(EXTRACT(ISODOW FROM day) = 4 AND owd.thursday = true) OR
|
||||
(EXTRACT(ISODOW FROM day) = 5 AND owd.friday = true) OR
|
||||
(EXTRACT(ISODOW FROM day) = 6 AND owd.saturday = true) OR
|
||||
(EXTRACT(ISODOW FROM day) = 7 AND owd.sunday = true)
|
||||
) * (MAX(pm.seconds_per_day) / 3600) AS total_hours -- Multiply by hours per day
|
||||
FROM public.project_member_allocations pm
|
||||
JOIN public.projects p ON pm.project_id = p.id
|
||||
JOIN public.teams t ON p.team_id = t.id
|
||||
GROUP BY pm.project_id, t.organization_id
|
||||
),
|
||||
projects_with_offsets AS (
|
||||
SELECT
|
||||
p.name AS project_name,
|
||||
p.id AS project_id,
|
||||
COALESCE(pd.hours_per_day, 0) AS hours_per_day, -- Default to 8 if not available in project_member_allocations
|
||||
COALESCE(pd.total_hours, 0) AS total_hours, -- Calculated total hours based on working days
|
||||
pd.start_date,
|
||||
pd.end_date,
|
||||
p.team_id,
|
||||
tm.user_id,
|
||||
-- Calculate indicator_offset dynamically: days difference from earliest project start date * 75px
|
||||
COALESCE(
|
||||
(DATE_PART('day', pd.start_date - MIN(pd.start_date) OVER ())) * 75,
|
||||
0
|
||||
) AS indicator_offset,
|
||||
-- Calculate indicator_width as the number of days * 75 pixels per day
|
||||
COALESCE((DATE_PART('day', pd.end_date - pd.start_date) + 1) * 75, 75) AS indicator_width, -- Fallback to 75 if no dates exist
|
||||
75 AS min_width -- 75px minimum width for a 1-day project
|
||||
FROM public.projects p
|
||||
LEFT JOIN project_dates pd ON p.id = pd.project_id
|
||||
JOIN public.team_members tm ON tm.team_id = p.team_id
|
||||
JOIN public.teams t ON p.team_id = t.id
|
||||
WHERE tm.user_id = $2
|
||||
AND tm.team_id = $1
|
||||
ORDER BY pd.start_date, pd.end_date -- Order by start and end date
|
||||
)
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'name', project_name,
|
||||
'id', project_id,
|
||||
'hours_per_day', hours_per_day,
|
||||
'total_hours', total_hours,
|
||||
'date_union', jsonb_build_object(
|
||||
'start', start_date::DATE,
|
||||
'end', end_date::DATE
|
||||
),
|
||||
'indicator_offset', indicator_offset,
|
||||
'indicator_width', indicator_width,
|
||||
'tasks', '[]'::jsonb, -- Empty tasks array for now,
|
||||
'default_values', jsonb_build_object(
|
||||
'allocated_from', start_date::DATE,
|
||||
'allocated_to', end_date::DATE,
|
||||
'seconds_per_day', hours_per_day,
|
||||
'total_seconds', total_hours
|
||||
)
|
||||
)) AS projects
|
||||
FROM projects_with_offsets;`;
|
||||
|
||||
const results = await db.query(getDataq, [req.user?.team_id, id]);
|
||||
const [data] = results.rows;
|
||||
return res.status(200).send(new ServerResponse(true, { projects: data.projects, id }));
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createSchedule(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const { allocated_from, allocated_to, project_id, team_member_id, seconds_per_day } = req.body;
|
||||
|
||||
const fromFormat = moment(allocated_from).format("YYYY-MM-DD");
|
||||
const toFormat = moment(allocated_to).format("YYYY-MM-DD");
|
||||
|
||||
const getDataq1 = `
|
||||
SELECT id
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = $1
|
||||
AND team_member_id = $2
|
||||
AND (
|
||||
-- Case 1: The given range starts inside an existing range
|
||||
($3 BETWEEN allocated_from AND allocated_to)
|
||||
OR
|
||||
-- Case 2: The given range ends inside an existing range
|
||||
($4 BETWEEN allocated_from AND allocated_to)
|
||||
OR
|
||||
-- Case 3: The given range fully covers an existing range
|
||||
(allocated_from BETWEEN $3 AND $4 AND allocated_to BETWEEN $3 AND $4)
|
||||
OR
|
||||
-- Case 4: The existing range fully covers the given range
|
||||
(allocated_from <= $3 AND allocated_to >= $4)
|
||||
);`;
|
||||
|
||||
const results1 = await db.query(getDataq1, [project_id, team_member_id, fromFormat, toFormat]);
|
||||
|
||||
const [data] = results1.rows;
|
||||
if (data) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Allocation already exists!"));
|
||||
}
|
||||
|
||||
const getDataq = `INSERT INTO public.project_member_allocations(
|
||||
project_id, team_member_id, allocated_from, allocated_to, seconds_per_day)
|
||||
VALUES ($1, $2, $3, $4, $5);`;
|
||||
|
||||
const results = await db.query(getDataq, [project_id, team_member_id, allocated_from, allocated_to, Number(seconds_per_day) * 60 * 60]);
|
||||
return res.status(200).send(new ServerResponse(true, null, "Allocated successfully!"));
|
||||
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,83 @@ export default class ScheduleControllerV2 extends ScheduleTasksControllerBase {
|
||||
private static GLOBAL_START_DATE = moment().format("YYYY-MM-DD");
|
||||
private static GLOBAL_END_DATE = moment().format("YYYY-MM-DD");
|
||||
|
||||
// Migrate data
|
||||
@HandleExceptions()
|
||||
public static async migrate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const getDataq = `SELECT p.id,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT tmiv.team_member_id,
|
||||
tmiv.user_id,
|
||||
|
||||
LEAST(
|
||||
(SELECT MIN(LEAST(start_date, end_date)) AS start_date
|
||||
FROM tasks
|
||||
INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p.id
|
||||
AND ta.team_member_id = tmiv.team_member_id),
|
||||
(SELECT MIN(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS ll_start_date
|
||||
FROM task_work_log twl
|
||||
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE
|
||||
WHERE t.project_id = p.id
|
||||
AND twl.user_id = tmiv.user_id)
|
||||
) AS lowest_date,
|
||||
|
||||
GREATEST(
|
||||
(SELECT MAX(GREATEST(start_date, end_date)) AS end_date
|
||||
FROM tasks
|
||||
INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p.id
|
||||
AND ta.team_member_id = tmiv.team_member_id),
|
||||
(SELECT MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS ll_end_date
|
||||
FROM task_work_log twl
|
||||
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE
|
||||
WHERE t.project_id = p.id
|
||||
AND twl.user_id = tmiv.user_id)
|
||||
) AS greatest_date
|
||||
|
||||
FROM project_members pm
|
||||
INNER JOIN team_member_info_view tmiv
|
||||
ON pm.team_member_id = tmiv.team_member_id
|
||||
WHERE project_id = p.id) rec) AS members
|
||||
|
||||
FROM projects p
|
||||
WHERE team_id IS NOT NULL
|
||||
AND p.id NOT IN (SELECT project_id FROM archived_projects)`;
|
||||
|
||||
const projectMembersResults = await db.query(getDataq);
|
||||
|
||||
const projectMemberData = projectMembersResults.rows;
|
||||
|
||||
const arrayToInsert = [];
|
||||
|
||||
for (const data of projectMemberData) {
|
||||
if (data.members.length) {
|
||||
for (const member of data.members) {
|
||||
|
||||
const body = {
|
||||
project_id: data.id,
|
||||
team_member_id: member.team_member_id,
|
||||
allocated_from: member.lowest_date ? member.lowest_date : null,
|
||||
allocated_to: member.greatest_date ? member.greatest_date : null
|
||||
};
|
||||
|
||||
if (body.allocated_from && body.allocated_to) arrayToInsert.push(body);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const insertArray = JSON.stringify(arrayToInsert);
|
||||
|
||||
const insertFunctionCall = `SELECT migrate_member_allocations($1)`;
|
||||
await db.query(insertFunctionCall, [insertArray]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, ""));
|
||||
}
|
||||
|
||||
|
||||
private static async getFirstLastDates(teamId: string, userId: string) {
|
||||
const q = `SELECT MIN(LEAST(allocated_from, allocated_to)) AS start_date,
|
||||
MAX(GREATEST(allocated_from, allocated_to)) AS end_date,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
|
||||
import {PriorityColorCodes, PriorityColorCodesDark, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
|
||||
import {getColor} from "../shared/utils";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
@@ -33,6 +33,7 @@ export default class SubTasksController extends WorklenzControllerBase {
|
||||
(ts.name) AS status_name,
|
||||
TRUE AS is_sub_task,
|
||||
(tsc.color_code) AS status_color,
|
||||
(tsc.color_code_dark) AS status_color_dark,
|
||||
(SELECT name FROM projects WHERE id = t.project_id) AS project_name,
|
||||
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
|
||||
total_minutes,
|
||||
@@ -46,11 +47,12 @@ export default class SubTasksController extends WorklenzControllerBase {
|
||||
WHERE task_id = t.id
|
||||
ORDER BY name) r) AS labels,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT task_statuses.id, task_statuses.name, stsc.color_code
|
||||
FROM (SELECT task_statuses.id, task_statuses.name, stsc.color_code, stsc.color_code_dark
|
||||
FROM task_statuses
|
||||
INNER JOIN sys_task_status_categories stsc ON task_statuses.category_id = stsc.id
|
||||
WHERE project_id = t.project_id
|
||||
ORDER BY task_statuses.name) rec) AS statuses
|
||||
ORDER BY task_statuses.name) rec) AS statuses,
|
||||
t.completed_at
|
||||
FROM tasks t
|
||||
INNER JOIN task_statuses ts ON ts.id = t.status_id
|
||||
INNER JOIN task_priorities tp ON tp.id = t.priority_id
|
||||
@@ -62,6 +64,7 @@ export default class SubTasksController extends WorklenzControllerBase {
|
||||
|
||||
for (const task of result.rows) {
|
||||
task.priority_color = PriorityColorCodes[task.priority_value] || null;
|
||||
task.priority_color_dark = PriorityColorCodesDark[task.priority_value] || null;
|
||||
|
||||
task.time_spent = {hours: Math.floor(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
|
||||
task.time_spent_string = `${task.time_spent.hours}h ${task.time_spent.minutes}m`;
|
||||
@@ -72,6 +75,7 @@ export default class SubTasksController extends WorklenzControllerBase {
|
||||
task.labels = this.createTagList(task.labels, 2);
|
||||
|
||||
task.status_color = task.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
task.status_color_dark = task.status_color_dark + TASK_STATUS_COLOR_ALPHA;
|
||||
task.priority_color = task.priority_color + TASK_PRIORITY_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
|
||||
201
worklenz-backend/src/controllers/survey-controller.ts
Normal file
201
worklenz-backend/src/controllers/survey-controller.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { ISurveySubmissionRequest } from "../interfaces/survey";
|
||||
import db from "../config/db";
|
||||
|
||||
export default class SurveyController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getAccountSetupSurvey(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.description,
|
||||
s.survey_type,
|
||||
s.is_active,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', sq.id,
|
||||
'survey_id', sq.survey_id,
|
||||
'question_key', sq.question_key,
|
||||
'question_type', sq.question_type,
|
||||
'is_required', sq.is_required,
|
||||
'sort_order', sq.sort_order,
|
||||
'options', sq.options
|
||||
) ORDER BY sq.sort_order
|
||||
) FILTER (WHERE sq.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS questions
|
||||
FROM surveys s
|
||||
LEFT JOIN survey_questions sq ON s.id = sq.survey_id
|
||||
WHERE s.survey_type = 'account_setup' AND s.is_active = true
|
||||
GROUP BY s.id, s.name, s.description, s.survey_type, s.is_active
|
||||
LIMIT 1;
|
||||
`;
|
||||
|
||||
const result = await db.query(q);
|
||||
const [survey] = result.rows;
|
||||
|
||||
if (!survey) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Account setup survey not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, survey));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async submitSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const userId = req.user?.id;
|
||||
const body = req.body as ISurveySubmissionRequest;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
||||
}
|
||||
|
||||
if (!body.survey_id || !body.answers || !Array.isArray(body.answers)) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Invalid survey submission data"));
|
||||
}
|
||||
|
||||
// Check if user has already submitted a response for this survey
|
||||
const existingResponseQuery = `
|
||||
SELECT id FROM survey_responses
|
||||
WHERE user_id = $1 AND survey_id = $2;
|
||||
`;
|
||||
const existingResult = await db.query(existingResponseQuery, [userId, body.survey_id]);
|
||||
|
||||
let responseId: string;
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
// Update existing response
|
||||
responseId = existingResult.rows[0].id;
|
||||
|
||||
const updateResponseQuery = `
|
||||
UPDATE survey_responses
|
||||
SET is_completed = true, completed_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
`;
|
||||
await db.query(updateResponseQuery, [responseId]);
|
||||
|
||||
// Delete existing answers
|
||||
const deleteAnswersQuery = `DELETE FROM survey_answers WHERE response_id = $1;`;
|
||||
await db.query(deleteAnswersQuery, [responseId]);
|
||||
} else {
|
||||
// Create new response
|
||||
const createResponseQuery = `
|
||||
INSERT INTO survey_responses (survey_id, user_id, is_completed, completed_at)
|
||||
VALUES ($1, $2, true, NOW())
|
||||
RETURNING id;
|
||||
`;
|
||||
const responseResult = await db.query(createResponseQuery, [body.survey_id, userId]);
|
||||
responseId = responseResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Insert new answers
|
||||
if (body.answers.length > 0) {
|
||||
const answerValues: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
body.answers.forEach((answer, index) => {
|
||||
const baseIndex = index * 4;
|
||||
answerValues.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4})`);
|
||||
|
||||
params.push(
|
||||
responseId,
|
||||
answer.question_id,
|
||||
answer.answer_text || null,
|
||||
answer.answer_json ? JSON.stringify(answer.answer_json) : null
|
||||
);
|
||||
});
|
||||
|
||||
const insertAnswersQuery = `
|
||||
INSERT INTO survey_answers (response_id, question_id, answer_text, answer_json)
|
||||
VALUES ${answerValues.join(', ')};
|
||||
`;
|
||||
|
||||
await db.query(insertAnswersQuery, params);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { response_id: responseId }));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getUserSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const userId = req.user?.id;
|
||||
const surveyId = req.params.survey_id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT
|
||||
sr.id,
|
||||
sr.survey_id,
|
||||
sr.user_id,
|
||||
sr.is_completed,
|
||||
sr.started_at,
|
||||
sr.completed_at,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'question_id', sa.question_id,
|
||||
'answer_text', sa.answer_text,
|
||||
'answer_json', sa.answer_json
|
||||
)
|
||||
) FILTER (WHERE sa.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS answers
|
||||
FROM survey_responses sr
|
||||
LEFT JOIN survey_answers sa ON sr.id = sa.response_id
|
||||
WHERE sr.user_id = $1 AND sr.survey_id = $2
|
||||
GROUP BY sr.id, sr.survey_id, sr.user_id, sr.is_completed, sr.started_at, sr.completed_at;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [userId, surveyId]);
|
||||
const [response] = result.rows;
|
||||
|
||||
if (!response) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Survey response not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, response));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async checkAccountSetupSurveyStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM survey_responses sr
|
||||
INNER JOIN surveys s ON sr.survey_id = s.id
|
||||
WHERE sr.user_id = $1
|
||||
AND s.survey_type = 'account_setup'
|
||||
AND sr.is_completed = true
|
||||
) as is_completed,
|
||||
(
|
||||
SELECT sr.completed_at
|
||||
FROM survey_responses sr
|
||||
INNER JOIN surveys s ON sr.survey_id = s.id
|
||||
WHERE sr.user_id = $1
|
||||
AND s.survey_type = 'account_setup'
|
||||
AND sr.is_completed = true
|
||||
LIMIT 1
|
||||
) as completed_at;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [userId]);
|
||||
const status = result.rows[0] || { is_completed: false, completed_at: null };
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, status));
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { NotificationsService } from "../services/notifications/notifications.service";
|
||||
import { log_error } from "../shared/utils";
|
||||
import { HTML_TAG_REGEXP } from "../shared/constants";
|
||||
import { humanFileSize, log_error, megabytesToBytes } from "../shared/utils";
|
||||
import { HTML_TAG_REGEXP, S3_URL } from "../shared/constants";
|
||||
import { getBaseUrl } from "../cron_jobs/helpers";
|
||||
import { ICommentEmailNotification } from "../interfaces/comment-email-notification";
|
||||
import { sendTaskComment } from "../shared/email-notifications";
|
||||
import { getRootDir, uploadBase64, getKey, getTaskAttachmentKey, createPresignedUrlWithClient } from "../shared/s3";
|
||||
import { getFreePlanSettings, getUsedStorage } from "../shared/paddle-utils";
|
||||
|
||||
interface ITaskAssignee {
|
||||
team_member_id: string;
|
||||
@@ -99,11 +101,134 @@ export default class TaskCommentsController extends WorklenzControllerBase {
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
req.body.user_id = req.user?.id;
|
||||
req.body.team_id = req.user?.team_id;
|
||||
const {mentions} = req.body;
|
||||
const { mentions, attachments, task_id } = req.body;
|
||||
const url = `${S3_URL}/${getRootDir()}`;
|
||||
|
||||
let commentContent = req.body.content;
|
||||
if (mentions.length > 0) {
|
||||
commentContent = await this.replaceContent(commentContent, mentions);
|
||||
commentContent = this.replaceContent(commentContent, mentions);
|
||||
}
|
||||
|
||||
req.body.content = commentContent;
|
||||
|
||||
const q = `SELECT create_task_comment($1) AS comment;`;
|
||||
const result = await db.query(q, [JSON.stringify(req.body)]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const response = data.comment;
|
||||
|
||||
const commentId = response.id;
|
||||
|
||||
if (attachments.length !== 0) {
|
||||
for (const attachment of attachments) {
|
||||
const q = `
|
||||
INSERT INTO task_comment_attachments (name, type, size, task_id, comment_id, team_id, project_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, type, task_id, comment_id, created_at,
|
||||
CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS url;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [
|
||||
attachment.file_name,
|
||||
attachment.file_name.split(".").pop(),
|
||||
attachment.size,
|
||||
task_id,
|
||||
commentId,
|
||||
req.user?.team_id,
|
||||
attachment.project_id,
|
||||
url
|
||||
]);
|
||||
|
||||
const [data] = result.rows;
|
||||
|
||||
const s3Url = await uploadBase64(attachment.file, getTaskAttachmentKey(req.user?.team_id as string, attachment.project_id, task_id, commentId, data.id, data.type));
|
||||
|
||||
if (!data?.id || !s3Url)
|
||||
return res.status(200).send(new ServerResponse(false, null, "Attachment upload failed"));
|
||||
}
|
||||
}
|
||||
|
||||
const mentionMessage = `<b>${req.user?.name}</b> has mentioned you in a comment on <b>${response.task_name}</b> (${response.team_name})`;
|
||||
// const mentions = [...new Set(req.body.mentions || [])] as string[]; // remove duplicates
|
||||
|
||||
const assignees = await getAssignees(req.body.task_id);
|
||||
|
||||
const commentMessage = `<b>${req.user?.name}</b> added a comment on <b>${response.task_name}</b> (${response.team_name})`;
|
||||
for (const member of assignees || []) {
|
||||
if (member.user_id && member.user_id === req.user?.id) continue;
|
||||
|
||||
void NotificationsService.createNotification({
|
||||
userId: member.user_id,
|
||||
teamId: req.user?.team_id as string,
|
||||
socketId: member.socket_id,
|
||||
message: commentMessage,
|
||||
taskId: req.body.task_id,
|
||||
projectId: response.project_id
|
||||
});
|
||||
|
||||
if (member.email_notifications_enabled)
|
||||
await this.sendMail({
|
||||
message: commentMessage,
|
||||
receiverEmail: member.email,
|
||||
receiverName: member.name,
|
||||
content: req.body.content,
|
||||
commentId: response.id,
|
||||
projectId: response.project_id,
|
||||
taskId: req.body.task_id,
|
||||
teamName: response.team_name,
|
||||
projectName: response.project_name,
|
||||
taskName: response.task_name
|
||||
});
|
||||
}
|
||||
|
||||
const senderUserId = req.user?.id as string;
|
||||
|
||||
for (const mention of mentions) {
|
||||
if (mention) {
|
||||
const member = await this.getUserDataByTeamMemberId(senderUserId, mention.team_member_id, response.project_id);
|
||||
if (member) {
|
||||
|
||||
NotificationsService.sendNotification({
|
||||
team: member.team,
|
||||
receiver_socket_id: member.socket_id,
|
||||
message: mentionMessage,
|
||||
task_id: req.body.task_id,
|
||||
project_id: response.project_id,
|
||||
project: member.project,
|
||||
project_color: member.project_color,
|
||||
team_id: req.user?.team_id as string
|
||||
});
|
||||
|
||||
if (member.email_notifications_enabled)
|
||||
await this.sendMail({
|
||||
message: mentionMessage,
|
||||
receiverEmail: member.email,
|
||||
receiverName: member.user_name,
|
||||
content: req.body.content,
|
||||
commentId: response.id,
|
||||
projectId: response.project_id,
|
||||
taskId: req.body.task_id,
|
||||
teamName: response.team_name,
|
||||
projectName: response.project_name,
|
||||
taskName: response.task_name
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.comment));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
req.body.user_id = req.user?.id;
|
||||
req.body.team_id = req.user?.team_id;
|
||||
const { mentions, comment_id } = req.body;
|
||||
|
||||
let commentContent = req.body.content;
|
||||
if (mentions.length > 0) {
|
||||
commentContent = await this.replaceContent(commentContent, mentions);
|
||||
}
|
||||
|
||||
req.body.content = commentContent;
|
||||
@@ -210,46 +335,90 @@ export default class TaskCommentsController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getByTaskId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT task_comments.id,
|
||||
tc.text_content AS content,
|
||||
task_comments.user_id,
|
||||
task_comments.team_member_id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS member_name,
|
||||
u.avatar_url,
|
||||
task_comments.created_at,
|
||||
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (SELECT tmiv.name AS user_name,
|
||||
tmiv.email AS user_email
|
||||
FROM task_comment_mentions tcm
|
||||
LEFT JOIN team_member_info_view tmiv ON tcm.informed_by = tmiv.team_member_id
|
||||
WHERE tcm.comment_id = task_comments.id) rec) AS mentions
|
||||
FROM task_comments
|
||||
INNER JOIN task_comment_contents tc ON task_comments.id = tc.comment_id
|
||||
INNER JOIN team_members tm ON task_comments.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE task_comments.task_id = $1
|
||||
ORDER BY task_comments.created_at DESC;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id]); // task id
|
||||
const result = await TaskCommentsController.getTaskComments(req.params.id); // task id
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
private static async getTaskComments(taskId: string) {
|
||||
const url = `${S3_URL}/${getRootDir()}`;
|
||||
|
||||
const q = `SELECT task_comments.id,
|
||||
tc.text_content AS content,
|
||||
task_comments.user_id,
|
||||
task_comments.team_member_id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS member_name,
|
||||
u.avatar_url,
|
||||
task_comments.created_at,
|
||||
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (SELECT tmiv.name AS user_name,
|
||||
tmiv.email AS user_email
|
||||
FROM task_comment_mentions tcm
|
||||
LEFT JOIN team_member_info_view tmiv ON tcm.informed_by = tmiv.team_member_id
|
||||
WHERE tcm.comment_id = task_comments.id) rec) AS mentions,
|
||||
(SELECT JSON_BUILD_OBJECT(
|
||||
'likes',
|
||||
JSON_BUILD_OBJECT(
|
||||
'count', (SELECT COUNT(*)
|
||||
FROM task_comment_reactions tcr
|
||||
WHERE tcr.comment_id = task_comments.id
|
||||
AND reaction_type = 'like'),
|
||||
'liked_members', COALESCE(
|
||||
(SELECT JSON_AGG(tmiv.name)
|
||||
FROM task_comment_reactions tcr
|
||||
JOIN team_member_info_view tmiv ON tcr.team_member_id = tmiv.team_member_id
|
||||
WHERE tcr.comment_id = task_comments.id
|
||||
AND tcr.reaction_type = 'like'),
|
||||
'[]'::JSON
|
||||
),
|
||||
'liked_member_ids', COALESCE(
|
||||
(SELECT JSON_AGG(tmiv.team_member_id)
|
||||
FROM task_comment_reactions tcr
|
||||
JOIN team_member_info_view tmiv ON tcr.team_member_id = tmiv.team_member_id
|
||||
WHERE tcr.comment_id = task_comments.id
|
||||
AND tcr.reaction_type = 'like'),
|
||||
'[]'::JSON
|
||||
)
|
||||
)
|
||||
)) AS reactions,
|
||||
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (SELECT id, created_at, name, size, type, (CONCAT('/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type)) AS url
|
||||
FROM task_comment_attachments tca
|
||||
WHERE tca.comment_id = task_comments.id) rec) AS attachments
|
||||
FROM task_comments
|
||||
LEFT JOIN task_comment_contents tc ON task_comments.id = tc.comment_id
|
||||
INNER JOIN team_members tm ON task_comments.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE task_comments.task_id = $1
|
||||
ORDER BY task_comments.created_at;`;
|
||||
const result = await db.query(q, [taskId]); // task id
|
||||
|
||||
for (const comment of result.rows) {
|
||||
if (!comment.content) comment.content = "";
|
||||
comment.rawContent = await comment.content;
|
||||
comment.content = await comment.content.replace(/\n/g, "</br>");
|
||||
const {mentions} = comment;
|
||||
comment.edit = false;
|
||||
const { mentions } = comment;
|
||||
if (mentions.length > 0) {
|
||||
const placeHolders = comment.content.match(/{\d+}/g);
|
||||
if (placeHolders) {
|
||||
placeHolders.forEach((placeHolder: { match: (arg0: RegExp) => string[]; }) => {
|
||||
const index = parseInt(placeHolder.match(/\d+/)[0]);
|
||||
if (index >= 0 && index < comment.mentions.length) {
|
||||
comment.content = comment.content.replace(placeHolder, `<span class="mentions"> @${comment.mentions[index].user_name} </span>`);
|
||||
}
|
||||
const index = parseInt(placeHolder.match(/\d+/)[0]);
|
||||
if (index >= 0 && index < comment.mentions.length) {
|
||||
comment.rawContent = comment.rawContent.replace(placeHolder, `@${comment.mentions[index].user_name}`);
|
||||
comment.content = comment.content.replace(placeHolder, `<span class="mentions"> @${comment.mentions[index].user_name} </span>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of comment.attachments) {
|
||||
attachment.size = humanFileSize(attachment.size);
|
||||
attachment.url = url + attachment.url;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
return result;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -262,4 +431,186 @@ export default class TaskCommentsController extends WorklenzControllerBase {
|
||||
const result = await db.query(q, [req.params.id, req.params.taskId, req.user?.id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteAttachmentById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE
|
||||
FROM task_comment_attachments
|
||||
WHERE id = $1;`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
private static async checkIfAlreadyExists(commentId: string, teamMemberId: string | undefined, reaction_type: string) {
|
||||
if (!teamMemberId) return;
|
||||
try {
|
||||
const q = `SELECT EXISTS(SELECT 1 FROM task_comment_reactions WHERE comment_id = $1 AND team_member_id = $2 AND reaction_type = $3)`;
|
||||
const result = await db.query(q, [commentId, teamMemberId, reaction_type]);
|
||||
const [data] = result.rows;
|
||||
return data.exists;
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
private static async getTaskCommentData(commentId: string) {
|
||||
if (!commentId) return;
|
||||
try {
|
||||
const q = `SELECT tc.user_id,
|
||||
t.project_id,
|
||||
t.name AS task_name,
|
||||
(SELECT team_id FROM projects p WHERE p.id = t.project_id) AS team_id,
|
||||
(SELECT name FROM teams te WHERE id = (SELECT team_id FROM projects p WHERE p.id = t.project_id)) AS team_name,
|
||||
(SELECT u.socket_id FROM users u WHERE u.id = tc.user_id) AS socket_id,
|
||||
(SELECT name FROM team_member_info_view tmiv WHERE tmiv.team_member_id = tcr.team_member_id) AS reactor_name
|
||||
FROM task_comments tc
|
||||
LEFT JOIN tasks t ON t.id = tc.task_id
|
||||
LEFT JOIN task_comment_reactions tcr ON tc.id = tcr.comment_id
|
||||
WHERE tc.id = $1;`;
|
||||
const result = await db.query(q, [commentId]);
|
||||
const [data] = result.rows;
|
||||
return data;
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateReaction(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const { reaction_type, task_id } = req.query;
|
||||
|
||||
const exists = await this.checkIfAlreadyExists(id, req.user?.team_member_id, reaction_type as string);
|
||||
|
||||
if (exists) {
|
||||
const deleteQ = `DELETE FROM task_comment_reactions WHERE comment_id = $1 AND team_member_id = $2;`;
|
||||
await db.query(deleteQ, [id, req.user?.team_member_id]);
|
||||
} else {
|
||||
const q = `INSERT INTO task_comment_reactions (comment_id, user_id, team_member_id) VALUES ($1, $2, $3);`;
|
||||
await db.query(q, [id, req.user?.id, req.user?.team_member_id]);
|
||||
|
||||
const getTaskCommentData = await TaskCommentsController.getTaskCommentData(id);
|
||||
const commentMessage = `<b>${getTaskCommentData.reactor_name}</b> liked your comment on <b>${getTaskCommentData.task_name}</b> (${getTaskCommentData.team_name})`;
|
||||
|
||||
if (getTaskCommentData && getTaskCommentData.user_id !== req.user?.id) {
|
||||
void NotificationsService.createNotification({
|
||||
userId: getTaskCommentData.user_id,
|
||||
teamId: req.user?.team_id as string,
|
||||
socketId: getTaskCommentData.socket_id,
|
||||
message: commentMessage,
|
||||
taskId: req.body.task_id,
|
||||
projectId: getTaskCommentData.project_id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await TaskCommentsController.getTaskComments(task_id as string);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createAttachment(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
req.body.user_id = req.user?.id;
|
||||
req.body.team_id = req.user?.team_id;
|
||||
const { attachments, task_id } = req.body;
|
||||
|
||||
const q = `INSERT INTO task_comments (user_id, team_member_id, task_id)
|
||||
VALUES ($1, (SELECT id
|
||||
FROM team_members
|
||||
WHERE user_id = $1
|
||||
AND team_id = $2::UUID), $3)
|
||||
RETURNING id;`;
|
||||
const result = await db.query(q, [req.user?.id, req.user?.team_id, task_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const commentId = data.id;
|
||||
|
||||
const url = `${S3_URL}/${getRootDir()}`;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (req.user?.subscription_status === "free" && req.user?.owner_id) {
|
||||
const limits = await getFreePlanSettings();
|
||||
|
||||
const usedStorage = await getUsedStorage(req.user?.owner_id);
|
||||
if ((parseInt(usedStorage) + attachment.size) > megabytesToBytes(parseInt(limits.free_tier_storage))) {
|
||||
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot exceed ${limits.free_tier_storage}MB of storage.`));
|
||||
}
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO task_comment_attachments (name, type, size, task_id, comment_id, team_id, project_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, type, task_id, comment_id, created_at,
|
||||
CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS url;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [
|
||||
attachment.file_name,
|
||||
attachment.size,
|
||||
attachment.file_name.split(".").pop(),
|
||||
task_id,
|
||||
commentId,
|
||||
req.user?.team_id,
|
||||
attachment.project_id,
|
||||
url
|
||||
]);
|
||||
|
||||
const [data] = result.rows;
|
||||
|
||||
const s3Url = await uploadBase64(attachment.file, getTaskAttachmentKey(req.user?.team_id as string, attachment.project_id, task_id, commentId, data.id, data.type));
|
||||
|
||||
if (!data?.id || !s3Url)
|
||||
return res.status(200).send(new ServerResponse(false, null, "Attachment upload failed"));
|
||||
}
|
||||
|
||||
const assignees = await getAssignees(task_id);
|
||||
|
||||
const commentMessage = `<b>${req.user?.name}</b> added a new attachment as a comment on <b>${commentId.task_name}</b> (${commentId.team_name})`;
|
||||
|
||||
for (const member of assignees || []) {
|
||||
if (member.user_id && member.user_id === req.user?.id) continue;
|
||||
|
||||
void NotificationsService.createNotification({
|
||||
userId: member.user_id,
|
||||
teamId: req.user?.team_id as string,
|
||||
socketId: member.socket_id,
|
||||
message: commentMessage,
|
||||
taskId: task_id,
|
||||
projectId: commentId.project_id
|
||||
});
|
||||
|
||||
if (member.email_notifications_enabled)
|
||||
await this.sendMail({
|
||||
message: commentMessage,
|
||||
receiverEmail: member.email,
|
||||
receiverName: member.name,
|
||||
content: req.body.content,
|
||||
commentId: commentId.id,
|
||||
projectId: commentId.project_id,
|
||||
taskId: task_id,
|
||||
teamName: commentId.team_name,
|
||||
projectName: commentId.project_name,
|
||||
taskName: commentId.task_name
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, []));
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async download(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT CONCAT($2::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS key
|
||||
FROM task_comment_attachments
|
||||
WHERE id = $1;`;
|
||||
const result = await db.query(q, [req.query.id, getRootDir()]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data?.key) {
|
||||
const url = await createPresignedUrlWithClient(data.key, req.query.file as string);
|
||||
return res.status(200).send(new ServerResponse(true, url));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class TaskdependenciesController extends WorklenzControllerBase {
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"DEPENDENCY_EXISTS": `Task dependency already exists.`
|
||||
}
|
||||
})
|
||||
public static async saveTaskDependency(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {task_id, related_task_id, dependency_type } = req.body;
|
||||
const q = `SELECT insert_task_dependency($1, $2, $3);`;
|
||||
const result = await db.query(q, [task_id, related_task_id, dependency_type]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTaskDependencies(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
|
||||
const q = `SELECT
|
||||
td.id,
|
||||
t2.name AS task_name,
|
||||
td.dependency_type,
|
||||
CONCAT(p.key, '-', t2.task_no) AS task_key
|
||||
FROM
|
||||
task_dependencies td
|
||||
LEFT JOIN
|
||||
tasks t ON td.task_id = t.id
|
||||
LEFT JOIN
|
||||
tasks t2 ON td.related_task_id = t2.id
|
||||
LEFT JOIN
|
||||
projects p ON t.project_id = p.id
|
||||
WHERE
|
||||
td.task_id = $1;`;
|
||||
const result = await db.query(q, [id]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {id} = req.params;
|
||||
const q = `DELETE FROM task_dependencies WHERE id = $1;`;
|
||||
const result = await db.query(q, [id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,9 @@ export default class TaskListColumnsController extends WorklenzControllerBase {
|
||||
const q = `UPDATE project_task_list_cols
|
||||
SET pinned = $3
|
||||
WHERE project_id = $1
|
||||
AND key = $2;`;
|
||||
AND key = $2 RETURNING *;`;
|
||||
const result = await db.query(q, [req.params.id, req.body.key, !!req.body.pinned]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user