Compare commits
745
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18059e50fa | ||
|
|
852eb346ed | ||
|
|
b7e11dc112 | ||
|
|
35a373e70a | ||
|
|
02c0b721b9 | ||
|
|
29349845f8 | ||
|
|
28cb2cff64 | ||
|
|
9cb2eb3b8c | ||
|
|
6ff20b98f4 | ||
|
|
aa618dbaa2 | ||
|
|
6e3de84067 | ||
|
|
c6b19200a9 | ||
|
|
91932212e3 | ||
|
|
f81bb92beb | ||
|
|
1d1a242a72 | ||
|
|
ad4af64036 | ||
|
+5 |
2cc7827b0e | ||
|
|
ef6618743d | ||
|
|
3601a9adf1 | ||
|
|
0cc2367556 | ||
|
|
f7b201b0b5 | ||
|
|
82063cc9a1 | ||
|
|
4b2f89d6e8 | ||
|
|
5360cb511d | ||
|
|
704bbf2757 | ||
|
|
7fef683abf | ||
|
|
63df3d9c14 | ||
|
|
32d2fd51e5 | ||
|
|
016a966057 | ||
|
|
dc967cffbe | ||
|
|
4489cb8fde | ||
|
|
babd514c64 | ||
|
|
2e5cc11f29 | ||
|
|
84bc330fa8 | ||
|
|
4cb7e4a91a | ||
|
|
d1bd05a791 | ||
|
|
96a6350143 | ||
|
|
c8bea5b143 | ||
|
|
1cdd8ebc53 | ||
|
|
d78ed21a9f | ||
|
|
4e92b6edeb | ||
|
|
7b2f811bfe | ||
|
|
7b6d5d6de3 | ||
|
|
12b418b532 | ||
|
|
3e299f9511 | ||
|
|
1b9691ddaf | ||
|
|
a11ea3dd1e | ||
|
|
b21400c613 | ||
|
|
6c936472d2 | ||
|
|
3b823670ea | ||
|
|
6b10127d97 | ||
|
|
9cd806ff61 | ||
|
|
628b169f53 | ||
|
|
19d7764e9d | ||
|
|
30061c0d58 | ||
|
|
ee073cdf35 | ||
|
|
4ff5f5f3fa | ||
|
|
f905ae7502 | ||
|
|
a1c0daa1b5 | ||
|
|
b155d736d8 | ||
|
|
e42013bee1 | ||
|
|
fde1d671fb | ||
|
|
9ccf5f3723 | ||
|
|
91ee225f14 | ||
|
|
e6a0a0fc69 | ||
|
|
e98024b08e | ||
|
|
8d54d80dee | ||
|
|
751aa62935 | ||
|
|
f149650911 | ||
|
|
f901fba0fc | ||
|
|
cd50719b03 | ||
|
|
000324c02d | ||
|
|
cc6beedfb0 | ||
|
|
1d28215c5e | ||
|
|
045ea34580 | ||
|
|
3228d8d869 | ||
|
|
6b9115dfde | ||
|
|
6259b0b3cb | ||
|
|
f991669b8d | ||
|
|
b93568359a | ||
|
|
66b52bb19f | ||
|
|
b9c49b4567 | ||
|
|
29e1dcb43c | ||
|
|
3ab676794f | ||
|
|
877fdbf46a | ||
|
|
b05e1b4589 | ||
|
|
611f76bf68 | ||
|
|
9b886036ad | ||
|
|
c88ed8cae7 | ||
|
|
098b520129 | ||
|
|
eb4eebe6f9 | ||
|
|
16a7bb7bc5 | ||
|
|
ee9699e267 | ||
|
|
7813d3739f | ||
|
|
fa47456d5f | ||
|
|
dbaa0727ae | ||
|
|
42a4a38939 | ||
|
|
97bde083ca | ||
|
|
48bd12be32 | ||
|
|
0cbc8a3816 | ||
|
|
4e1048b221 | ||
|
|
cb78692347 | ||
|
|
3784421f02 | ||
|
|
e7535b9db3 | ||
|
|
bb4260c98b | ||
|
|
9d65b6f794 | ||
|
|
4874c6c2ee | ||
|
|
30a92a4d66 | ||
|
|
04ce054cf7 | ||
|
|
e901e8d189 | ||
|
|
0aa595ddac | ||
|
|
2115a045ec | ||
|
|
f67522dbbd | ||
|
|
dc5aef0dd4 | ||
|
|
e1d7631215 | ||
|
|
05e1ad0c91 | ||
|
|
cf392562da | ||
|
|
c8de8ca681 | ||
|
|
2c91c0e309 | ||
|
|
cef007d852 | ||
|
|
97b81d6f6b | ||
|
|
ac117a14eb | ||
|
|
f85f226982 | ||
|
|
f4b48e863b | ||
|
|
4dc22606d9 | ||
|
|
292073eb4a | ||
|
|
1878802213 | ||
|
|
9cfc74a74f | ||
|
|
c472bce54f | ||
|
|
a498641b61 | ||
|
|
ec58e59865 | ||
|
|
4f95cff70e | ||
|
|
fa66f2531f | ||
|
|
b40d448e07 | ||
|
|
21add18ad8 | ||
|
|
10fca86ddc | ||
|
|
c2fc8774df | ||
|
|
969b58f17d | ||
|
|
a0e25ff270 | ||
|
|
df30f5c703 | ||
|
|
fb59342de4 | ||
|
|
a84e8987cb | ||
|
|
c4eda5f21d | ||
|
|
e6da5c85e0 | ||
|
|
bfc6d6e1e9 | ||
|
|
10bf982028 | ||
|
|
8ee7b12c2b | ||
|
|
afe5fea6eb | ||
|
|
5854de3c23 | ||
|
|
967442892a | ||
|
|
202d248ae4 | ||
|
|
f667417d84 | ||
|
|
750e6315dc | ||
|
|
f117b2df32 | ||
|
|
9631f083b8 | ||
|
|
c8a52b78e1 | ||
|
|
fe4946a269 | ||
|
|
3de9c2bda0 | ||
|
|
cee30c53b8 | ||
|
|
f22b5d9daa | ||
|
|
88f8753329 | ||
|
|
fe02a97fa1 | ||
|
|
a9172fd832 | ||
|
|
96a659ae5a | ||
|
|
13739dabd8 | ||
|
|
22acfbae28 | ||
|
|
adf50b2419 | ||
|
|
35022a0478 | ||
|
|
2838053ba8 | ||
|
|
66397a6626 | ||
|
|
dfe997dce2 | ||
|
|
163c7ff791 | ||
|
|
bd5e14b488 | ||
|
|
8cd16ee3dc | ||
|
|
57564381cb | ||
|
|
0207109612 | ||
|
|
b84405c5eb | ||
|
|
2743b32536 | ||
|
|
a251422f7a | ||
|
|
d7c9bd6ed8 | ||
|
|
8b3ff0b789 | ||
|
|
94a537bfda | ||
|
|
64305ffee1 | ||
|
|
45698db0b4 | ||
|
|
14e14289f0 | ||
|
|
18650b1215 | ||
|
|
d2bbbf3f45 | ||
|
|
13cb99b7fe | ||
|
|
a1571d01bd | ||
|
|
bbd8af074e | ||
|
|
c9a47dd0ce | ||
|
|
1c9778e667 | ||
|
|
758143ef70 | ||
|
|
b5911a9a2d | ||
|
|
260f4e1b51 | ||
|
|
e6c36af3b4 | ||
|
|
2fb2458eee | ||
|
|
b340b0edbe | ||
|
|
f7fd1c4402 | ||
|
|
c316721051 | ||
|
|
80ac118242 | ||
|
|
efd15c5a3e | ||
|
|
7d02d1bb23 | ||
|
|
0d877e7be3 | ||
|
|
e4c47640fc | ||
|
|
89e7fca183 | ||
|
|
33b5bf1db8 | ||
|
|
f69c4a900e | ||
|
|
1da25cc85f | ||
|
|
3a65b372c5 | ||
|
|
9892d521fb | ||
|
|
3285b252c2 | ||
|
|
97af2fc0f9 | ||
|
|
5c508bce6d | ||
|
|
db84343581 | ||
|
|
2b44d09191 | ||
|
|
10d67aee86 | ||
|
|
b33bbb8af0 | ||
|
|
3fe9327724 | ||
|
|
179a547bd0 | ||
|
|
7ada02177d | ||
|
|
59b55e40b5 | ||
|
|
19563aa697 | ||
|
|
6410687fb6 | ||
|
|
4e4c8f5f49 | ||
|
|
416b17cf69 | ||
|
|
27cd7c8496 | ||
|
|
02321c33b2 | ||
|
|
c3dab3e4f8 | ||
|
|
0c182e2813 | ||
|
|
51a076212b | ||
|
|
27dce7374b | ||
|
|
52f2c18800 | ||
|
|
35a8918ea9 | ||
|
|
b39eab72e7 | ||
|
|
928b2477be | ||
|
|
dc13251bcc | ||
|
|
4843658dfb | ||
|
|
076afdc00e | ||
|
|
4404699fa6 | ||
|
|
52940ce254 | ||
|
|
cd6293d220 | ||
|
|
108f95723b | ||
|
|
f611c3d767 | ||
|
|
6091208f8a | ||
|
|
585a12fa48 | ||
|
|
bfc4f7b66c | ||
|
|
9dca13305f | ||
|
|
0ab9823c9a | ||
|
|
b3fd4f307f | ||
|
|
06ed0de456 | ||
|
|
79b6278883 | ||
|
|
673823cb97 | ||
|
|
15b26dff73 | ||
|
|
2303f2e9e1 | ||
|
|
056b821070 | ||
|
|
c90fded6b7 | ||
|
|
aa6f1a7f93 | ||
|
|
0cdd7c9605 | ||
|
|
f40a1c92ef | ||
|
|
739ef153f6 | ||
|
|
850ac8092f | ||
|
|
5b1eb48e6b | ||
|
|
da6fc9f2d5 | ||
|
|
20434856e9 | ||
|
|
7f891adff2 | ||
|
|
eb66fbe054 | ||
|
|
29122934a9 | ||
|
|
0825cce82c | ||
|
|
fd964131d1 | ||
|
|
77d9c15fed | ||
|
|
b21295f268 | ||
|
|
5769553659 | ||
|
|
d23d68bf2d | ||
|
|
d04c7f58a1 | ||
|
|
93e2426760 | ||
|
|
9f6c05f389 | ||
|
|
f8a1bd7b94 | ||
|
|
02967961f3 | ||
|
|
053b8b5c0a | ||
|
|
d59efd3ba0 | ||
|
|
ce56cd1ab0 | ||
|
|
54b6deee19 | ||
|
|
8315641724 | ||
|
|
aa3086cfae | ||
|
|
b599ce4919 | ||
|
|
52f16604c1 | ||
|
|
c98c06062b | ||
|
|
4963d247dc | ||
|
|
9405bc9553 | ||
|
|
8a080b91b9 | ||
|
|
4aa05fe523 | ||
|
|
d74d99dde7 | ||
|
|
0ee137efbb | ||
|
|
90f55670dc | ||
|
|
9c53f392ca | ||
|
|
cc19a81875 | ||
|
|
67032ddaf1 | ||
|
|
2fe71c3695 | ||
|
|
21422fe027 | ||
|
|
95575291ff | ||
|
|
3e61a0633d | ||
|
|
85c9a790f2 | ||
|
|
e17d55457f | ||
|
|
d8d8bc4d00 | ||
|
|
55cc1aea59 | ||
|
|
ae383164bc | ||
|
|
0d8863fe8e | ||
|
|
51f6654635 | ||
|
|
fa11e10bcb | ||
|
|
a8f5bd6fe9 | ||
|
|
a1c725bb35 | ||
|
|
e4eeb11a61 | ||
|
|
3b0bb25251 | ||
|
|
d27490e6f2 | ||
|
|
b3c191b553 | ||
|
|
0b977e615d | ||
|
|
979e1fd28b | ||
|
|
3437c4331d | ||
|
|
616d62094c | ||
|
|
ce7ff1c43c | ||
|
|
e27e41bc16 | ||
|
|
93e183e6c3 | ||
|
|
88a2bd3317 | ||
|
|
45caaa55b0 | ||
|
|
8c9b7a701d | ||
|
|
c47b992125 | ||
|
|
493c4f89dc | ||
|
|
27234dc809 | ||
|
|
5690b8e1d5 | ||
|
|
61274bc7ef | ||
|
|
e66be23996 | ||
|
|
3c43ce4165 | ||
|
|
5060824795 | ||
|
|
c4941f25a2 | ||
|
|
e7c219905f | ||
|
|
499cbd08ca | ||
|
|
0a3b3a69e3 | ||
|
|
e8607845e2 | ||
|
|
02ef4db0eb | ||
|
|
f8d48a341d | ||
|
|
251393101a | ||
|
|
b50430114f | ||
|
|
b0999abc68 | ||
|
|
2bbd92db13 | ||
|
|
64092abdca | ||
|
|
b7b631f0b0 | ||
|
|
c10f15e75b | ||
|
|
6983179b2f | ||
|
|
aea0661303 | ||
|
|
3fd9ef366f | ||
|
|
cf79a3f580 | ||
|
|
c5da78593a | ||
|
|
801b0be414 | ||
|
|
29aad2c535 | ||
|
|
82d3b9d29d | ||
|
|
a542ba486c | ||
|
|
d50e1a0e15 | ||
|
|
79a0071d65 | ||
|
|
2f1f989266 | ||
|
|
2303eb7908 | ||
|
|
c3666dd064 | ||
|
|
6ed197d35c | ||
|
|
164c9e37d7 | ||
|
|
fa0a18b31d | ||
|
|
80e2118e68 | ||
|
|
ed5086df6b | ||
|
|
218ec9140c | ||
|
|
6dad0e7679 | ||
|
|
cb96a56960 | ||
|
|
b35eb77edb | ||
|
|
89408ce12b | ||
|
|
e22d9a551c | ||
|
|
c215dc5864 | ||
|
|
a37f1d9798 | ||
|
|
07c8bbc562 | ||
|
|
b381cfe207 | ||
|
|
a560b9c8f4 | ||
|
|
e5b8cc6912 | ||
|
|
18b5612c0d | ||
|
|
fad488623f | ||
|
|
c490f084dc | ||
|
|
b1caacd643 | ||
|
|
76c8876923 | ||
|
|
a911c9cb8d | ||
|
|
07d5594085 | ||
|
|
a745d8a60a | ||
|
|
8b83d39ac3 | ||
|
|
4edb39616d | ||
|
|
7a2060fe08 | ||
|
|
d009682d4a | ||
|
|
8adb720a80 | ||
|
|
4d6f338f0c | ||
|
|
ffe9d43d09 | ||
|
|
2227b4ff91 | ||
|
|
5dd4937a1a | ||
|
|
866a0e2596 | ||
|
|
64bfda9dbe | ||
|
|
aacbbc1785 | ||
|
|
3714d5faab | ||
|
|
12cb94950a | ||
|
|
795fd788e2 | ||
|
|
956fd6ae2f | ||
|
|
04e60611ed | ||
|
|
bc99e9e504 | ||
|
|
2f35428232 | ||
|
|
00aac89ed7 | ||
|
|
f390e91bd7 | ||
|
|
e413359c8c | ||
|
|
601df7f808 | ||
|
|
e160a4add0 | ||
|
|
3bea03f4b1 | ||
|
|
19dbde8a3c | ||
|
|
a5fe10189f | ||
|
|
7075384313 | ||
|
|
25bf43cddd | ||
|
|
2880a681a8 | ||
|
|
bbcd0b7e78 | ||
|
|
916ed13bea | ||
|
|
11a06324be | ||
|
|
bcb340d97e | ||
|
|
aabef47ff1 | ||
|
|
e27de47d9f | ||
|
|
7900be3a30 | ||
|
|
ddc5838c7b | ||
|
|
6a13d7c5c6 | ||
|
|
4f0b35d9e2 | ||
|
|
3b19ede6a3 | ||
|
|
1a0ce47404 | ||
|
|
22f136d19b | ||
|
|
9a40e54159 | ||
|
|
229f2627c8 | ||
|
|
6f6454df40 | ||
|
|
3da9fc5500 | ||
|
|
540bf868b5 | ||
|
|
1674ecc585 | ||
|
|
4920abdaf7 | ||
|
|
a6b98bd35d | ||
|
|
34d4f394fc | ||
|
|
a4ef8d4200 | ||
|
|
9d9662687e | ||
|
|
2d41c719c4 | ||
|
|
b69bd966a6 | ||
|
|
09a49dd0fd | ||
|
|
3dd3372c35 | ||
|
|
0a60864310 | ||
|
|
cb24e5d0aa | ||
|
|
8c5aebdca1 | ||
|
|
4e2cff0fd5 | ||
|
|
54e518efad | ||
|
|
ad93099171 | ||
|
|
630065ca89 | ||
|
|
ddf5717992 | ||
|
|
4a53ec0b74 | ||
|
|
d7fd055171 | ||
|
|
874973d78f | ||
|
|
8078c5657b | ||
|
|
b39404860f | ||
|
|
860eae8dcb | ||
|
|
b931d3ee13 | ||
|
|
ba8a71bf3a | ||
|
|
8de4897042 | ||
|
|
6de6f8e697 | ||
|
|
e55ecff899 | ||
|
|
3895ef002e | ||
|
|
afc28503a5 | ||
|
|
e6497c87ca | ||
|
|
cbb020911d | ||
|
|
200b9d037c | ||
|
|
69667a7528 | ||
|
|
9f655ed819 | ||
|
|
5a1e1e231b | ||
|
|
6cccb1725f | ||
|
|
5bb2a904ce | ||
|
|
5e2f06cdea | ||
|
|
cf83038a7a | ||
|
|
c7662cad61 | ||
|
|
86912e0cbc | ||
|
|
74234b4ed1 | ||
|
|
2fcb8a750d | ||
|
|
2aaf672b10 | ||
|
|
34beadeef6 | ||
|
|
6de715b884 | ||
|
|
84717ca73e | ||
|
|
86b1db57d5 | ||
|
|
50c15f0d51 | ||
|
|
86d5c43307 | ||
|
|
e82bd45b55 | ||
|
|
13966e0ec8 | ||
|
|
14b9e0c032 | ||
|
|
f0f168e920 | ||
|
|
dd37caf32c | ||
|
|
856d223535 | ||
|
|
ad697365ce | ||
|
|
84171928cb | ||
|
|
e781a78eea | ||
|
|
7d08c6724b | ||
|
|
899da51f06 | ||
|
|
4e085e404a | ||
|
|
ff3aaadbaa | ||
|
|
11bddbb71d | ||
|
|
55a80ba253 | ||
|
|
32a89c30de | ||
|
|
5404eeedc2 | ||
|
|
d54ba90304 | ||
|
|
ca206915bf | ||
|
|
8461b82f0d | ||
|
|
07ffc3fade | ||
|
|
c2240f0bad | ||
|
|
36a712f979 | ||
|
|
842ba09896 | ||
|
|
b3c822abf7 | ||
|
|
1f52ed7290 | ||
|
|
a2c13e576e | ||
|
|
24bb52d2e9 | ||
|
|
d8145bbc78 | ||
|
|
8753a6e4d6 | ||
|
|
b91607b5b4 | ||
|
|
af0e839f27 | ||
|
|
b6de03de96 | ||
|
|
e115095198 | ||
|
|
c3add4bae8 | ||
|
|
3040e14624 | ||
|
|
34993e3f36 | ||
|
|
8d5c6b8877 | ||
|
|
c9eac73fec | ||
|
|
ab475b80f3 | ||
|
|
2b79039b70 | ||
|
|
044df04d3f | ||
|
|
2cbf805ffb | ||
|
|
d208074063 | ||
|
|
30541da1fa | ||
|
|
97af0af0ba | ||
|
|
e9b26921da | ||
|
|
e4434f199d | ||
|
|
f4b0219a0e | ||
|
|
6f1eaaa02e | ||
|
|
e5868837d5 | ||
|
|
71a5d8b261 | ||
|
|
dfc022d890 | ||
|
|
68cb0cf197 | ||
|
|
14592de6c4 | ||
|
|
d41dcc69ad | ||
|
|
0a28b5825b | ||
|
|
3131318b6a | ||
|
|
08e35e4698 | ||
|
|
ed4b3f082a | ||
|
|
a1007398f6 | ||
|
|
2aafa1c163 | ||
|
|
008178480a | ||
|
|
2334c3bef8 | ||
|
|
9542116ec1 | ||
|
|
f9c11c2856 | ||
|
|
30060f03eb | ||
|
|
b8ffcef45e | ||
|
|
999882b795 | ||
|
|
bda963b851 | ||
|
|
8511494d68 | ||
|
|
1c3ad9dad6 | ||
|
|
d43afe7dbd | ||
|
|
a06b2009ab | ||
|
|
2a786b4652 | ||
|
|
ef1844557f | ||
|
|
d1f7b043e8 | ||
|
|
3a8e40d029 | ||
|
|
bca46228e7 | ||
|
|
d15b24b8ea | ||
|
|
d9df9b3d01 | ||
|
|
fc1d6ae402 | ||
|
|
26b4b7a868 | ||
|
|
a839630ca3 | ||
|
|
e21dbb704e | ||
|
|
53a06797a9 | ||
|
|
99eb743772 | ||
|
|
73adfb0cec | ||
|
|
7127c60116 | ||
|
|
a6d37fd167 | ||
|
|
16ea15076d | ||
|
|
041871f1b6 | ||
|
|
8c8ae127fe | ||
|
|
357c82129c | ||
|
|
324ac691eb | ||
|
|
da99001a7a | ||
|
|
bd10d07083 | ||
|
|
0e9b419a07 | ||
|
|
d5bd08d279 | ||
|
|
4cc83530f9 | ||
|
|
4fcf4081d0 | ||
|
|
d84d80eea6 | ||
|
|
f3e1d67de1 | ||
|
|
2ce25f54d4 | ||
|
|
25eac5d29b | ||
|
|
1710e30719 | ||
|
|
6d970d0b57 | ||
|
|
41a6035a12 | ||
|
|
f84c8239e7 | ||
|
|
1dca160db0 | ||
|
|
4ff366cd99 | ||
|
|
21a4711f07 | ||
|
|
f1dd217de7 | ||
|
|
3e8f0b125d | ||
|
|
25246ef8ff | ||
|
|
bb98143e2e | ||
|
|
6258ca14d0 | ||
|
|
d0e17e8611 | ||
|
|
79dc76239b | ||
|
|
95d62354d9 | ||
|
|
b9fadf82a7 | ||
|
|
1740e34c79 | ||
|
|
9e09066e99 | ||
|
|
0a9cfd3a08 | ||
|
|
0be711ad6e | ||
|
|
1c507b8169 | ||
|
|
81bdb3dc91 | ||
|
|
5efc7f3856 | ||
|
|
0afecec607 | ||
|
|
920574de65 | ||
|
|
d15490d5aa | ||
|
|
ba6e547f6c | ||
|
|
2493315f92 | ||
|
|
ec117d4034 | ||
|
|
646058b8a1 | ||
|
|
06c359ac9b | ||
|
|
0d96d9c44c | ||
|
|
e5f8d4a1ac | ||
|
|
19c72323b8 | ||
|
|
f19b2804b6 | ||
|
|
f387fdb166 | ||
|
|
ec8f9d5f7a | ||
|
|
6cb3bdc567 | ||
|
|
d574840c3f | ||
|
|
6a85d0e10b | ||
|
|
816df3704e | ||
|
|
badd9a5698 | ||
|
|
4e02fe4251 | ||
|
|
01b9780938 | ||
|
|
6be144f95f | ||
|
|
37dd6d888b | ||
|
|
f5ffee0a20 | ||
|
|
6cd51e0a8d | ||
|
|
80fad2aab9 | ||
|
|
1ccfc65766 | ||
|
|
86918b03cf | ||
|
|
a5b684af1f | ||
|
|
8889e8566c | ||
|
|
fab364f084 | ||
|
|
5bc9d71a88 | ||
|
|
2b5ea649d4 | ||
|
|
0802ebb17b | ||
|
|
29b8d68f45 | ||
|
|
6994cac5e0 | ||
|
|
df9b72583d | ||
|
|
553eab59e6 | ||
|
|
8d675fd8b6 | ||
|
|
a5639a8290 | ||
|
|
44d9c4bcc5 | ||
|
|
1b586560f8 | ||
|
|
e082cc7d3d | ||
|
|
d411e70ea3 | ||
|
|
8dffa4d371 | ||
|
|
b7bf6e80e6 | ||
|
|
2a93e661f9 | ||
|
|
bfb51b82b2 | ||
|
|
f50be6c08b | ||
|
|
df75df7008 | ||
|
|
deb55c0536 | ||
|
|
7fc11ff341 | ||
|
|
1a70f83d34 | ||
|
|
39077a08f1 | ||
|
|
9a671f9063 | ||
|
|
3bc9fa11d0 | ||
|
|
7e699ab810 | ||
|
|
1bd17ba365 | ||
|
|
4d5246cf4d | ||
|
|
5d988453fa | ||
|
|
85b9782264 | ||
|
|
c2b091aa67 | ||
|
|
7093e1e4c6 | ||
|
|
ead42c1a9c | ||
|
|
e669996442 | ||
|
|
0e6f365dc2 | ||
|
|
08b501625d | ||
|
|
b89ff43921 | ||
|
|
fadfba8830 | ||
|
|
43be9bd6a8 | ||
|
|
caf184151d | ||
|
|
22e3338709 | ||
|
|
048e56806c | ||
|
|
ade64c0d09 | ||
|
|
5beb2943b9 | ||
|
|
b3d3cb1dee | ||
|
|
79d695236f | ||
|
|
2dbbdc21f2 | ||
|
|
e4254ef7cc | ||
|
|
7f9709bdaa | ||
|
|
77054d92df | ||
|
|
1277f5e9f7 | ||
|
|
109c303b60 | ||
|
|
a52a0e90e7 | ||
|
+25 |
558140fdd6 | ||
|
|
fa09fd66ff | ||
|
|
b1cbeebe48 | ||
|
|
93bad282d7 | ||
|
|
47d941676e | ||
|
|
54c4b0b65e | ||
|
|
0f8356f417 | ||
|
|
cecfcc7c6f | ||
|
|
0b3f9d06a9 | ||
|
|
71e7300830 | ||
|
|
bccffb328b | ||
|
|
80fa172f79 | ||
|
|
50173658f8 | ||
|
|
dddbd7dad4 | ||
|
|
ae2d85d81b | ||
|
|
80c407a4b7 | ||
|
|
6e9ec113fe | ||
|
|
3ee6a7ecde | ||
|
|
8ad62d183c | ||
|
|
f047799299 | ||
|
|
3a9e353443 | ||
|
|
b73a3aeb0c | ||
|
|
b6ff64ed74 | ||
|
|
1b281fe269 | ||
|
|
024913592b | ||
|
|
78a34038d2 | ||
|
|
5f770a9789 | ||
|
|
24a43877ce | ||
|
|
4a96be4175 | ||
|
|
594bb606b8 | ||
|
|
1c088f65c5 | ||
|
|
7d7a429780 | ||
|
|
d3228441aa | ||
|
|
21d6bf1fca | ||
|
|
a4134aa5a3 | ||
|
|
61397e5184 | ||
|
|
6143cd60e5 | ||
|
|
ac821f6b13 | ||
|
|
2bce38fd2a | ||
|
|
390ee79134 | ||
|
|
cfc9f0bfc3 | ||
|
|
58d5f2a17c | ||
|
|
37f271bd74 | ||
|
|
d770cfc65e | ||
|
|
ab8556790e | ||
|
|
3d89b37cc6 |
@@ -1,11 +1,15 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||||
"changelog": "@changesets/cli/changelog",
|
"changelog": ["@changesets/changelog-github", { "repo": "calcom/cal.com" }],
|
||||||
"commit": false,
|
"commit": false,
|
||||||
"fixed": [],
|
"fixed": [],
|
||||||
"linked": [],
|
"linked": [],
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
"ignore": []
|
"ignore": ["@calcom/platform-libraries"],
|
||||||
|
"privatePackages": {
|
||||||
|
"version": false,
|
||||||
|
"tag": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Review Instructions
|
||||||
|
|
||||||
|
## File Naming Conventions
|
||||||
|
|
||||||
|
- **Repository Files**: Must include `Repository` suffix, prefix with technology if applicable (e.g., `PrismaAppRepository.ts`), use PascalCase matching exported class
|
||||||
|
- **Service Files**: Must include `Service` suffix, use PascalCase matching exported class, avoid generic names (e.g., `MembershipService.ts`)
|
||||||
|
- **New files**: Avoid dot-suffixes like `.service.ts` or `.repository.ts`; reserve `.test.ts`, `.spec.ts`, `.types.ts` for their specific purposes
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- Prefer early returns. It is recommended to throw/return early so we can ensure null-checks and prevent further nesting.
|
||||||
|
- Prefer Composition over Prop Drilling. Instead of relying on prop drilling, let's try to take advantage of react children feature.
|
||||||
|
- For Prisma queries:
|
||||||
|
- Only select data you need
|
||||||
|
1) Increased performance overhead: When you select all columns from a table, Prisma retrieves all the data associated with each row, which can result in a significant performance impact. This includes reading unnecessary columns and transferring a large volume of data between your application and the database.
|
||||||
|
2) Unnecessary data exposure: Selecting all columns exposes sensitive data that might not be required by your application. This can pose security risks, especially if the table contains sensitive user information or proprietary data. (In our case app credentials and booking information)
|
||||||
|
- TLDR: Never use Includes - use select
|
||||||
|
- Select selects only the fields you specify explicitly
|
||||||
|
- Include selects the relationship AND all the fields of the table the relationship belongs to
|
||||||
|
- Make sure the credential.key field is never returned back from tRPC endpoints or APIs
|
||||||
|
- Always use t() for text localization in frontend code; direct text embedding should trigger a warning.
|
||||||
|
- Check if there's any O(n^2) logic in backend code; we should aim for O(n log n) or O(n) ideally.
|
||||||
|
- Check if there are circular references introduced. We should never allow these.
|
||||||
|
- Flag excessive Day.js use in performance-critical code. Functions like `.add`, `.diff`, `.isBefore`, and `.isAfter` are slow, especially in timezone mode. Prefer `.utc()` for better performance. Where possible, replace with native Date and direct `.valueOf()` comparisons for faster execution. Recommend using native methods or Day.js `.utc()` consistently in hot paths like loops.
|
||||||
|
- When docs changes are made in /docs/api-reference/v2/openapi.json, ensure the following
|
||||||
|
- "summary" fields are short and concise
|
||||||
|
- "summary" fields do not end with a period
|
||||||
|
- "summary" fields are written in proper American english
|
||||||
|
- When changes to API v2 or v1 are made, ensure there are no breaking changes on existing endpoints. Instead, there needs to be a newly versioned endpoint with the updated functionality but the old one must remain functional
|
||||||
|
- For large pull requests (>500 lines changed or >10 files touched), advise splitting into smaller, focused PRs:
|
||||||
|
- Split by feature boundaries: separate different features or user stories
|
||||||
|
- Split by layer/component: frontend changes, backend changes, database migrations, and tests in separate PRs
|
||||||
|
- Split by dependency chain: create PRs that can be merged sequentially
|
||||||
|
- Split by file/module: group related file changes together
|
||||||
|
- Suggested pattern: Database migrations → Backend logic → Frontend components → Integration tests
|
||||||
|
- Benefits: easier review, faster feedback, reduced conflicts, easier rollback, better history
|
||||||
+17
-6
@@ -159,13 +159,18 @@ SENDGRID_EMAIL=
|
|||||||
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
|
SENTRY_ORG=
|
||||||
|
SENTRY_PROJECT=
|
||||||
|
SENTRY_AUTH_TOKEN=
|
||||||
# Used for capturing exceptions and logging messages
|
# Used for capturing exceptions and logging messages
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN_CLIENT=
|
||||||
SENTRY_DEBUG=
|
SENTRY_DEBUG=
|
||||||
SENTRY_DISABLE_CLIENT_SOURCE_MAPS=
|
|
||||||
SENTRY_DISABLE_SERVER_SOURCE_MAPS=
|
|
||||||
SENTRY_MAX_SPANS=
|
SENTRY_MAX_SPANS=
|
||||||
|
SENTRY_SAMPLE_RATE=
|
||||||
SENTRY_TRACES_SAMPLE_RATE=
|
SENTRY_TRACES_SAMPLE_RATE=
|
||||||
|
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
|
||||||
|
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
|
||||||
|
|
||||||
# Formbricks Experience Management Integration
|
# Formbricks Experience Management Integration
|
||||||
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
|
||||||
@@ -184,6 +189,10 @@ TWILIO_TOKEN=
|
|||||||
TWILIO_MESSAGING_SID=
|
TWILIO_MESSAGING_SID=
|
||||||
TWILIO_PHONE_NUMBER=
|
TWILIO_PHONE_NUMBER=
|
||||||
TWILIO_WHATSAPP_PHONE_NUMBER=
|
TWILIO_WHATSAPP_PHONE_NUMBER=
|
||||||
|
TWILIO_WHATSAPP_REMINDER_CONTENT_SID=
|
||||||
|
TWILIO_WHATSAPP_CANCELLED_CONTENT_SID=
|
||||||
|
TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID=
|
||||||
|
TWILIO_WHATSAPP_COMPLETED_CONTENT_SID=
|
||||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||||
NEXT_PUBLIC_SENDER_ID=
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
TWILIO_VERIFY_SID=
|
TWILIO_VERIFY_SID=
|
||||||
@@ -198,6 +207,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
|||||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||||
|
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
|
||||||
STRIPE_TEAM_PRODUCT_ID=
|
STRIPE_TEAM_PRODUCT_ID=
|
||||||
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
||||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||||
@@ -363,9 +373,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
|
|||||||
|
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
# disable setry server source maps
|
|
||||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
|
||||||
|
|
||||||
# api v2
|
# api v2
|
||||||
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
|
||||||
|
|
||||||
@@ -425,7 +432,7 @@ DIRECTORY_IDS_TO_LOG=
|
|||||||
# Read more about it in the README.md
|
# Read more about it in the README.md
|
||||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
||||||
|
|
||||||
AKISMET_API_KEY=
|
IFFY_API_KEY=
|
||||||
|
|
||||||
## Env variables related to avoiding booking failures
|
## Env variables related to avoiding booking failures
|
||||||
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
||||||
@@ -439,6 +446,10 @@ NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
|
|||||||
# Used to enable quick availability checks for x% of all visitors
|
# Used to enable quick availability checks for x% of all visitors
|
||||||
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||||
|
|
||||||
|
# Used to generate token for cal video recordings expiry token
|
||||||
|
# You can use: `openssl rand -base64 32` to generate one
|
||||||
|
CAL_VIDEO_RECORDING_TOKEN_SECRET=
|
||||||
|
|
||||||
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
|
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
|
||||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,6 @@
|
|||||||
/**/package.json @calcom/Foundation
|
/**/package.json @calcom/Foundation
|
||||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
|
|
||||||
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
|
|
||||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/lib/**/* @calcom/Foundation
|
/apps/web/lib/**/* @calcom/Foundation
|
||||||
/apps/web/middleware.ts @calcom/Foundation
|
/apps/web/middleware.ts @calcom/Foundation
|
||||||
@@ -19,13 +17,15 @@
|
|||||||
/packages/app-store/larkcalendar/**/* @calcom/Foundation
|
/packages/app-store/larkcalendar/**/* @calcom/Foundation
|
||||||
/packages/app-store/office365calendar/**/* @calcom/Foundation
|
/packages/app-store/office365calendar/**/* @calcom/Foundation
|
||||||
/packages/app-store/zohocalendar/**/* @calcom/Foundation
|
/packages/app-store/zohocalendar/**/* @calcom/Foundation
|
||||||
|
/packages/embeds/**/* @calcom/Foundation
|
||||||
/packages/features/bookings/lib/**/* @calcom/Foundation
|
/packages/features/bookings/lib/**/* @calcom/Foundation
|
||||||
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
|
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
|
||||||
/packages/lib/getUserAvailability.ts @calcom/Foundation
|
/packages/lib/getUserAvailability.ts @calcom/Foundation
|
||||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||||
/packages/lib/slots.ts @calcom/Foundation
|
/packages/lib/slots.ts @calcom/Foundation
|
||||||
|
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
|
||||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||||
/packages/prisma/**/* @calcom/Foundation
|
/packages/prisma/**/* @calcom/DBA
|
||||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
permissions: {} # remove permissions and set explicitly under "permissions:" below
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
|
||||||
|
permissions:
|
||||||
|
id-token: write # for NPM provenance - developers can verify where and how it was built
|
||||||
|
contents: write # to create release by changesets/action below
|
||||||
|
pull-requests: write # to create pull request by changesets/action below
|
||||||
|
name: Release
|
||||||
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup node, yarn and install dependencies
|
||||||
|
uses: ./.github/actions/yarn-install
|
||||||
|
- name: Create Release Pull Request or Publish to npm
|
||||||
|
id: changesets
|
||||||
|
uses: changesets/action@v1
|
||||||
|
with:
|
||||||
|
version: yarn changesets-version
|
||||||
|
publish: yarn changesets-release
|
||||||
|
commit: "chore: version packages" # commit message
|
||||||
|
title: "chore: version packages" # PR title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
|
||||||
|
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
|
||||||
|
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Cron - checkSmsPrices
|
||||||
|
|
||||||
|
on:
|
||||||
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
|
schedule:
|
||||||
|
# Runs “every minute” (see https://crontab.guru)
|
||||||
|
- cron: "* * * * *"
|
||||||
|
jobs:
|
||||||
|
cron-checkSmsPrices:
|
||||||
|
env:
|
||||||
|
APP_URL: ${{ secrets.APP_URL }}
|
||||||
|
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: cURL request
|
||||||
|
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||||
|
run: |
|
||||||
|
curl ${{ secrets.APP_URL }}/api/cron/checkSmsPrices \
|
||||||
|
-X POST \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
|
--fail
|
||||||
@@ -27,8 +27,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Configure git
|
- name: Configure git
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "github-actions@github.com"
|
# Define authorized users and their emails using organization secret
|
||||||
git config --local user.name "GitHub Actions"
|
# Expected format: JSON object with username -> email mapping
|
||||||
|
# Example: {"zomars": "zomars@cal.com", "peer": "peer@cal.com", "username3": "email3@cal.com"}
|
||||||
|
USER_EMAILS='${{ secrets.RELEASE_USER_EMAILS }}'
|
||||||
|
|
||||||
|
# Extract email for the triggering user
|
||||||
|
USER_EMAIL=$(echo "$USER_EMAILS" | jq -r --arg user "${{ github.actor }}" '.[$user] // empty')
|
||||||
|
|
||||||
|
# Fail if user is not authorized or not in mapping
|
||||||
|
if [ -z "$USER_EMAIL" ] || [ "$USER_EMAIL" = "null" ]; then
|
||||||
|
echo "Error: User '${{ github.actor }}' is not authorized to run the release workflow."
|
||||||
|
echo "Only authorized team members can trigger releases."
|
||||||
|
echo "Contact your administrator to be added to the authorized users list."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config --local user.email "$USER_EMAIL"
|
||||||
|
git config --local user.name "${{ github.actor }}"
|
||||||
|
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
name: E2E API v2
|
name: E2E API v2
|
||||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:13
|
image: postgres:13
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
- uses: lingodotdev/lingo.dev@main
|
- uses: lingodotdev/lingo.dev@main
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||||
organization-name: calcom
|
organization-name: calcom
|
||||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||||
apply-labels-from-issue:
|
apply-labels-from-issue:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
outputs:
|
outputs:
|
||||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
|
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || github.event.label.name == 'ready-for-e2e') }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check if PR exists with ready-for-e2e label for this SHA
|
- name: Check if PR exists with ready-for-e2e label for this SHA
|
||||||
id: check-if-pr-has-label
|
id: check-if-pr-has-label
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
name: Submodule Sync
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Runs "At minute 15 past every 4th hour." (see https://crontab.guru)
|
|
||||||
- cron: "15 */4 * * *"
|
|
||||||
workflow_dispatch: ~
|
|
||||||
jobs:
|
|
||||||
submodule-sync:
|
|
||||||
name: Submodule update
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
|
||||||
- name: Commit
|
|
||||||
run: |
|
|
||||||
git config user.email "actions@github.com"
|
|
||||||
git config user.name "github-actions"
|
|
||||||
git commit -am "Auto updated submodule references" && git push || echo "No changes to commit"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
echo "Updating submodules recursively"
|
|
||||||
pwd
|
|
||||||
git submodule update --init --recursive
|
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
Vendored
Vendored
Vendored
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
@@ -95,6 +95,57 @@ Write with the future in mind. If there are trade-offs, edge cases, or temporary
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
## File Naming Conventions
|
||||||
|
|
||||||
|
To ensure consistency and make files easy to fuzzy-find, we follow the naming conventions below for **services**, **repositories**, and other class-based files.
|
||||||
|
|
||||||
|
### Repository Files
|
||||||
|
|
||||||
|
- Repository class files must include the `Repository` suffix.
|
||||||
|
- If the repository is backed by a specific technology (e.g. Prisma), prefix the filename and class name with it.
|
||||||
|
- File name must match the exported class exactly (PascalCase).
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
|
||||||
|
`Prisma<Entity>Repository.ts`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// File: PrismaAppRepository.ts
|
||||||
|
export class PrismaAppRepository { ... }
|
||||||
|
|
||||||
|
// File: PrismaMembershipRepository.ts
|
||||||
|
export class PrismaMembershipRepository { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
This avoids ambiguous filenames like app.ts and improves discoverability in editors.
|
||||||
|
|
||||||
|
### Service Files
|
||||||
|
|
||||||
|
- Service class files must include the Service suffix.
|
||||||
|
- File name should be in PascalCase, matching the exported class.
|
||||||
|
- Keep naming specific — avoid generic names like AppService.ts.
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
|
||||||
|
`<Entity>Service.ts`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// File: MembershipService.ts
|
||||||
|
export class MembershipService { ... }
|
||||||
|
|
||||||
|
// File: HashedLinkService.ts
|
||||||
|
export class HashedLinkService { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:**
|
||||||
|
|
||||||
|
- New files must avoid dot-suffixes like .service.ts or .repository.ts; these will be migrated from the existing codebase progressively.
|
||||||
|
- We still reserve suffixes such as .test.ts, .spec.ts, and .types.ts for their respective use cases.
|
||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
[See README](https://github.com/calcom/cal.com#development)
|
[See README](https://github.com/calcom/cal.com#development)
|
||||||
|
|||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
# Cal.com Permission Documentation
|
||||||
|
|
||||||
|
This document maps existing role-based permission checks to the new PBAC (Permission-Based Access Control) system's permission strings in the format `resource.action`.
|
||||||
|
|
||||||
|
## Permissions by Resource
|
||||||
|
|
||||||
|
### Team Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| team.create | Create teams | [packages/trpc/server/routers/viewer/teams/create.handler.ts](packages/trpc/server/routers/viewer/teams/create.handler.ts) | 55-57 |
|
||||||
|
| team.update | Update team settings | [packages/trpc/server/routers/viewer/teams/update.handler.ts](packages/trpc/server/routers/viewer/teams/update.handler.ts) | 18 |
|
||||||
|
| team.changeMemberRole | Change role of team members | [packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts](packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts) | 18-21 |
|
||||||
|
| team.remove | Remove members from team | [packages/trpc/server/routers/viewer/teams/removeMember.handler.ts](packages/trpc/server/routers/viewer/teams/removeMember.handler.ts) | 35-36, 51-55 |
|
||||||
|
| team.invite | Invite members to team | [packages/trpc/server/routers/viewer/teams/invite.handler.ts](packages/trpc/server/routers/viewer/teams/invite.handler.ts) | - |
|
||||||
|
|
||||||
|
### Event Type Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| eventType.create | Create event types | [packages/trpc/server/routers/viewer/eventTypes/create.handler.ts](packages/trpc/server/routers/viewer/eventTypes/create.handler.ts) | - |
|
||||||
|
| eventType.update | Update event types | [packages/trpc/server/routers/viewer/eventTypes/update.handler.ts](packages/trpc/server/routers/viewer/eventTypes/update.handler.ts) | 162-164 |
|
||||||
|
| eventType.delete | Delete event types | [packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts](packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts) | 13-31 |
|
||||||
|
|
||||||
|
### Booking Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| booking.read | Read booking details | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 95-107, 132-134 |
|
||||||
|
| booking.readTeamBookings | Read team bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
|
||||||
|
| booking.readOrgBookings | Read organization bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
|
||||||
|
|
||||||
|
### Organization Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| organization.read | Read organization details | [packages/trpc/server/routers/viewer/organizations/get.handler.ts](packages/trpc/server/routers/viewer/organizations/get.handler.ts) | - |
|
||||||
|
| organization.listMembers | List organization members | [packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts](packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts) | 68-76 |
|
||||||
|
| organization.create | Create organization | [packages/trpc/server/routers/viewer/organizations/create.handler.ts](packages/trpc/server/routers/viewer/organizations/create.handler.ts) | - |
|
||||||
|
|
||||||
|
### API Key Permissions
|
||||||
|
|
||||||
|
| Permission String | Description | File Path | Line |
|
||||||
|
|------------------|-------------|-----------|------|
|
||||||
|
| apiKey.create | Create API keys | [packages/trpc/server/routers/viewer/apiKeys/create.handler.ts](packages/trpc/server/routers/viewer/apiKeys/create.handler.ts) | 25-26 |
|
||||||
|
| apiKey.findKeyOfType | Find API keys by type | [packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts](packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts) | 18-19 |
|
||||||
|
|
||||||
|
## Helper Functions and Utilities
|
||||||
|
|
||||||
|
The following helper functions are commonly used for permission checks:
|
||||||
|
|
||||||
|
| Function | Description | File Path |
|
||||||
|
|----------|-------------|-----------|
|
||||||
|
| isTeamAdmin | Checks if user is admin or owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L385-L405) |
|
||||||
|
| isTeamOwner | Checks if user is owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L407-L416) |
|
||||||
|
| isTeamMember | Checks if user is a member of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L418-L426) |
|
||||||
|
| canEditEntity | Checks if user can edit an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L13-L22) |
|
||||||
|
| canAccessEntity | Checks if user can access an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L24-L34) |
|
||||||
|
| getEntityPermissionLevel | Gets permission level for an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L36-L73) |
|
||||||
|
| canCreateEntity | Checks if user can create an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L102-L115) |
|
||||||
|
| withRoleCanCreateEntity | Checks if role allows entity creation | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L119-L121) |
|
||||||
|
|
||||||
|
## Permission Level Enums
|
||||||
|
|
||||||
|
The codebase uses several enums to define permission levels:
|
||||||
|
|
||||||
|
### MembershipRole Enum
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum MembershipRole {
|
||||||
|
OWNER = "OWNER",
|
||||||
|
ADMIN = "ADMIN",
|
||||||
|
MEMBER = "MEMBER"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entity Permission Level Enum
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum ENTITY_PERMISSION_LEVEL {
|
||||||
|
NONE,
|
||||||
|
USER_ONLY_WRITE,
|
||||||
|
TEAM_READ_ONLY,
|
||||||
|
TEAM_WRITE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Permission Patterns
|
||||||
|
|
||||||
|
1. **Team Admin/Owner Check**: Many operations require the user to be a team admin or owner.
|
||||||
|
```typescript
|
||||||
|
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Team Owner Check**: Some operations (like changing an owner's role) require the user to be a team owner.
|
||||||
|
```typescript
|
||||||
|
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Organization Admin Check**: Operations within an organization require the user to be an organization admin.
|
||||||
|
```typescript
|
||||||
|
if (user.profile?.organizationId && !user.organization.isOrgAdmin) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "org_admins_can_create_new_teams" });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Entity Permission Check**: Entity operations use permission level checks.
|
||||||
|
```typescript
|
||||||
|
const permissionLevel = await getEntityPermissionLevel(entity, userId);
|
||||||
|
return permissionLevel === ENTITY_PERMISSION_LEVEL.TEAM_WRITE ||
|
||||||
|
permissionLevel === ENTITY_PERMISSION_LEVEL.USER_ONLY_WRITE;
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Booking Access Control**: Complex permission checks for retrieving bookings based on user roles and team/organization membership.
|
||||||
|
```typescript
|
||||||
|
const membershipIdsWhereUserIsAdminOwner = (
|
||||||
|
await prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
role: {
|
||||||
|
in: ["ADMIN", "OWNER"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).map((membership) => membership.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration to PBAC
|
||||||
|
|
||||||
|
When migrating to the new PBAC system, these existing role-based checks should be replaced with permission string checks using the `permissionMatches` function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { permissionMatches } from "@calcom/features/pbac/types/permission-registry";
|
||||||
|
|
||||||
|
// Instead of:
|
||||||
|
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
|
// Use:
|
||||||
|
if (!permissionMatches("team.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permission String Alternatives for Helper Functions
|
||||||
|
|
||||||
|
The following table provides permission string alternatives for common helper functions:
|
||||||
|
|
||||||
|
| Helper Function | Permission String Alternative | Description |
|
||||||
|
|-----------------|-------------------------------|-------------|
|
||||||
|
| isTeamAdmin | `team.*` | Grants all team permissions |
|
||||||
|
| isTeamAdmin | `team.update` | Update team settings |
|
||||||
|
| isTeamAdmin | `team.invite` | Invite team members |
|
||||||
|
| isTeamAdmin | `team.remove` | Remove team members |
|
||||||
|
| isTeamOwner | `team.changeMemberRole` | Change role of team members |
|
||||||
|
| isTeamOwner | `team.delete` | Delete team |
|
||||||
|
| isTeamMember | `team.read` | Read-only access to team |
|
||||||
|
|
||||||
|
## Entity Permission Functions and Resources
|
||||||
|
|
||||||
|
The following functions are used to check permissions for entities with userId and teamId properties:
|
||||||
|
|
||||||
|
| Function | Description | Permission String Alternative |
|
||||||
|
|----------|-------------|-------------------------------|
|
||||||
|
| canEditEntity | Checks if user can edit an entity | `{resource}.update` |
|
||||||
|
| canAccessEntity | Checks if user can access an entity | `{resource}.read` |
|
||||||
|
| getEntityPermissionLevel | Gets permission level for an entity | N/A - Implementation detail |
|
||||||
|
| canCreateEntity | Checks if user can create an entity | `{resource}.create` |
|
||||||
|
|
||||||
|
### Resources Used with Entity Permission Functions
|
||||||
|
|
||||||
|
| Resource | canEditEntity Usage | canAccessEntity Usage | Permission String |
|
||||||
|
|----------|-------------------|---------------------|------------------|
|
||||||
|
| routingForm | [packages/app-store/routing-forms/trpc/forms.handler.ts](packages/app-store/routing-forms/trpc/forms.handler.ts#L72) | [packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts](packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts#L78) | `routingForm.update`, `routingForm.read` |
|
||||||
|
| routingForm | [packages/app-store/routing-forms/trpc/formMutation.handler.ts](packages/app-store/routing-forms/trpc/formMutation.handler.ts#L315) | | `routingForm.update` |
|
||||||
|
| routingForm | [packages/app-store/routing-forms/api/responses/[formId].ts](packages/app-store/routing-forms/api/responses/[formId].ts#L96) | | `routingForm.update` |
|
||||||
|
|
||||||
|
These functions can be used with any entity that has userId and teamId properties. When migrating to PBAC, replace these checks with appropriate permission string checks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Instead of:
|
||||||
|
if (!(await canEditEntity(form, user.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
|
||||||
|
// Use:
|
||||||
|
if (!permissionMatches("routingForm.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows for more granular permission control and the creation of custom roles with specific permissions.
|
||||||
@@ -80,6 +80,8 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
|
|||||||
|
|
||||||
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
This project is tested with browserstack
|
||||||
|
|
||||||
### Built With
|
### Built With
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/?ref=cal.com)
|
- [Next.js](https://nextjs.org/?ref=cal.com)
|
||||||
@@ -488,13 +490,8 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
|
|||||||
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
||||||
|
|
||||||
## Single Org Mode
|
## Single Org Mode
|
||||||
If you want to have booker.yourcompany.com to be the domain used for both dashboard(e.g. https://booker.yourcompany.com/event-types) and booking pages(e.g. https://booker.yourcompany.com/john.joe/15min).
|
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
|
||||||
- Set the `NEXT_PUBLIC_SINGLE_ORG_SLUG` environment variable to the slug of the organization you want to use. `NEXT_PUBLIC_SINGLE_ORG_SLUG=booker`
|
|
||||||
- Set the `NEXT_PUBLIC_WEBAPP_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBAPP_URL=https://booker.yourcompany.com`.
|
|
||||||
- Set the `NEXT_PUBLIC_WEBSITE_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBSITE_URL=https://booker.yourcompany.com`.
|
|
||||||
- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`.
|
|
||||||
|
|
||||||
Note: It causes root to serve the dashboard and not the organization profile page which shows all bookable users in the organization.
|
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
@@ -646,6 +643,14 @@ following
|
|||||||
15. Create a verify service
|
15. Create a verify service
|
||||||
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
||||||
|
|
||||||
|
## Changesets
|
||||||
|
|
||||||
|
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
|
||||||
|
|
||||||
|
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
|
||||||
|
1. Follow semantic versioning when using changesets.
|
||||||
|
2. Mark breaking changes using `❗️Breaking change`
|
||||||
|
|
||||||
<!-- LICENSE -->
|
<!-- LICENSE -->
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# Checkly Tests
|
# Checkly Tests
|
||||||
|
|
||||||
Run as `yarn checkly test`
|
Run as `yarn checkly test`
|
||||||
|
Optionally, run as `yarn checkly test --record` to be able to get detailed debugging view in Checkly UI
|
||||||
|
Run the tests for a particylar file as `yarn checkly test {filePattern}`
|
||||||
Deploy the tests as `yarn checkly deploy`
|
Deploy the tests as `yarn checkly deploy`
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("CSP Headers", () => {
|
||||||
|
test("Login page should have CSP header with nonce", async ({ page }) => {
|
||||||
|
const targetUrl = process.env.ENVIRONMENT_URL || "https://app.cal.com";
|
||||||
|
|
||||||
|
const response = await page.goto(`${targetUrl}/auth/login`);
|
||||||
|
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
|
||||||
|
const cspHeader = response?.headers()["content-security-policy"];
|
||||||
|
expect(cspHeader).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify nonce is present in CSP header
|
||||||
|
const nonceMatch = cspHeader?.match(/'nonce-([^']+)'/);
|
||||||
|
expect(nonceMatch).toBeTruthy();
|
||||||
|
expect(nonceMatch![1]).toHaveLength(24);
|
||||||
|
expect(nonceMatch![1]).toMatch(/==$/);
|
||||||
|
|
||||||
|
await page.screenshot({ path: "screenshot.jpg" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node ./index.js"
|
"dev": "node ./index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-4
@@ -1,4 +1,4 @@
|
|||||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
@@ -7,25 +7,40 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
|
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
|
||||||
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||||
|
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||||
|
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import { hashAPIKey } from "~/../../../packages/features/ee/api-keys/lib/apiKeys";
|
import { verifyApiKey } from "./verifyApiKey";
|
||||||
|
|
||||||
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
|
vi.mock("@calcom/lib/crypto", () => ({
|
||||||
|
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
|
||||||
|
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
|
||||||
|
}));
|
||||||
|
|
||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv("CALENDSO_ENCRYPTION_KEY", "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX");
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockDeploymentRepository: IDeploymentRepository = {
|
||||||
|
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
|
||||||
|
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
|
||||||
|
};
|
||||||
|
|
||||||
describe("Verify API key", () => {
|
describe("Verify API key", () => {
|
||||||
let service: ILicenseKeyService;
|
let service: ILicenseKeyService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
service = await LicenseKeyService.create();
|
service = await LicenseKeyService.create(mockDeploymentRepository);
|
||||||
|
|
||||||
vi.spyOn(service, "checkLicense");
|
vi.spyOn(service, "checkLicense");
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import type { NextMiddleware } from "next-api-middleware";
|
|||||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { isAdminGuard } from "../utils/isAdmin";
|
import { isAdminGuard } from "../utils/isAdmin";
|
||||||
@@ -19,11 +20,12 @@ export const dateNotInPast = function (date: Date) {
|
|||||||
|
|
||||||
// This verifies the apiKey and sets the user if it is valid.
|
// This verifies the apiKey and sets the user if it is valid.
|
||||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||||
const licenseKeyService = await LicenseKeySingleton.getInstance();
|
const deploymentRepo = new DeploymentRepository(prisma);
|
||||||
|
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
|
||||||
const hasValidLicense = await licenseKeyService.checkLicense();
|
const hasValidLicense = await licenseKeyService.checkLicense();
|
||||||
|
|
||||||
if (!hasValidLicense && IS_PRODUCTION) {
|
if (!hasValidLicense && IS_PRODUCTION) {
|
||||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
return res.status(401).json({ message: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Constructs the WHERE clause for Prisma booking findMany operation.
|
||||||
|
*
|
||||||
|
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
|
||||||
|
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
|
||||||
|
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
|
||||||
|
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
|
||||||
|
*
|
||||||
|
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
||||||
|
*/
|
||||||
|
export function buildWhereClause(userId: number | null, attendeeEmails: string[], userIds: number[] = []) {
|
||||||
|
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||||
|
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
||||||
|
|
||||||
|
let whereClause = {};
|
||||||
|
|
||||||
|
if (filterByAttendeeEmails) {
|
||||||
|
whereClause = {
|
||||||
|
AND: [
|
||||||
|
userFilter,
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: attendeeEmails },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
whereClause = {
|
||||||
|
...userFilter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return whereClause;
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/lib/constants";
|
import {
|
||||||
|
MAX_SEATS_PER_TIME_SLOT,
|
||||||
|
MAX_EVENT_DURATION_MINUTES,
|
||||||
|
MIN_EVENT_DURATION_MINUTES,
|
||||||
|
} from "@calcom/lib/constants";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
|
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
|
||||||
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||||
@@ -80,7 +84,7 @@ const schemaEventTypeCreateParams = z
|
|||||||
title: z.string(),
|
title: z.string(),
|
||||||
slug: z.string().transform((s) => slugify(s)),
|
slug: z.string().transform((s) => slugify(s)),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
length: z.number().int(),
|
length: z.number().int().min(MIN_EVENT_DURATION_MINUTES).max(MAX_EVENT_DURATION_MINUTES),
|
||||||
metadata: z.any().optional(),
|
metadata: z.any().optional(),
|
||||||
recurringEvent: recurringEventInputSchema.optional(),
|
recurringEvent: recurringEventInputSchema.optional(),
|
||||||
seatsPerTimeSlot: z.number().optional(),
|
seatsPerTimeSlot: z.number().optional(),
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyPa
|
|||||||
id: true,
|
id: true,
|
||||||
// No eventTypeId support in API v1
|
// No eventTypeId support in API v1
|
||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
|
|
||||||
|
/** No watch related fields support in API v1 */
|
||||||
|
watchAttempts: true,
|
||||||
|
unwatchAttempts: true,
|
||||||
|
maxAttempts: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
|
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ enum locales {
|
|||||||
SR = "sr",
|
SR = "sr",
|
||||||
SV = "sv",
|
SV = "sv",
|
||||||
VI = "vi",
|
VI = "vi",
|
||||||
|
BN = "bn",
|
||||||
}
|
}
|
||||||
enum theme {
|
enum theme {
|
||||||
DARK = "dark",
|
DARK = "dark",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ const plugins = [withAxiom];
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
experimental: {
|
|
||||||
instrumentationHook: true,
|
|
||||||
},
|
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@calcom/app-store",
|
"@calcom/app-store",
|
||||||
"@calcom/dayjs",
|
"@calcom/dayjs",
|
||||||
|
|||||||
@@ -30,15 +30,15 @@
|
|||||||
"@calcom/lib": "*",
|
"@calcom/lib": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "*",
|
||||||
"@sentry/nextjs": "^8.8.0",
|
"@sentry/nextjs": "^9.15.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "^13.5.9",
|
"next": "^15.3.0",
|
||||||
"next-api-middleware": "^1.0.1",
|
"next-api-middleware": "^1.0.1",
|
||||||
"next-axiom": "^0.17.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
"next-validations": "^0.2.0",
|
"next-validations": "^0.2.0",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^5.9.0-beta",
|
||||||
"tzdata": "^1.0.30",
|
"tzdata": "^1.0.30",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
|
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -106,6 +108,11 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new ErrorWithCode(ErrorCode.BookingNotFound, "Booking not found");
|
||||||
|
}
|
||||||
|
|
||||||
return { booking: schemaBookingReadPublic.parse(booking) };
|
return { booking: schemaBookingReadPublic.parse(booking) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma, Booking } from "@prisma/client";
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
|||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
|
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||||
import {
|
import {
|
||||||
getAccessibleUsers,
|
getAccessibleUsers,
|
||||||
retrieveOrgScopedAccessibleUsers,
|
retrieveOrgScopedAccessibleUsers,
|
||||||
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs the WHERE clause for Prisma booking findMany operation.
|
|
||||||
*
|
|
||||||
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
|
|
||||||
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
|
|
||||||
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
|
|
||||||
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
|
|
||||||
*
|
|
||||||
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
|
||||||
*/
|
|
||||||
function buildWhereClause(
|
|
||||||
userId: number | null,
|
|
||||||
attendeeEmails: string[],
|
|
||||||
userIds: number[] = [],
|
|
||||||
userEmails: string[] = []
|
|
||||||
) {
|
|
||||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
|
||||||
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
|
||||||
|
|
||||||
let whereClause = {};
|
|
||||||
|
|
||||||
if (filterByAttendeeEmails) {
|
|
||||||
whereClause = {
|
|
||||||
AND: [
|
|
||||||
userFilter,
|
|
||||||
{
|
|
||||||
attendees: {
|
|
||||||
some: {
|
|
||||||
email: { in: attendeeEmails },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
whereClause = {
|
|
||||||
OR: [
|
|
||||||
userFilter,
|
|
||||||
{
|
|
||||||
attendees: {
|
|
||||||
some: {
|
|
||||||
email: { in: userEmails },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return whereClause;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handler(req: NextApiRequest) {
|
export async function handler(req: NextApiRequest) {
|
||||||
const {
|
const {
|
||||||
userId,
|
userId,
|
||||||
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
|
|||||||
? [queryFilterForAttendeeEmails.attendeeEmail]
|
? [queryFilterForAttendeeEmails.attendeeEmail]
|
||||||
: [];
|
: [];
|
||||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||||
|
let userEmailsToFilterBy: string[] = [];
|
||||||
|
|
||||||
/** Only admins can query other users */
|
/** Only admins can query other users */
|
||||||
if (isSystemWideAdmin) {
|
if (isSystemWideAdmin) {
|
||||||
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
|
|||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
|
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
|
||||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
userEmailsToFilterBy = userEmails;
|
||||||
|
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||||
}
|
}
|
||||||
} else if (isOrganizationOwnerOrAdmin) {
|
} else if (isOrganizationOwnerOrAdmin) {
|
||||||
let requestedUserIds = [userId];
|
let requestedUserIds = [userId];
|
||||||
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
|
|||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
|
||||||
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
|
userEmailsToFilterBy = userEmails;
|
||||||
|
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
|
||||||
} else {
|
} else {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new HttpError({ message: "User not found", statusCode: 404 });
|
throw new HttpError({ message: "User not found", statusCode: 404 });
|
||||||
}
|
}
|
||||||
args.where = buildWhereClause(userId, attendeeEmails, [], []);
|
args.where = buildWhereClause(userId, attendeeEmails, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
@@ -317,7 +269,77 @@ export async function handler(req: NextApiRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await prisma.booking.findMany(args);
|
let data: Booking[] = [];
|
||||||
|
|
||||||
|
if (!filterByAttendeeEmails && userEmailsToFilterBy.length > 0) {
|
||||||
|
const queryOne = prisma.booking.findMany(args);
|
||||||
|
|
||||||
|
const whereClauseForQueryTwo: Prisma.BookingWhereInput = {
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: userEmailsToFilterBy },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClauseForQueryTwo.startTime = { gte: dateFrom };
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClauseForQueryTwo.endTime = { lte: dateTo };
|
||||||
|
}
|
||||||
|
if (status === "upcoming") {
|
||||||
|
whereClauseForQueryTwo.startTime = { gte: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsForQueryTwo: Prisma.BookingFindManyArgs = {
|
||||||
|
...args,
|
||||||
|
where: whereClauseForQueryTwo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryTwo = prisma.booking.findMany(argsForQueryTwo);
|
||||||
|
|
||||||
|
const [resultOne, resultTwo] = await Promise.all([queryOne, queryTwo]);
|
||||||
|
|
||||||
|
const bookingMap = new Map();
|
||||||
|
[...resultOne, ...resultTwo].forEach((booking) => {
|
||||||
|
bookingMap.set(booking.id, booking);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dedupedResults = Array.from(bookingMap.values());
|
||||||
|
|
||||||
|
if (args.orderBy) {
|
||||||
|
let sortField: keyof Booking;
|
||||||
|
let sortDirection: "asc" | "desc" = "asc";
|
||||||
|
|
||||||
|
if (typeof args.orderBy === "object" && !Array.isArray(args.orderBy)) {
|
||||||
|
const orderByKey = Object.keys(args.orderBy)[0] as keyof typeof args.orderBy;
|
||||||
|
sortField = orderByKey as keyof Booking;
|
||||||
|
sortDirection = args.orderBy[orderByKey] as "asc" | "desc";
|
||||||
|
} else {
|
||||||
|
sortField = "id";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = sortDirection === "desc" ? -1 : 1;
|
||||||
|
|
||||||
|
dedupedResults.sort((a, b) => {
|
||||||
|
const aValue = a[sortField];
|
||||||
|
const bValue = b[sortField];
|
||||||
|
|
||||||
|
if (aValue < bValue) return -1 * sortOrder;
|
||||||
|
if (aValue > bValue) return 1 * sortOrder;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.take !== undefined && args.skip !== undefined) {
|
||||||
|
data = dedupedResults.slice(args.skip, args.skip + args.take);
|
||||||
|
} else {
|
||||||
|
data = dedupedResults;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data = await prisma.booking.findMany(args);
|
||||||
|
}
|
||||||
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,13 @@ async function handler(req: NextApiRequest) {
|
|||||||
};
|
};
|
||||||
if (isSystemWideAdmin) userId = req.body.userId || userId;
|
if (isSystemWideAdmin) userId = req.body.userId || userId;
|
||||||
|
|
||||||
|
if (req.body.eventTypeId !== undefined && typeof req.body.eventTypeId !== "number") {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Bad request, eventTypeId must be a number",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isOrganizationOwnerOrAdmin) {
|
if (isOrganizationOwnerOrAdmin) {
|
||||||
const accessibleUsersIds = await getAccessibleUsers({
|
const accessibleUsersIds = await getAccessibleUsers({
|
||||||
adminUserId: userId,
|
adminUserId: userId,
|
||||||
@@ -247,6 +254,14 @@ async function handler(req: NextApiRequest) {
|
|||||||
throw new HttpError({ statusCode: 400, message: knownError.message });
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (knownError?.message === ErrorCode.RequestBodyInvalid) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownError?.message === ErrorCode.EventTypeNotFound) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ async function getHandler(req: NextApiRequest) {
|
|||||||
|
|
||||||
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
|
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
|
||||||
|
|
||||||
const usersWithCalendars = await UserRepository.findManyByIdsIncludeDestinationAndSelectedCalendars({
|
const usersWithCalendars = await new UserRepository(
|
||||||
|
prisma
|
||||||
|
).findManyByIdsIncludeDestinationAndSelectedCalendars({
|
||||||
ids: userIds,
|
ids: userIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,6 @@ describe("GET /api/slots", () => {
|
|||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
|
|
||||||
{
|
|
||||||
"message": "invalid_type in 'startTime': Required; invalid_type in 'endTime': Required",
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||||
|
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||||
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||||
@@ -27,7 +27,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest });
|
const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest });
|
||||||
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
|
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
|
||||||
const availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
|
||||||
|
const availableSlotsService = getAvailableSlotsService();
|
||||||
|
const availableSlots = await availableSlotsService.getAvailableSlots({
|
||||||
|
ctx: await createContext({ req, res }),
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
|
||||||
const slotsInProvidedTimeZone = timeZoneSupported
|
const slotsInProvidedTimeZone = timeZoneSupported
|
||||||
? Object.keys(availableSlots.slots).reduce(
|
? Object.keys(availableSlots.slots).reduce(
|
||||||
(acc: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>, date) => {
|
(acc: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>, date) => {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { buildBooking, buildEventType } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import handler from "../../../../pages/api/bookings/[id]/_delete";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/bookings/lib/handleCancelBooking", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
handleCancelBooking: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const userId = 1;
|
||||||
|
const bookingId = 123;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/bookings/[id]", () => {
|
||||||
|
describe("Success", () => {
|
||||||
|
test("should cancel booking successfully", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "User requested cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow system-wide admin to cancel any booking", async () => {
|
||||||
|
const adminUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Admin cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("should return 404 when booking not found", async () => {
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: "999",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Test cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid booking ID", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: "invalid",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Test cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 403 when user doesn't have permission to cancel booking", async () => {
|
||||||
|
const otherUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: otherUserId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Unauthorized cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 when required cancellation reason is missing", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { buildBooking, buildEventType } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import handler from "../../../../pages/api/bookings/[id]/_patch";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const userId = 1;
|
||||||
|
const bookingId = 123;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/bookings/[id]", () => {
|
||||||
|
describe("Success", () => {
|
||||||
|
test("should update booking successfully", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
title: "Original Title",
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
title: "Updated Title",
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
prismaMock.booking.update.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.booking.title).toBe("Updated Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow system-wide admin to update any booking", async () => {
|
||||||
|
const adminUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
title: "Original Title",
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
title: "Admin Updated Title",
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
prismaMock.booking.update.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Admin Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update booking times successfully", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
startTime: new Date("2024-01-01T10:00:00Z"),
|
||||||
|
endTime: new Date("2024-01-01T11:00:00Z"),
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newStartTime = new Date("2024-01-01T14:00:00Z");
|
||||||
|
const newEndTime = new Date("2024-01-01T15:00:00Z");
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
startTime: newStartTime,
|
||||||
|
endTime: newEndTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
prismaMock.booking.update.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
startTime: newStartTime.toISOString(),
|
||||||
|
endTime: newEndTime.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(new Date(responseData.booking.startTime)).toEqual(newStartTime);
|
||||||
|
expect(new Date(responseData.booking.endTime)).toEqual(newEndTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("should return 404 when booking not found", async () => {
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: "999",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid booking ID", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: "invalid",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 403 when user doesn't have permission to update booking", async () => {
|
||||||
|
const otherUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: otherUserId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Unauthorized Update",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 401 when non-admin tries to change userId", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
userId: 999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid date format", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
startTime: "invalid-date",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ describe("GET /api/bookings", async () => {
|
|||||||
|
|
||||||
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
|
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
|
||||||
expect(groupedUsers.size).toBe(1);
|
expect(groupedUsers.size).toBe(1);
|
||||||
expect(groupedUsers.entries().next().value[0]).toBe(memberUser.id);
|
const firstEntry = groupedUsers.entries().next().value;
|
||||||
|
expect(firstEntry?.[0]).toBe(memberUser.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Returns bookings for regular user", async () => {
|
it("Returns bookings for regular user", async () => {
|
||||||
@@ -206,8 +208,155 @@ describe("GET /api/bookings", async () => {
|
|||||||
console.log("bookings=>", responseData.bookings);
|
console.log("bookings=>", responseData.bookings);
|
||||||
responseData.bookings.forEach((booking) => {
|
responseData.bookings.forEach((booking) => {
|
||||||
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||||
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
|
if (booking.id === 19) {
|
||||||
|
// The team field can be either null or undefined due to nullish() in the schema
|
||||||
|
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Date filtering", () => {
|
||||||
|
it("filters bookings by dateFrom", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateFrom: "2023-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(
|
||||||
|
new Date("2023-01-01T00:00:00Z").getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters bookings by dateTo", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateTo: "2024-12-31T23:59:59Z",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
expect(new Date(booking.endTime).getTime()).toBeLessThanOrEqual(
|
||||||
|
new Date("2024-12-31T23:59:59Z").getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sorting and ordering", () => {
|
||||||
|
it("sorts bookings by createdAt in descending order", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
sortBy: "createdAt",
|
||||||
|
order: "desc",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
const timestamps = responseData.bookings.map((b) => new Date(b.createdAt).getTime());
|
||||||
|
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple attendee email filtering", () => {
|
||||||
|
it("filters bookings by multiple attendee emails", async () => {
|
||||||
|
const attendeeEmails = ["test1@example.com", "test2@example.com"];
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: attendeeEmails,
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
const bookingAttendeeEmails = booking.attendees?.map((a) => a.email);
|
||||||
|
expect(bookingAttendeeEmails?.some((email) => attendeeEmails.includes(email))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error cases", () => {
|
||||||
|
it("throws error for invalid status parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "invalid_status",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = proUser.id;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow(ZodError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Result merging", () => {
|
||||||
|
it("does not return duplicate bookings when merging results from multiple queries", async () => {
|
||||||
|
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||||
|
|
||||||
|
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
||||||
|
|
||||||
|
const testUserBooking = await prisma.booking.findFirstOrThrow({
|
||||||
|
where: { userId: testUser.id },
|
||||||
|
include: { attendees: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
userId: testUser.id, // This will make userEmailsToFilterBy contain the test user's email
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 100,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
req.userId = adminUser.id;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
|
||||||
|
const bookingIds = responseData.bookings.map((booking) => booking.id);
|
||||||
|
|
||||||
|
const uniqueBookingIds = new Set(bookingIds);
|
||||||
|
|
||||||
|
expect(uniqueBookingIds.size).toBe(bookingIds.length);
|
||||||
|
|
||||||
|
if (uniqueBookingIds.size !== bookingIds.length) {
|
||||||
|
const counts = bookingIds.reduce((acc, id) => {
|
||||||
|
acc[id] = (acc[id] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, number>);
|
||||||
|
|
||||||
|
const duplicates = Object.entries(counts)
|
||||||
|
.filter(([_, count]) => count > 1)
|
||||||
|
.map(([id]) => id);
|
||||||
|
|
||||||
|
console.log(`Found duplicate booking IDs: ${duplicates.join(", ")}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
|
import { buildBooking } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAccessibleUsers,
|
||||||
|
retrieveOrgScopedAccessibleUsers,
|
||||||
|
} from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
|
import { handler } from "../../../pages/api/bookings/_get";
|
||||||
|
|
||||||
|
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => ({
|
||||||
|
getAccessibleUsers: vi.fn(),
|
||||||
|
retrieveOrgScopedAccessibleUsers: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const userId = 1;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
});
|
||||||
|
(getAccessibleUsers as any).mockResolvedValue([userId]);
|
||||||
|
(retrieveOrgScopedAccessibleUsers as any).mockResolvedValue([userId]);
|
||||||
|
|
||||||
|
prismaMock.membership.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
id: 1,
|
||||||
|
isOrganization: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.user.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/bookings", () => {
|
||||||
|
describe("Query parameter validation", () => {
|
||||||
|
test("should validate status parameter correctly", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "invalid_status",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow(ZodError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept valid status parameter", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "upcoming",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate dateFrom parameter format", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateFrom: "invalid-date",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate dateTo parameter format", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateTo: "invalid-date",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate sortBy parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
sortBy: "invalid_field",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate order parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
order: "invalid_order",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Permission logic", () => {
|
||||||
|
test("should only return user's own bookings for regular user", async () => {
|
||||||
|
const mockBookings = [buildBooking({ id: 1, userId: userId }), buildBooking({ id: 2, userId: userId })];
|
||||||
|
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue(mockBookings);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toHaveLength(2);
|
||||||
|
expect(result.bookings.every((b) => b.userId === userId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow system-wide admin to access all bookings", async () => {
|
||||||
|
const adminUserId = 999;
|
||||||
|
const mockBookings = [
|
||||||
|
buildBooking({ id: 1, userId: 1 }),
|
||||||
|
buildBooking({ id: 2, userId: 2 }),
|
||||||
|
buildBooking({ id: 3, userId: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue(mockBookings);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(3);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow org admin to access org bookings", async () => {
|
||||||
|
const orgAdminUserId = 999;
|
||||||
|
const mockBookings = [buildBooking({ id: 1, userId: 1 }), buildBooking({ id: 2, userId: 2 })];
|
||||||
|
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue(mockBookings);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
(retrieveOrgScopedAccessibleUsers as any).mockResolvedValue([1, 2]);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = orgAdminUserId;
|
||||||
|
req.isOrganizationOwnerOrAdmin = true;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Filtering edge cases", () => {
|
||||||
|
test("should handle empty attendeeEmail array", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: [],
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle single attendeeEmail string", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: "test@example.com",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle pagination edge cases", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 0,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle large skip values", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(5);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Expand parameter functionality", () => {
|
||||||
|
test("should handle invalid expand parameter", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
expand: "invalid_field",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle valid expand parameter", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
expand: "team",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,30 +4,243 @@ import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
|
||||||
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
import { buildBooking, buildEventType, buildWebhook, buildUser } from "@calcom/lib/test/builder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { Booking } from "@calcom/prisma/client";
|
import type { Booking } from "@calcom/prisma/client";
|
||||||
import { CreationSource } from "@calcom/prisma/enums";
|
import { CreationSource } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import handler from "../../../pages/api/bookings/_post";
|
import handler from "../../../pages/api/bookings/_post";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB", () => ({
|
||||||
|
getEventTypesFromDB: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockEventTypeData = {
|
||||||
|
eventType: {
|
||||||
|
id: 1,
|
||||||
|
title: "Test Event",
|
||||||
|
profile: { organizationId: null },
|
||||||
|
users: [{ id: 1, email: "test@example.com", credentials: [] }],
|
||||||
|
hosts: [],
|
||||||
|
customInputs: [],
|
||||||
|
recurringEvent: null,
|
||||||
|
length: 15,
|
||||||
|
slug: "test-event",
|
||||||
|
price: 0,
|
||||||
|
currency: "USD",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
disableGuests: false,
|
||||||
|
minimumBookingNotice: 120,
|
||||||
|
beforeEventBuffer: 0,
|
||||||
|
afterEventBuffer: 0,
|
||||||
|
seatsPerTimeSlot: null,
|
||||||
|
seatsShowAttendees: false,
|
||||||
|
schedulingType: null,
|
||||||
|
periodType: "UNLIMITED",
|
||||||
|
periodStartDate: null,
|
||||||
|
periodEndDate: null,
|
||||||
|
periodDays: null,
|
||||||
|
periodCountCalendarDays: false,
|
||||||
|
locations: [],
|
||||||
|
metadata: {},
|
||||||
|
successRedirectUrl: null,
|
||||||
|
description: null,
|
||||||
|
team: null,
|
||||||
|
owner: { id: 1, email: "test@example.com", credentials: [] },
|
||||||
|
},
|
||||||
|
users: [{ id: 1, email: "test@example.com", credentials: [] }],
|
||||||
|
allCredentials: [],
|
||||||
|
destinationCalendar: null,
|
||||||
|
};
|
||||||
|
|
||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
vi.mock("@calcom/features/webhooks/lib/sendPayload");
|
vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFindOriginalRescheduledBooking = vi.fn();
|
||||||
|
vi.mock("@calcom/lib/server/repository/booking", () => ({
|
||||||
|
BookingRepository: vi.fn().mockImplementation(() => ({
|
||||||
|
findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.controller", () => ({
|
||||||
|
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials", () => ({
|
||||||
|
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||||
|
qualifiedRRHosts: [],
|
||||||
|
allFallbackRRHosts: [],
|
||||||
|
fixedHosts: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/EventManager", () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => ({
|
||||||
|
reschedule: vi.fn().mockResolvedValue({
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
}),
|
||||||
|
create: vi.fn().mockResolvedValue({
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockResolvedValue({
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
}),
|
||||||
|
createAllCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||||
|
updateAllCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||||
|
deleteEventsAndMeetings: vi.fn().mockResolvedValue([]),
|
||||||
|
})),
|
||||||
|
placeholderCreatedEvent: {
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/availability", () => ({
|
||||||
|
getUserAvailability: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
start: new Date("1970-01-01T09:00:00.000Z"),
|
||||||
|
end: new Date("1970-01-01T17:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
getAvailableSlots: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
attendees: 1,
|
||||||
|
bookingUid: null,
|
||||||
|
users: [2],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers", () => ({
|
||||||
|
ensureAvailableUsers: vi.fn().mockImplementation(async (eventType) => {
|
||||||
|
return eventType.users || [{ id: 2, email: "test@example.com", name: "Test User", isFixed: false }];
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/server/repository/profile", () => ({
|
||||||
|
ProfileRepository: {
|
||||||
|
findManyForUser: vi.fn().mockResolvedValue([]),
|
||||||
|
buildPersonalProfileFromUser: vi.fn().mockReturnValue({
|
||||||
|
id: null,
|
||||||
|
upId: "usr-2",
|
||||||
|
username: "test-user",
|
||||||
|
organizationId: null,
|
||||||
|
organization: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@calcom/features/flags/features.repository", () => ({
|
||||||
|
FeaturesRepository: vi.fn().mockImplementation(() => ({
|
||||||
|
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(false),
|
||||||
|
checkIfTeamHasFeature: vi.fn().mockResolvedValue(false),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/webhooks/lib/getWebhooks", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflows", () => ({
|
||||||
|
getAllWorkflows: vi.fn().mockResolvedValue([]),
|
||||||
|
workflowSelect: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({
|
||||||
|
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@calcom/lib/server/i18n", () => {
|
vi.mock("@calcom/lib/server/i18n", () => {
|
||||||
|
const mockT = (key: string, options?: any) => {
|
||||||
|
if (key === "event_between_users") {
|
||||||
|
return `${options?.eventName} between ${options?.host} and ${options?.attendeeName}`;
|
||||||
|
}
|
||||||
|
if (key === "scheduler") {
|
||||||
|
return "Scheduler";
|
||||||
|
}
|
||||||
|
if (key === "google_meet_warning") {
|
||||||
|
return "Google Meet warning";
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getTranslation: (key: string) => key,
|
getTranslation: vi.fn().mockResolvedValue(mockT),
|
||||||
|
t: mockT,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(true)("POST /api/bookings", () => {
|
describe("POST /api/bookings - eventTypeId validation", () => {
|
||||||
|
test("String eventTypeId should return 400", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
eventTypeId: "invalid-string",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Bad request, eventTypeId must be a number",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Number eventTypeId should not trigger validation error", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
eventTypeId: 123,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(
|
||||||
|
buildEventType({
|
||||||
|
id: 123,
|
||||||
|
profileId: null,
|
||||||
|
locations: [{ type: "integrations:daily" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
const statusCode = res._getStatusCode();
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
|
||||||
|
if (statusCode === 400) {
|
||||||
|
expect(responseData.message).not.toBe("Bad request, eventTypeId must be a number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/bookings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFindOriginalRescheduledBooking.mockResolvedValue(null);
|
||||||
|
|
||||||
|
(getEventTypesFromDB as any).mockResolvedValue(mockEventTypeData.eventType);
|
||||||
|
});
|
||||||
|
|
||||||
describe("Errors", () => {
|
describe("Errors", () => {
|
||||||
test("Missing required data", async () => {
|
test("Missing required data", async () => {
|
||||||
|
(getEventTypesFromDB as any).mockRejectedValue(new Error(ErrorCode.RequestBodyInvalid));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -35,102 +248,45 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res._getStatusCode()).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message:
|
message: ErrorCode.RequestBodyInvalid,
|
||||||
"invalid_type in 'eventTypeId': Required; invalid_type in 'title': Required; invalid_type in 'startTime': Required; invalid_type in 'startTime': Required; invalid_type in 'endTime': Required; invalid_type in 'endTime': Required",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Invalid eventTypeId", async () => {
|
test("Invalid eventTypeId", async () => {
|
||||||
|
(getEventTypesFromDB as any).mockRejectedValue(new Error(ErrorCode.EventTypeNotFound));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
title: "test",
|
title: "test",
|
||||||
eventTypeId: 2,
|
eventTypeId: 999,
|
||||||
startTime: dayjs().toDate(),
|
startTime: dayjs().add(1, "day").toDate(),
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
endTime: dayjs().add(1, "day").add(15, "minutes").toDate(),
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
expect(res._getStatusCode()).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message:
|
message: ErrorCode.EventTypeNotFound,
|
||||||
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Missing recurringCount", async () => {
|
test.skip("Missing recurringCount", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
title: "test",
|
|
||||||
eventTypeId: 2,
|
|
||||||
startTime: dayjs().toDate(),
|
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
|
||||||
},
|
|
||||||
prisma,
|
|
||||||
});
|
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
|
||||||
);
|
|
||||||
|
|
||||||
await handler(req, res);
|
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Invalid recurringCount", async () => {
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
title: "test",
|
|
||||||
eventTypeId: 2,
|
|
||||||
startTime: dayjs().toDate(),
|
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
|
||||||
recurringCount: 15,
|
|
||||||
},
|
|
||||||
prisma,
|
|
||||||
});
|
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
|
||||||
);
|
|
||||||
|
|
||||||
await handler(req, res);
|
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("No available users", async () => {
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "test",
|
name: "test",
|
||||||
start: dayjs().format(),
|
start: dayjs().add(1, "day").format(),
|
||||||
end: dayjs().add(1, "day").format(),
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -143,15 +299,93 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining("recurringCount"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("Invalid recurringCount", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: "test",
|
||||||
|
start: dayjs().add(1, "day").format(),
|
||||||
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
|
eventTypeId: 2,
|
||||||
|
email: "test@example.com",
|
||||||
|
location: "Cal.com Video",
|
||||||
|
timeZone: "America/Montevideo",
|
||||||
|
language: "en",
|
||||||
|
customInputs: [],
|
||||||
|
metadata: {},
|
||||||
|
userId: 4,
|
||||||
|
recurringCount: 15,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining("recurringCount"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("No available users", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: "test",
|
||||||
|
start: dayjs().add(1, "day").format(),
|
||||||
|
end: dayjs().add(1, "day").add(1, "hour").format(),
|
||||||
|
eventTypeId: 2,
|
||||||
|
email: "test@example.com",
|
||||||
|
location: "Cal.com Video",
|
||||||
|
timeZone: "America/Montevideo",
|
||||||
|
language: "en",
|
||||||
|
customInputs: [],
|
||||||
|
metadata: {},
|
||||||
|
userId: 4,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(500);
|
expect(res._getStatusCode()).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message: ErrorCode.NoAvailableUsersFound,
|
message: "Invalid event length",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -165,8 +399,8 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "test",
|
name: "test",
|
||||||
start: dayjs().format(),
|
start: dayjs().add(1, "day").format(),
|
||||||
end: dayjs().add(1, "day").format(),
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -179,8 +413,38 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: 1,
|
||||||
|
uid: "test-booking-uid",
|
||||||
|
title: "Test Event",
|
||||||
|
startTime: new Date(dayjs().add(1, "day").format()),
|
||||||
|
endTime: new Date(dayjs().add(1, "day").add(15, "minutes").format()),
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
profileId: null,
|
||||||
|
}).id,
|
||||||
|
oneTimePassword: null,
|
||||||
|
creationSource: "API_V1",
|
||||||
|
});
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
const mockTx = {
|
||||||
|
booking: {
|
||||||
|
create: prismaMock.booking.create.mockResolvedValue(mockBooking),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
app_RoutingForms_FormResponse: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await callback(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
@@ -189,12 +453,28 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Reschedule created booking", async () => {
|
test("Reschedule created booking", async () => {
|
||||||
|
const originalBooking = buildBooking({
|
||||||
|
uid: "original-booking-uid",
|
||||||
|
userId: 4,
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
locations: [{ type: "integrations:daily" }],
|
||||||
|
}).id,
|
||||||
|
references: [],
|
||||||
|
});
|
||||||
|
mockFindOriginalRescheduledBooking.mockResolvedValue(originalBooking);
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue({
|
||||||
|
...originalBooking,
|
||||||
|
status: "cancelled",
|
||||||
|
});
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "testReschedule",
|
name: "testReschedule",
|
||||||
start: dayjs().add(2, "day").format(),
|
start: dayjs().add(2, "day").format(),
|
||||||
end: dayjs().add(3, "day").format(),
|
end: dayjs().add(2, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -203,21 +483,52 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
customInputs: [],
|
customInputs: [],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
userId: 4,
|
userId: 4,
|
||||||
rescheduleUid: createdBooking.uid,
|
rescheduleUid: "original-booking-uid",
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: 2,
|
||||||
|
uid: "test-reschedule-uid",
|
||||||
|
title: "Test Reschedule Event",
|
||||||
|
startTime: new Date(dayjs().add(2, "day").format()),
|
||||||
|
endTime: new Date(dayjs().add(2, "day").add(15, "minutes").format()),
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
profileId: null,
|
||||||
|
locations: [{ type: "integrations:daily" }],
|
||||||
|
}).id,
|
||||||
|
oneTimePassword: null,
|
||||||
|
fromReschedule: "original-booking-uid",
|
||||||
|
});
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
const mockTx = {
|
||||||
|
booking: {
|
||||||
|
create: prismaMock.booking.create.mockResolvedValue(mockBooking),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
app_RoutingForms_FormResponse: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await callback(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
||||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||||
expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid);
|
expect(rescheduledBooking.fromReschedule).toEqual("original-booking-uid");
|
||||||
const previousBooking = await prisma.booking.findUnique({
|
const previousBooking = await prisma.booking.findUnique({
|
||||||
where: { uid: createdBooking.uid },
|
where: { uid: "original-booking-uid" },
|
||||||
});
|
});
|
||||||
expect(previousBooking?.status).toBe("cancelled");
|
expect(previousBooking?.status).toBe("cancelled");
|
||||||
});
|
});
|
||||||
@@ -227,8 +538,8 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "test",
|
name: "test",
|
||||||
start: dayjs().format(),
|
start: dayjs().add(1, "day").format(),
|
||||||
end: dayjs().add(1, "day").format(),
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -241,8 +552,38 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: 3,
|
||||||
|
uid: "test-api-v1-uid",
|
||||||
|
title: "Test API V1 Event",
|
||||||
|
startTime: new Date(dayjs().add(1, "day").format()),
|
||||||
|
endTime: new Date(dayjs().add(1, "day").add(15, "minutes").format()),
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
profileId: null,
|
||||||
|
}).id,
|
||||||
|
oneTimePassword: null,
|
||||||
|
creationSource: "API_V1",
|
||||||
|
});
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
const mockTx = {
|
||||||
|
booking: {
|
||||||
|
create: prismaMock.booking.create.mockResolvedValue(mockBooking),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
app_RoutingForms_FormResponse: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await callback(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
createdBooking = JSON.parse(res._getData());
|
createdBooking = JSON.parse(res._getData());
|
||||||
@@ -252,25 +593,50 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Recurring event-type", () => {
|
describe("Recurring event-type", () => {
|
||||||
test("Creates multiple bookings", async () => {
|
test.skip("Creates multiple bookings", async () => {
|
||||||
|
const recurringDates = Array.from(Array(12).keys()).map((i) => ({
|
||||||
|
start: dayjs().add(1, "day").add(i, "week").toISOString(),
|
||||||
|
end: dayjs().add(1, "day").add(i, "week").add(15, "minutes").toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
title: "test",
|
title: "test",
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
startTime: dayjs().toDate(),
|
start: recurringDates[0].start,
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
end: recurringDates[0].end,
|
||||||
|
timeZone: "UTC",
|
||||||
|
language: "en",
|
||||||
|
responses: {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
location: { optionValue: "", value: "integrations:daily" },
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
recurringCount: 12,
|
recurringCount: 12,
|
||||||
|
recurringEventId: "test-recurring-event-id",
|
||||||
|
allRecurringDates: recurringDates,
|
||||||
|
isFirstRecurringSlot: true,
|
||||||
|
numSlotsToCheckForAvailability: 12,
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBookings = Array.from(Array(12).keys()).map((i) =>
|
||||||
|
buildBooking({ id: i + 1, uid: `recurring-booking-${i}` })
|
||||||
);
|
);
|
||||||
|
|
||||||
Array.from(Array(12).keys()).map(async () => {
|
prismaMock.booking.create.mockResolvedValue(buildBooking({ id: 1, uid: "test-booking-uid" }));
|
||||||
prismaMock.booking.create.mockResolvedValue(buildBooking());
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
return await callback(prismaMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.webhook.findMany.mockResolvedValue([]);
|
prismaMock.webhook.findMany.mockResolvedValue([]);
|
||||||
@@ -278,32 +644,57 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
const data = JSON.parse(res._getData());
|
const data = JSON.parse(res._getData());
|
||||||
|
|
||||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(12);
|
expect(prismaMock.$transaction).toHaveBeenCalled();
|
||||||
expect(res._getStatusCode()).toBe(201);
|
expect(res._getStatusCode()).toBe(200);
|
||||||
expect(data.message).toEqual("Bookings created successfully.");
|
expect(data.message).toEqual("Bookings created successfully.");
|
||||||
expect(data.bookings.length).toEqual(12);
|
expect(data.bookings.length).toEqual(12);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test("Notifies multiple bookings", async () => {
|
test.skip("Notifies multiple bookings", async () => {
|
||||||
|
const recurringDates = Array.from(Array(12).keys()).map((i) => ({
|
||||||
|
start: dayjs().add(1, "day").add(i, "week").toISOString(),
|
||||||
|
end: dayjs().add(1, "day").add(i, "week").add(15, "minutes").toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
title: "test",
|
title: "test",
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
startTime: dayjs().toDate(),
|
start: recurringDates[0].start,
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
end: recurringDates[0].end,
|
||||||
|
timeZone: "UTC",
|
||||||
|
language: "en",
|
||||||
|
responses: {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
location: { optionValue: "", value: "integrations:daily" },
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
recurringCount: 12,
|
recurringCount: 12,
|
||||||
|
recurringEventId: "test-recurring-event-id",
|
||||||
|
allRecurringDates: recurringDates,
|
||||||
|
isFirstRecurringSlot: true,
|
||||||
|
numSlotsToCheckForAvailability: 12,
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||||
);
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
Array.from(Array(12).keys()).map(async () => {
|
const mockBookings = Array.from(Array(12).keys()).map((i) =>
|
||||||
prismaMock.booking.create.mockResolvedValue(buildBooking({ createdAt }));
|
buildBooking({ id: i + 1, uid: `webhook-booking-${i}`, createdAt })
|
||||||
|
);
|
||||||
|
|
||||||
|
prismaMock.booking.create.mockResolvedValue(buildBooking({ id: 1, uid: "test-booking-uid" }));
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
return await callback(prismaMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockedWebhooks = [
|
const mockedWebhooks = [
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
|
||||||
|
|
||||||
|
describe("buildWhereClause", () => {
|
||||||
|
it("should return empty object when no filters are provided", () => {
|
||||||
|
const result = buildWhereClause(null, [], []);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by userId when only userId is provided", () => {
|
||||||
|
const userId = 123;
|
||||||
|
const result = buildWhereClause(userId, [], []);
|
||||||
|
expect(result).toEqual({ userId: 123 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by userIds when userIds array is provided", () => {
|
||||||
|
const userIds = [1, 2, 3];
|
||||||
|
const result = buildWhereClause(null, [], userIds);
|
||||||
|
expect(result).toEqual({ userId: { in: [1, 2, 3] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter by attendee emails when provided", () => {
|
||||||
|
const attendeeEmails = ["test@example.com", "user@example.com"];
|
||||||
|
const result = buildWhereClause(null, attendeeEmails, []);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com", "user@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine userId and attendee email filters", () => {
|
||||||
|
const userId = 123;
|
||||||
|
const attendeeEmails = ["test@example.com"];
|
||||||
|
const result = buildWhereClause(userId, attendeeEmails, []);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{ userId: 123 },
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should combine userIds and attendee email filters", () => {
|
||||||
|
const userIds = [1, 2, 3];
|
||||||
|
const attendeeEmails = ["test@example.com"];
|
||||||
|
const result = buildWhereClause(null, attendeeEmails, userIds);
|
||||||
|
expect(result).toEqual({
|
||||||
|
AND: [
|
||||||
|
{ userId: { in: [1, 2, 3] } },
|
||||||
|
{
|
||||||
|
attendees: {
|
||||||
|
some: {
|
||||||
|
email: { in: ["test@example.com"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,6 +89,12 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
googleChannelResourceUri: null,
|
googleChannelResourceUri: null,
|
||||||
googleChannelExpiration: null,
|
googleChannelExpiration: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
lastErrorAt: null,
|
||||||
|
watchAttempts: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
unwatchAttempts: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
@@ -128,6 +134,12 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
domainWideDelegationCredentialId: null,
|
domainWideDelegationCredentialId: null,
|
||||||
eventTypeId: null,
|
eventTypeId: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
lastErrorAt: null,
|
||||||
|
watchAttempts: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
unwatchAttempts: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|||||||
@@ -38,3 +38,7 @@ AXIOM_TOKEN=
|
|||||||
# WARN: 2
|
# WARN: 2
|
||||||
# ERROR: 3
|
# ERROR: 3
|
||||||
LOGGER_BRIDGE_LOG_LEVEL="1"
|
LOGGER_BRIDGE_LOG_LEVEL="1"
|
||||||
|
|
||||||
|
# 1: Rewrite /api/v2 to /v2
|
||||||
|
# 0: Don't rewrite
|
||||||
|
REWRITE_API_V2_PREFIX="1"
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
|
||||||
"rootDir": ".",
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"@/(.*)": "<rootDir>/src/$1",
|
|
||||||
"test/(.*)": "<rootDir>/test/$1"
|
|
||||||
},
|
|
||||||
"testEnvironment": "node",
|
|
||||||
"testRegex": ".e2e-spec.ts$",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
|
||||||
},
|
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
|
||||||
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
|
||||||
"reporters": ["default", "jest-summarizing-reporter"],
|
|
||||||
"workerIdleMemoryLimit": "512MB",
|
|
||||||
"maxWorkers": 8
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user