Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b34aec13d | ||
|
|
7ab8fdbcb4 | ||
|
|
b0bf87f3ed | ||
|
|
1490642260 | ||
|
|
04b49acbb0 | ||
|
|
ff79e5c70e | ||
|
|
5d51c79739 | ||
|
|
8bc2c08288 | ||
|
|
ade144aa20 | ||
|
|
fcd06c80e3 | ||
|
|
57bbba3d95 | ||
|
|
d952e77b78 | ||
|
|
73989da3b7 | ||
|
|
a534d39351 | ||
|
|
c16ef798f5 | ||
|
|
58cac84f4a | ||
|
|
1404581f78 | ||
|
|
ff18b9a733 | ||
|
|
82e52a1948 | ||
|
|
e3abd35ba7 | ||
|
|
dfdb04acf4 | ||
|
|
352a6ba8cf | ||
|
|
997d6a75e8 | ||
|
|
757e3a2faf | ||
|
|
1b457f82a1 | ||
|
|
16573717b4 | ||
|
|
97b920744e | ||
|
|
6cfe9ff88d | ||
|
|
7c5292e13b | ||
|
|
3cd4e34406 | ||
|
|
a99eda21af | ||
|
|
03e2c6278d | ||
|
|
32aaa26bf1 | ||
|
|
7c0ee9f186 | ||
|
|
50df22928b | ||
|
|
fbda8a38cb | ||
|
|
8194ffcd07 | ||
|
|
7b94822c8d | ||
|
|
9aa8f8a30c | ||
|
|
e206ead611 | ||
|
|
ecac331fd1 | ||
|
|
fb4cd7f0e4 | ||
|
|
f743db9eaa | ||
|
|
0f3773d724 | ||
|
|
e6633050ba | ||
|
|
f1e972a213 | ||
|
|
12e78a7350 | ||
|
|
277b3e0cf5 | ||
|
|
2b1041460c | ||
|
|
ce0d685849 | ||
|
|
cc5ce4e598 | ||
|
|
4123152bd5 | ||
|
|
1b4469f6e5 | ||
|
|
5401bcca4e | ||
|
|
f9afca2939 | ||
|
|
fc1d553b74 | ||
|
|
b8c2fdac44 | ||
|
|
e24c40f518 | ||
|
|
c5cefc3e61 | ||
|
|
2c57ad0288 | ||
|
|
af9bf14d9e | ||
|
|
b1a0cdc8e2 | ||
|
|
7fef3e7b85 | ||
|
|
384deab0ce | ||
|
|
22da681f8c | ||
|
|
f4f4afad39 | ||
|
|
1c05952077 | ||
|
|
1df70e75ed | ||
|
|
8962f6f08e | ||
|
|
6272c881c8 | ||
|
|
bdf94c7f48 | ||
|
|
b79c5e7c86 | ||
|
|
3eaccb8738 | ||
|
|
9273226fb4 | ||
|
|
7ce5886b4f | ||
|
|
e85c0e1a85 | ||
|
|
d294a74aad | ||
|
|
5012a16794 | ||
|
|
322ca2a12c | ||
|
|
9b79c037c2 | ||
|
|
f76696032e | ||
|
|
c6aa57177a | ||
|
|
7f2db48c72 | ||
|
|
c59e733b5a | ||
|
|
e0713d78b3 | ||
|
|
d868b325c4 | ||
|
|
f8f6c31e40 | ||
|
|
7246fa4084 | ||
|
|
039a0b7a85 | ||
|
|
12c5005305 | ||
|
|
1ff6465384 | ||
|
|
ae706bb6d6 | ||
|
|
538a39328e | ||
|
|
3589767099 | ||
|
|
ff2754f663 | ||
|
|
65484e7d01 | ||
|
|
4d942a454a | ||
|
|
7dbcb62724 | ||
|
|
c197c33fd0 | ||
|
|
815065d67c | ||
|
|
02cecd6707 | ||
|
|
81386d4ec0 | ||
|
|
204e411585 | ||
|
|
1ec1554cb5 | ||
|
|
241579eba6 | ||
|
|
8ccad29ab6 | ||
|
|
74b4d7a94a | ||
|
|
5c6e60b726 | ||
|
|
18c7c3c940 | ||
|
|
fbd113b976 | ||
|
|
c7f11c6795 | ||
|
|
84fb9bb371 | ||
|
|
1aac70f643 | ||
|
|
0dde8c1db9 | ||
|
|
0da3a9fa11 | ||
|
|
eb8f5bd194 | ||
|
|
82901fee94 | ||
|
|
4e0e3a47f5 | ||
|
|
dc400d80d0 | ||
|
|
6889592d4f | ||
|
|
95b833bc1c | ||
|
|
b06683458a | ||
|
|
01d764c6d3 | ||
|
|
49333addc2 | ||
|
|
6c40d29745 | ||
|
|
fc5ad53a6d | ||
|
|
4d38c6d0c7 | ||
|
|
cd15335eed | ||
|
|
aaa75100d4 | ||
|
|
ae9063804d | ||
|
|
30f9d9b980 | ||
|
|
42a264380a | ||
|
|
62aaba32cf | ||
|
|
aaf630f01f | ||
|
|
838e8b9e89 | ||
|
|
ce373c475c | ||
|
|
ed662b7956 | ||
|
|
2e49110ce6 | ||
|
|
fc35f9de85 | ||
|
|
9c67ee7375 | ||
|
|
a1477dc7e1 | ||
|
|
25d39b4273 | ||
|
|
4976e24e12 | ||
|
|
5ae6e1e983 | ||
|
|
6080ce0c85 | ||
|
|
79f0754bf7 | ||
|
|
540bd478dc | ||
|
|
9873e373dc | ||
|
|
52093862ce | ||
|
|
e101f35b4f | ||
|
|
54cc2dc094 | ||
|
|
88e57d48fe | ||
|
|
6619701c88 | ||
|
|
ebb8bb9c67 | ||
|
|
993bf23986 | ||
|
|
4b38d73120 | ||
|
|
8f92c9b884 | ||
|
|
edecc7e60e | ||
|
|
4eefb08a0d | ||
|
|
3f7e632d5c | ||
|
|
ef0fd67646 | ||
|
|
8e7567b6a3 | ||
|
|
193855d60f | ||
|
|
92fafa7344 | ||
|
|
21aaff01fa | ||
|
|
e07a28a2d1 | ||
|
|
9a1b5ad149 | ||
|
|
1c20a257d3 | ||
|
|
656fcafcde | ||
|
|
6d7080d9fc | ||
|
|
f943debda2 | ||
|
|
803fc488c5 | ||
|
|
423b284278 | ||
|
|
f1111c9d42 | ||
|
|
ed6d88e30f | ||
|
|
e675ad0c6c | ||
|
|
a01d6aa932 | ||
|
|
f868b86e6c | ||
|
|
db0a7f3797 | ||
|
|
5646229cd4 | ||
|
|
f39e817d86 | ||
|
|
42c4c134fa | ||
|
|
ffc4b69edd | ||
|
|
7263f3b00b | ||
|
|
ddfbe25903 | ||
|
|
dd944bace1 | ||
|
|
e4353af839 | ||
|
|
ff9936fdeb | ||
|
|
e74718ac66 | ||
|
|
a78f10223f | ||
|
|
c850af7409 | ||
|
|
06dccd7a73 | ||
|
|
3b59185f75 | ||
|
|
c907bcc646 | ||
|
|
6c5b12db57 | ||
|
|
bcc1a8e279 | ||
|
|
a277b8515f | ||
|
|
42a02cbb60 | ||
|
|
0c60ad1c82 | ||
|
|
1add8e73ee | ||
|
|
ff15ec77c3 | ||
|
|
b2d395de52 | ||
|
|
9dd35c7ab3 | ||
|
|
bd30bf335b | ||
|
|
ab65ce73c2 | ||
|
|
06f83e6d0d | ||
|
|
ebd5ca6ceb | ||
|
|
bcaf067ed6 | ||
|
|
120cfed8a1 | ||
|
|
e728ed74ce | ||
|
|
64551a506a | ||
|
|
fee3b73bda | ||
|
|
a5806cb100 | ||
|
|
f649f8b78d | ||
|
|
ced7f282a5 | ||
|
|
dfa4e10c83 | ||
|
|
c3f03c8000 | ||
|
|
aaa5531a3a | ||
|
|
2784564427 | ||
|
|
4b6b9badf5 | ||
|
|
5a9197f357 | ||
|
|
315077f6c5 | ||
|
|
b8b270e4e5 | ||
|
|
04a49e74d4 | ||
|
|
8605fb4518 | ||
|
|
55671db5fd | ||
|
|
1a55f2d8e2 | ||
|
|
b82b39d12d | ||
|
|
85246f0754 | ||
|
|
c5becb0990 | ||
|
|
13d4102dc8 | ||
|
|
8ab73caeb9 | ||
|
|
e6dbb19eff | ||
|
|
2efa593d2b | ||
|
|
dfc73ea307 | ||
|
|
ed6c94b941 | ||
|
|
f8cbf4693b | ||
|
|
7d51741de0 | ||
|
|
7e3f60bbdb | ||
|
|
f81024e3f0 | ||
|
|
097c14ca64 | ||
|
|
b9128a7a0b | ||
|
|
5c86522e28 | ||
|
|
b6ce06b515 | ||
|
|
13bc6d8fee | ||
|
|
c5402d8bd7 | ||
|
|
b53abe93bc | ||
|
|
377cae9fd8 | ||
|
|
fd1edae706 | ||
|
|
2fab54546e | ||
|
|
820230d61d | ||
|
|
af363e975a | ||
|
|
96571df308 | ||
|
|
9868926ec1 | ||
|
|
69ca57e45a | ||
|
|
9157e1faf3 | ||
|
|
87baf0363e | ||
|
|
4c6f242345 | ||
|
|
1c8cf62582 | ||
|
|
52e1a58904 | ||
|
|
b6cef49ee6 | ||
|
|
af94c64f46 | ||
|
|
e251a5afea | ||
|
|
62d162f001 | ||
|
|
8811ebcc33 | ||
|
|
c0529a26c4 | ||
|
|
a28d9bb840 | ||
|
|
06ec89e787 | ||
|
|
a4d733fb9d | ||
|
|
c3d56f736a | ||
|
|
c629d08a9c | ||
|
|
50a41109d0 | ||
|
|
ca5b83f876 | ||
|
|
c29e37a03f | ||
|
|
14ae664941 | ||
|
|
0949bbf167 | ||
|
|
d0a1e90199 | ||
|
|
077f29305d | ||
|
|
4c528afe65 | ||
|
|
57ed383554 | ||
|
|
d53cabd163 | ||
|
|
817d8738a9 | ||
|
|
d6dbbd1472 | ||
|
|
4021801698 | ||
|
|
b38d8e7ae4 | ||
|
|
123a39bd9a | ||
|
|
596dc3fbd7 | ||
|
|
058d58a6f3 | ||
|
|
4caec83c8f | ||
|
|
3ddef59761 | ||
|
|
c7edfd5bd3 | ||
|
|
23d1a21e23 | ||
|
|
e2c54b1fc4 | ||
|
|
622b2795f5 | ||
|
|
c0298248dc | ||
|
|
db07738514 | ||
|
|
700f0aeb35 | ||
|
|
f3fd043255 | ||
|
|
a0e4580f4e | ||
|
|
659b60aac0 | ||
|
|
7131996722 | ||
|
|
42cbd582f2 | ||
|
|
5481248c1a | ||
|
|
bd4a949a7c | ||
|
|
b768cc5b44 | ||
|
|
de80ca2aaa | ||
|
|
4afbe26506 | ||
|
|
f22d585c7d | ||
|
|
1ee4fa0906 | ||
|
|
1fc376c51a | ||
|
|
9382b862e4 | ||
|
|
c2662638a5 | ||
|
|
c2291164fa | ||
|
|
9d01025a1f | ||
|
|
86af6bd626 | ||
|
|
2f4dd328c3 | ||
|
|
6df994389b | ||
|
|
93e93a9c7a | ||
|
|
9087f5307b | ||
|
|
5f6032131b | ||
|
|
4e90b65884 | ||
|
|
b7792d0d0a | ||
|
|
c8cd260112 | ||
|
|
242268d580 | ||
|
|
3999f84eff | ||
|
|
995bf09dde | ||
|
|
59aff41ca3 | ||
|
|
b0e3e4c82f | ||
|
|
3e0d53228e | ||
|
|
df2ef84251 | ||
|
|
dcd07d1cc6 | ||
|
|
5f0cc805a9 | ||
|
|
c5677f1dd9 | ||
|
|
bf9be5915c | ||
|
|
4cf3627d52 | ||
|
|
0424304a50 | ||
|
|
7ab1288fa6 | ||
|
|
c7f4f52efa | ||
|
|
f70e8c5189 | ||
|
|
fcc2e0f2ae | ||
|
|
731f97ebe4 | ||
|
|
474f81f08e | ||
|
|
76a2822753 | ||
|
|
b8d4fccbab | ||
|
|
4f14775a4d | ||
|
|
acbcd59c31 | ||
|
|
05c1ad885b | ||
|
|
d73d0ebb6e | ||
|
|
a3971c127b | ||
|
|
912d3cf5b2 | ||
|
|
73c35ba5a2 | ||
|
|
55f1019908 | ||
|
|
863fc695ec | ||
|
|
d931fb1f9c | ||
|
|
fd054dfb8d | ||
|
|
699e08b2ee | ||
|
|
e1228ca03c | ||
|
|
9ad69c20cf | ||
|
|
9a5efd39ce | ||
|
|
497b0804d3 | ||
|
|
8b50f419e5 | ||
|
|
9208827406 | ||
|
|
8891381d89 | ||
|
|
ab39f0c8b6 | ||
|
|
cd2382e091 | ||
|
|
154c54a2aa | ||
|
|
e949a4c529 | ||
|
|
64b321ac9c | ||
|
|
e90e060739 | ||
|
|
123074f4ba | ||
|
|
8115c86e37 | ||
|
|
92a48b466c | ||
|
|
7a4298dc00 | ||
|
|
3d379c65e8 | ||
|
|
b1d509ba30 | ||
|
|
6360e73901 | ||
|
|
7e44e686e8 | ||
|
|
0b914bef79 | ||
|
|
a48d190af3 | ||
|
|
c21f0caecf | ||
|
|
1f42a152b0 | ||
|
|
b8f0cc6fa1 | ||
|
|
c6f84e6c4b | ||
|
|
9c3f4ec055 | ||
|
|
f0cfbede7e | ||
|
|
e2a825a9a0 | ||
|
|
963a2a0b76 | ||
|
|
4037367f8c | ||
|
|
0b3b268eed | ||
|
|
2acb5559dc | ||
|
|
be1c6eb65c | ||
|
|
feefa26c90 | ||
|
|
2c25f88100 | ||
|
|
2be9fc8bc0 | ||
|
|
5927b42560 | ||
|
|
5726d18027 | ||
|
|
3312d9cfc6 | ||
|
|
c140b01548 | ||
|
|
c2085c6be9 | ||
|
|
b8b9d04897 | ||
|
|
4497c4ce0b | ||
|
|
cd06141816 | ||
|
|
013646bf2c | ||
|
|
12bbbe3617 | ||
|
|
2c7c9fd716 | ||
|
|
11fc2a738a | ||
|
|
44ab25b4a0 | ||
|
|
4a2c0cb164 | ||
|
|
b1d9e2b84c | ||
|
|
47b9e8a8ac | ||
|
|
42265021fe | ||
|
|
0b0fee3985 | ||
|
|
5718cc5e17 | ||
|
|
361889ded7 | ||
|
|
2260ddcebc | ||
|
|
80c3b91077 | ||
|
|
a11cf130bf | ||
|
|
f9d6ea2a00 | ||
|
|
50f6aa5f58 | ||
|
|
132746af74 | ||
|
|
395381ddcc | ||
|
|
ef88effd9f | ||
|
|
673a2be7c7 | ||
|
|
b1cd3e940e | ||
|
|
711bec8d71 | ||
|
|
7529e331a3 | ||
|
|
cc147d0797 | ||
|
|
c8e8800647 | ||
|
|
99caae8665 | ||
|
|
1f08bc5a4f | ||
|
|
5594e5c713 | ||
|
|
34afc7b674 | ||
|
|
513a58c91c | ||
|
|
375b9190a3 | ||
|
|
83d7bf322a | ||
|
|
06429e27fb | ||
|
|
90bcd711a9 | ||
|
|
8d61b452b1 | ||
|
|
03d0b98155 | ||
|
|
de503add83 | ||
|
|
1a4cf5f91d | ||
|
|
ed53645783 | ||
|
|
c7b1fbdf67 | ||
|
|
6d7f7108e5 | ||
|
|
d9aab9088d | ||
|
|
c80daf43b8 | ||
|
|
2bdf65ce8c | ||
|
|
343e2ac9d7 | ||
|
|
670b82fd23 | ||
|
|
e6f81d09c8 | ||
|
|
8f91ffe692 | ||
|
|
f5dbdae4a9 | ||
|
|
e99f6048d6 | ||
|
|
654a204878 | ||
|
|
7928933852 | ||
|
|
c4cdba1015 | ||
|
|
5ed36a7fe1 | ||
|
|
e77a2f6963 | ||
|
|
a10d3599e2 | ||
|
|
d99f171597 | ||
|
|
da55306a5a | ||
|
|
66378160df | ||
|
|
8aee8d9d3b | ||
|
|
79e33a4d26 | ||
|
|
b5fa43f086 | ||
|
|
0d757039f7 | ||
|
|
44f6f2e320 | ||
|
|
d8eb1df61d | ||
|
|
ef7cac6c50 | ||
|
|
21dd44733f | ||
|
|
b0bfec7247 | ||
|
|
ba5b9e951a | ||
|
|
5e3865b645 | ||
|
|
a02240b579 | ||
|
|
376caa412d | ||
|
|
8722745185 | ||
|
+12 |
a4a3eb5ea9 | ||
|
|
b560475d89 | ||
|
|
4872daef60 | ||
|
|
dfa92f5cb6 | ||
|
|
babb1a3bf7 | ||
|
|
7b5a2726ab | ||
|
|
7d4653296e | ||
|
|
644fb38f87 | ||
|
|
743b155a88 | ||
|
|
664a92a1d0 | ||
|
|
8ae71f6160 | ||
|
|
ab1eab9c54 | ||
|
|
a64b7752c9 | ||
|
|
be728e9c01 | ||
|
|
2146c04444 | ||
|
|
0b94473c7f | ||
|
|
2a1dc619ee | ||
|
|
48a2fc2f67 | ||
|
|
4553455098 | ||
|
|
b4f1b5aebe | ||
|
|
a99e285d76 | ||
|
|
bdea436f8b | ||
|
|
182d0fa228 | ||
|
|
0d7f560c43 | ||
|
|
1377f1a214 | ||
|
|
e8dfa50119 | ||
|
|
706e8e4c1f | ||
|
|
a95729f4a1 | ||
|
|
4b6a389212 | ||
|
|
9d3be8de03 | ||
|
|
b283f1f8cd | ||
|
|
5ff52e1239 | ||
|
|
2b1cb9f878 | ||
|
|
74a46e8ba3 | ||
|
|
5b5f7c1c48 | ||
|
|
9375efffe3 | ||
|
|
da59c9de12 | ||
|
|
2a734faf45 | ||
|
|
934cefecce | ||
|
|
c88811aeee | ||
|
|
f46c3c65fb | ||
|
|
1d203dc566 | ||
|
|
26a1c749f7 | ||
|
|
ad347cb59f | ||
|
|
ac300ff670 | ||
|
|
c834b927b6 | ||
|
|
34ba27407b | ||
|
|
8431cd2ef8 | ||
|
|
cd09a56491 | ||
|
|
5c05a422a8 | ||
|
|
e805c0f00a | ||
|
|
2fa7f7931d | ||
|
|
20729b3a4e | ||
|
|
4d9777af8b | ||
|
|
1534588b34 | ||
|
|
7183ec7f1b | ||
|
|
d836497d56 | ||
|
|
38fa0fae1d | ||
|
|
2872b263fd | ||
|
|
92e53adc8e | ||
|
|
72cc4ab13e | ||
|
|
4e82ffe2ad | ||
|
|
90f6c7e239 | ||
|
|
ffa58d1cdb | ||
|
|
f834e93652 | ||
|
|
ae7fae7da4 | ||
|
|
b6c6e41603 | ||
|
|
e4dc2d0877 | ||
|
|
e43690f190 | ||
|
|
5bbccad41d | ||
|
|
bc53037f37 | ||
|
|
b4a1a8e7bf | ||
|
|
ac43d90d53 | ||
|
|
2b24b4472d | ||
|
|
715cbc1c38 | ||
|
|
d670976a69 | ||
|
|
17ff01af75 | ||
|
|
77af36e2af | ||
|
|
d52b2a23bd | ||
|
|
54e79d3c08 | ||
|
|
498b55efdd | ||
|
|
1e9a4c58a3 | ||
|
|
35a5ed5431 | ||
|
|
4921cd7ecb | ||
|
|
04a0e4642e | ||
|
|
f6ba77ccfe | ||
|
|
9a40838fa4 | ||
|
|
f94494b91e | ||
|
|
2e2a6c73b1 | ||
|
|
6de454b071 | ||
|
|
7936709644 | ||
|
|
d7383b2c80 | ||
|
|
9e980dc0ab | ||
|
|
b879aeef09 | ||
|
|
0eabe7f28b | ||
|
|
3371602e0f | ||
|
|
f642a6ebeb | ||
|
|
bc34e67191 | ||
|
|
2fb1408dd1 | ||
|
|
16ca4dd39c | ||
|
|
6dd8812a19 | ||
|
|
1a60afad7a | ||
|
|
76a2773688 | ||
|
|
8e76d7bd10 | ||
|
|
9749ba1cd0 | ||
|
|
f52497f181 | ||
|
|
c1c4b1278b | ||
|
|
4a914c2dfb | ||
|
|
62d579d03d | ||
|
|
74bed31edc | ||
|
|
97f213f260 | ||
|
|
b84cdc0d45 | ||
|
|
8cb5a7080b | ||
|
|
11f0d48d0b | ||
|
|
c4a1382982 | ||
|
|
e0922c5ee0 | ||
|
|
f8dc7a115a | ||
|
|
a60aa1bf6c | ||
|
|
bc203d7439 | ||
|
|
d82847035f | ||
|
|
6d133c26f8 | ||
|
|
3ae53a4b6e | ||
|
|
63339d8c01 | ||
|
|
44410c87f0 | ||
|
|
c016241004 | ||
|
|
019ef6de2a | ||
|
|
a2b064b269 | ||
|
|
abb4fd307f | ||
|
|
a9ebb0d160 | ||
|
|
1058c0e171 | ||
|
|
021e5f3833 | ||
|
|
5b5a42e72d | ||
|
|
2b43d9a716 | ||
|
|
3a5325f2a8 | ||
|
|
5ad73607f0 | ||
|
|
5067f0dd16 | ||
|
|
a53e697baa | ||
|
|
e7415fe6e2 | ||
|
|
562309d24b | ||
|
|
3900c55204 | ||
|
|
b3be9a93e0 | ||
|
|
d27ccd6f44 | ||
|
|
f86a5db843 | ||
|
|
4cbf9ec9ab | ||
|
|
aa3c91ebd6 | ||
|
|
071c899ea6 | ||
|
|
32b2e83564 | ||
|
|
7ff6d6559a | ||
|
|
2572a5cb93 | ||
|
|
45e0f87e45 | ||
|
|
6e875fccbe | ||
|
|
689e17bcf6 | ||
|
|
584f9c6e93 | ||
|
|
70348c6f64 | ||
|
|
747591fded | ||
|
|
9bfd933699 | ||
|
|
a775e42e85 | ||
|
|
e3e2bc804a | ||
|
|
b84099aec0 | ||
|
|
073d34f893 | ||
|
|
d72900d164 | ||
|
|
e3b478f5b1 | ||
|
|
a5191d1e5a | ||
|
|
ea21cd5826 | ||
|
|
e78c8738fb | ||
|
|
47314bae83 | ||
|
|
fd8afbc418 | ||
|
|
6e7012757f | ||
|
|
933eb0d057 | ||
|
|
fcb79888f3 | ||
|
|
111e2f30c6 | ||
|
|
f2b3e620b4 | ||
|
|
d9133cb395 | ||
|
|
1722edbd36 | ||
|
|
e68ab3b123 | ||
|
|
cf16cd3ed2 | ||
|
|
f4bc7d2ea4 | ||
|
|
f5f7242f02 | ||
|
|
8f5ac26bb1 | ||
|
|
b983adb16a | ||
|
|
8ce018995a | ||
|
|
f11086e9f8 | ||
|
|
91f5d49cd4 | ||
|
|
55e1e0fa3d | ||
|
|
4a59841b02 | ||
|
|
74a9f3c19e | ||
|
|
89250422ea | ||
|
|
2f2f4dba60 | ||
|
|
b292cafe7b | ||
|
|
82053e0c7c | ||
|
|
860d173f0a | ||
|
|
5bc0e1265d | ||
|
|
e362c37ff3 | ||
|
|
4d53f327d9 | ||
|
|
61f436be2a | ||
|
|
d1a0fc6604 | ||
|
|
976013512c | ||
|
|
d9777a3011 | ||
|
|
7725adedc8 | ||
|
|
7269b3ea60 | ||
|
|
c168df4df0 | ||
|
|
be9b0fb9a8 | ||
|
|
c056cabbbd | ||
|
|
97630e8add | ||
|
|
a5443ccfbe | ||
|
|
6f2986e42e | ||
|
|
db345de99e | ||
|
|
bde40dceb3 | ||
|
|
1a86e7c506 | ||
|
|
9ed1624111 | ||
|
|
5dff1c6df0 | ||
|
|
706d7dff42 | ||
|
|
71443ef23d | ||
|
|
17201334b6 | ||
|
|
bc82ff4d21 | ||
|
|
71ae3f157a | ||
|
|
8784be067f | ||
|
|
84a2e55125 | ||
|
|
21e23f32ac | ||
|
|
33aa9420af | ||
|
|
ef0ba48f49 | ||
|
|
6e54c459a4 | ||
|
|
f5c16f0d6b | ||
|
|
105e7d3e28 | ||
|
|
21ef1900e9 | ||
|
|
f07b73dc79 | ||
|
|
d4b0516235 | ||
|
|
7bfad11ff9 | ||
|
|
d2f7a427d0 | ||
|
|
a910eb96ad | ||
|
|
c080458faa | ||
|
|
5c75cfc17f | ||
|
|
4280f556cb | ||
|
|
21368bb130 | ||
|
|
e9076c8e3e | ||
|
|
2471ee801b | ||
|
+6 |
9fc9876b3f | ||
|
|
34ca3bd22d | ||
|
|
bebe1dd7b4 | ||
|
|
fced9a6355 | ||
|
|
6b57dd6794 | ||
|
|
eb4c58186b | ||
|
|
17e438ab9f | ||
|
|
458e6a3abf | ||
|
|
963d22648b | ||
|
|
fabcf2055b | ||
|
|
633697a812 | ||
|
|
2de33fe1bf | ||
|
|
36e79bf69c | ||
|
|
2e36cadce4 | ||
|
|
9ef532019f | ||
|
|
176dd1a7f3 | ||
|
|
e9c71abd6f | ||
|
|
eb472cba36 | ||
|
|
7d77554696 | ||
|
|
31bfa9e32e | ||
|
|
26be88fc56 | ||
|
|
86cf8feb55 | ||
|
|
1edd255846 | ||
|
|
f538877349 | ||
|
|
d759669c1e | ||
|
|
71ce87ca21 | ||
|
|
7eae004aa2 | ||
|
|
6cd427bc78 | ||
|
|
b8d67c5a41 | ||
|
|
42953548b6 | ||
|
|
28c631e108 | ||
|
|
263a96e660 | ||
|
|
d059b1399b | ||
|
|
12a9c74eb6 | ||
|
|
6bb60e36fd | ||
|
|
c1eb2512b6 | ||
|
|
55d75a9786 | ||
|
|
779eb19e7a | ||
|
|
5d8ed130de | ||
|
|
971d9ff0d8 | ||
|
|
b6bcc43655 | ||
|
|
b576fc8c26 | ||
|
|
821843bc6c | ||
|
|
d19e3e53f0 | ||
|
|
40fee7af51 | ||
|
|
f3462776e8 | ||
|
|
57e9ef7320 | ||
|
|
88f46a6b20 | ||
|
|
17f537774c | ||
|
|
3aa6878c27 | ||
|
|
ae55e5e264 | ||
|
|
36ecece181 | ||
|
|
1a0448910b | ||
|
|
ad5d645347 | ||
|
|
54f7c8328d | ||
|
|
36a1715ece | ||
|
|
edaa6b1de8 | ||
|
|
f47b2066c4 | ||
|
|
7337b3de1e | ||
|
|
933ffcbf69 | ||
|
|
cf50566a10 | ||
|
|
4424aa5b38 | ||
|
|
19634073e9 | ||
|
|
4284e86e51 | ||
|
|
f7c1d62bb8 | ||
|
|
795a228a44 | ||
|
|
0190ca6711 | ||
|
|
ee8cc81862 | ||
|
|
6b244fc5b5 | ||
|
|
356ecfe2fa | ||
|
|
bd410cf09f | ||
|
|
3c5ab5e8d2 | ||
|
|
d938e67e65 | ||
|
|
6a90518231 | ||
|
|
47e8907184 | ||
|
|
d6096f459b | ||
|
|
8839f08257 | ||
|
|
6104add6bd | ||
|
|
7aae55f417 | ||
|
|
ef1b5067c2 | ||
|
|
4caa095d52 | ||
|
|
c8e20b0c57 | ||
|
|
2fb4289798 | ||
|
|
6529b49c3c | ||
|
|
758423ecf9 | ||
|
|
ef11755488 | ||
|
|
03051a2bb1 | ||
|
|
7a57f57e99 | ||
|
|
b6d31129a3 | ||
|
|
34075ee303 | ||
|
|
89f711d929 | ||
|
|
623f0c7d93 | ||
|
|
c67877cc51 | ||
|
|
fd50728169 | ||
|
|
74567909b2 | ||
|
|
d84f08c998 | ||
|
|
685eadc30b | ||
|
|
c821f15a14 | ||
|
|
8a972a74c6 | ||
|
|
cf76cf7aae | ||
|
|
4dd762681c | ||
|
|
399a23a52f | ||
|
|
342f935aba | ||
|
|
27e4210693 | ||
|
|
66cb2bcc13 | ||
|
|
30c31d3585 | ||
|
|
e17303ea88 | ||
|
|
bebf1d80a7 | ||
|
|
5bcefb0b3e | ||
|
|
f52de7331f | ||
|
|
1690770d6a | ||
|
|
7759e6a029 |
No files matched your search
@@ -5,6 +5,7 @@
|
|||||||
# - DAILY.CO VIDEO
|
# - DAILY.CO VIDEO
|
||||||
# - GOOGLE CALENDAR/MEET/LOGIN
|
# - GOOGLE CALENDAR/MEET/LOGIN
|
||||||
# - HUBSPOT
|
# - HUBSPOT
|
||||||
|
# - HUDDLE01
|
||||||
# - OFFICE 365
|
# - OFFICE 365
|
||||||
# - SLACK
|
# - SLACK
|
||||||
# - STRIPE
|
# - STRIPE
|
||||||
@@ -137,3 +138,7 @@ REVERT_PUBLIC_TOKEN=
|
|||||||
# NOTE: If you're self hosting Revert, update this URL to point to your own instance.
|
# NOTE: If you're self hosting Revert, update this URL to point to your own instance.
|
||||||
REVERT_API_URL=https://api.revert.dev/
|
REVERT_API_URL=https://api.revert.dev/
|
||||||
# *********************************************************************************************************
|
# *********************************************************************************************************
|
||||||
|
|
||||||
|
# - Huddle01
|
||||||
|
# Used for the huddle01 integration
|
||||||
|
HUDDLE01_API_TOKEN=
|
||||||
+53
-7
@@ -16,7 +16,7 @@
|
|||||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||||
#
|
#
|
||||||
|
|
||||||
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
|
# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
|
||||||
CALCOM_LICENSE_KEY=
|
CALCOM_LICENSE_KEY=
|
||||||
# Signature token for the Cal.com License API (used for self-hosted integrations)
|
# Signature token for the Cal.com License API (used for self-hosted integrations)
|
||||||
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
|
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
|
||||||
@@ -102,6 +102,11 @@ NEXT_PUBLIC_INTERCOM_APP_ID=
|
|||||||
# Secret to enable Intercom Identity Verification
|
# Secret to enable Intercom Identity Verification
|
||||||
INTERCOM_SECRET=
|
INTERCOM_SECRET=
|
||||||
|
|
||||||
|
# Posthog Config
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY=
|
||||||
|
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST=
|
||||||
|
|
||||||
# Zendesk Config
|
# Zendesk Config
|
||||||
NEXT_PUBLIC_ZENDESK_KEY=
|
NEXT_PUBLIC_ZENDESK_KEY=
|
||||||
|
|
||||||
@@ -114,7 +119,7 @@ NEXT_PUBLIC_FRESHCHAT_HOST=
|
|||||||
|
|
||||||
# Google OAuth credentials
|
# Google OAuth credentials
|
||||||
# To enable Login with Google you need to:
|
# To enable Login with Google you need to:
|
||||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
# 1. Set `GOOGLE_API_CREDENTIALS` below
|
||||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||||
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
||||||
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
||||||
@@ -124,11 +129,15 @@ GOOGLE_LOGIN_ENABLED=false
|
|||||||
# Needed to enable Google Calendar integration and Login with Google
|
# Needed to enable Google Calendar integration and Login with Google
|
||||||
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
|
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
|
||||||
GOOGLE_API_CREDENTIALS=
|
GOOGLE_API_CREDENTIALS=
|
||||||
|
# Token to verify incoming webhooks from Google Calendar
|
||||||
|
GOOGLE_WEBHOOK_TOKEN=
|
||||||
|
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
||||||
|
GOOGLE_WEBHOOK_URL=
|
||||||
|
|
||||||
# Inbox to send user feedback
|
# Inbox to send user feedback
|
||||||
SEND_FEEDBACK_EMAIL=
|
SEND_FEEDBACK_EMAIL=
|
||||||
|
|
||||||
# Sengrid
|
# Sendgrid
|
||||||
# Used for email reminders in workflows and internal sync services
|
# Used for email reminders in workflows and internal sync services
|
||||||
SENDGRID_API_KEY=
|
SENDGRID_API_KEY=
|
||||||
SENDGRID_EMAIL=
|
SENDGRID_EMAIL=
|
||||||
@@ -137,6 +146,9 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
|||||||
# Sentry
|
# Sentry
|
||||||
# Used for capturing exceptions and logging messages
|
# Used for capturing exceptions and logging messages
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
SENTRY_DEBUG=
|
||||||
|
SENTRY_MAX_SPANS=
|
||||||
|
SENTRY_TRACES_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
|
||||||
@@ -168,13 +180,13 @@ 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=
|
||||||
|
STRIPE_TEAM_PRODUCT_ID=
|
||||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_WEBHOOK_SECRET_APPS=
|
STRIPE_WEBHOOK_SECRET_APPS=
|
||||||
STRIPE_PRIVATE_KEY=
|
STRIPE_PRIVATE_KEY=
|
||||||
STRIPE_CLIENT_ID=
|
STRIPE_CLIENT_ID=
|
||||||
|
|
||||||
|
|
||||||
# Use for internal Public API Keys and optional
|
# Use for internal Public API Keys and optional
|
||||||
API_KEY_PREFIX=cal_
|
API_KEY_PREFIX=cal_
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
@@ -262,7 +274,7 @@ ORGANIZATIONS_ENABLED=
|
|||||||
NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=30
|
NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=30
|
||||||
NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE=3700 # $37.00 per seat
|
NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE=3700 # $37.00 per seat
|
||||||
|
|
||||||
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
|
# This variable should only be set to 1 or true if you want to autolink external provider sign-ups with
|
||||||
# existing organizations based on email domain address
|
# existing organizations based on email domain address
|
||||||
ORGANIZATIONS_AUTOLINK=
|
ORGANIZATIONS_AUTOLINK=
|
||||||
|
|
||||||
@@ -333,7 +345,8 @@ E2E_TEST_OIDC_USER_PASSWORD=
|
|||||||
AB_TEST_BUCKET_PROBABILITY=50
|
AB_TEST_BUCKET_PROBABILITY=50
|
||||||
# whether we redirect to the future/event-types from event-types or not
|
# whether we redirect to the future/event-types from event-types or not
|
||||||
APP_ROUTER_EVENT_TYPES_ENABLED=0
|
APP_ROUTER_EVENT_TYPES_ENABLED=0
|
||||||
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
|
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
|
||||||
|
APP_ROUTER_SETTINGS_ORG_ENABLED=0
|
||||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
||||||
APP_ROUTER_APPS_SLUG_ENABLED=0
|
APP_ROUTER_APPS_SLUG_ENABLED=0
|
||||||
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
|
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
|
||||||
@@ -341,13 +354,28 @@ APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
|
|||||||
APP_ROUTER_APPS_CATEGORIES_ENABLED=0
|
APP_ROUTER_APPS_CATEGORIES_ENABLED=0
|
||||||
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
|
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
|
||||||
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
|
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
|
||||||
|
APP_ROUTER_BOOKING_ENABLED=0
|
||||||
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
|
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
|
||||||
APP_ROUTER_WORKFLOWS_ENABLED=0
|
APP_ROUTER_WORKFLOWS_ENABLED=0
|
||||||
APP_ROUTER_SETTINGS_TEAMS_ENABLED=0
|
APP_ROUTER_SETTINGS_TEAMS_ENABLED=0
|
||||||
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
|
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
|
||||||
APP_ROUTER_APPS_ENABLED=0
|
APP_ROUTER_APPS_ENABLED=0
|
||||||
APP_ROUTER_VIDEO_ENABLED=0
|
APP_ROUTER_VIDEO_ENABLED=0
|
||||||
|
APP_ROUTER_TEAM_ENABLED=0
|
||||||
APP_ROUTER_TEAMS_ENABLED=0
|
APP_ROUTER_TEAMS_ENABLED=0
|
||||||
|
APP_ROUTER_AVAILABILITY_ENABLED=0
|
||||||
|
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
|
||||||
|
APP_ROUTER_AUTH_LOGIN_ENABLED=0
|
||||||
|
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
|
||||||
|
APP_ROUTER_AUTH_NEW_ENABLED=0
|
||||||
|
APP_ROUTER_AUTH_SAML_ENABLED=0
|
||||||
|
APP_ROUTER_AUTH_ERROR_ENABLED=0
|
||||||
|
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
|
||||||
|
APP_ROUTER_AUTH_OAUTH2_ENABLED=0
|
||||||
|
APP_ROUTER_MORE_ENABLED=0
|
||||||
|
APP_ROUTER_MAINTENANCE_ENABLED=0
|
||||||
|
APP_ROUTER_UPGRADE_ENABLED=0
|
||||||
|
APP_ROUTER_CONNECT_AND_JOIN_ENABLED=0
|
||||||
|
|
||||||
# disable setry server source maps
|
# disable setry server source maps
|
||||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
||||||
@@ -362,10 +390,28 @@ TASKER_ENABLE_EMAILS=0
|
|||||||
# Ratelimiting via unkey
|
# Ratelimiting via unkey
|
||||||
UNKEY_ROOT_KEY=
|
UNKEY_ROOT_KEY=
|
||||||
|
|
||||||
|
|
||||||
# Used for Cal.ai Enterprise Voice AI Agents
|
# Used for Cal.ai Enterprise Voice AI Agents
|
||||||
# https://retellai.com
|
# https://retellai.com
|
||||||
RETELL_AI_KEY=
|
RETELL_AI_KEY=
|
||||||
|
|
||||||
|
# Used for the huddle01 integration
|
||||||
|
HUDDLE01_API_TOKEN=
|
||||||
|
|
||||||
# Used to disallow emails as being added as guests on bookings
|
# Used to disallow emails as being added as guests on bookings
|
||||||
BLACKLISTED_GUEST_EMAILS=
|
BLACKLISTED_GUEST_EMAILS=
|
||||||
|
|
||||||
|
# Used to allow browser push notifications
|
||||||
|
# You can use: 'npx web-push generate-vapid-keys' to generate these keys
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
|
||||||
|
# Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs)
|
||||||
|
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=
|
||||||
|
NEXT_PUBLIC_WEBSITE_TERMS_URL=
|
||||||
|
|
||||||
|
# NEXT_PUBLIC_LOGGER_LEVEL=3 sets to log info, warn, error and fatal logs.
|
||||||
|
# [0: silly & upwards, 1: trace & upwards, 2: debug & upwards, 3: info & upwards, 4: warn & upwards, 5: error & fatal, 6: fatal]
|
||||||
|
NEXT_PUBLIC_LOGGER_LEVEL=
|
||||||
|
|
||||||
|
# Used to use Replexica SDK, a tool for real-time AI-powered localization
|
||||||
|
REPLEXICA_API_KEY=
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Report any issues with the platform
|
||||||
|
title: ""
|
||||||
|
labels: ["🐛 bug"]
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
Found a bug? Please fill out the sections below. 👍
|
||||||
|
|
||||||
|
### Issue Summary
|
||||||
|
|
||||||
|
A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||||
|
|
||||||
|
### Steps to Reproduce
|
||||||
|
|
||||||
|
1. (for example) Went to ...
|
||||||
|
2. Clicked on...
|
||||||
|
3. ...
|
||||||
|
|
||||||
|
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
|
||||||
|
|
||||||
|
### Actual Results
|
||||||
|
|
||||||
|
- What's happening right now that is different from what is expected
|
||||||
|
|
||||||
|
### Expected Results
|
||||||
|
|
||||||
|
- This is an ideal result that the system should get after the tests are performed
|
||||||
|
|
||||||
|
### Technical details
|
||||||
|
|
||||||
|
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||||
|
- Node.js version
|
||||||
|
- Anything else that you think could be an issue.
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
|
||||||
|
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
name: Bug report
|
|
||||||
description: "Please make sure you have checked for any duplicate issues."
|
|
||||||
title: "(bug)"
|
|
||||||
labels: ["🐛 bug"]
|
|
||||||
assignees: []
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: issue-summary
|
|
||||||
attributes:
|
|
||||||
label: Issue Summary
|
|
||||||
description: A summary of the issue. This needs to be a clear detailed-rich summary.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: steps-to-reproduce
|
|
||||||
attributes:
|
|
||||||
label: Steps to Reproduce
|
|
||||||
value: |
|
|
||||||
1. (for example) Went to ...
|
|
||||||
2. Clicked on...
|
|
||||||
3. ...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: expected-behavior
|
|
||||||
attributes:
|
|
||||||
label: Expected behavior
|
|
||||||
description: A clear and concise description of what you expected to happen.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: other-information
|
|
||||||
attributes:
|
|
||||||
label: Other information
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
id: screenshots
|
|
||||||
attributes:
|
|
||||||
label: Screenshots
|
|
||||||
description: If applicable, add screenshots to help explain your problem.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: checkboxes
|
|
||||||
id: environment
|
|
||||||
attributes:
|
|
||||||
label: Environment
|
|
||||||
options:
|
|
||||||
- label: app.cal.com
|
|
||||||
- label: Self-hosted Cal
|
|
||||||
- type: textarea
|
|
||||||
id: desktop-version
|
|
||||||
attributes:
|
|
||||||
label: Desktop (please complete the following information)
|
|
||||||
description: |
|
|
||||||
examples:
|
|
||||||
- **OS**: [e.g. iOS]
|
|
||||||
- **Browser**: [e.g. chrome, safari]
|
|
||||||
- **Version**: [e.g. 22]
|
|
||||||
value: |
|
|
||||||
- OS:
|
|
||||||
- Node:
|
|
||||||
- npm:
|
|
||||||
render: markdown
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: markdown
|
|
||||||
id: nodejs-version
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
#### Node.JS version
|
|
||||||
|
|
||||||
[e.g. v18.15.0]
|
|
||||||
- type: markdown
|
|
||||||
id: anything-else
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
#### Anything else?
|
|
||||||
|
|
||||||
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
|
|
||||||
- Anything else that you think could be an issue?
|
|
||||||
@@ -49,7 +49,9 @@ assignees: ""
|
|||||||
(Share it here.)
|
(Share it here.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
##### House rules
|
##### House rules
|
||||||
|
|
||||||
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
|
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
|
||||||
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
|
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
|
||||||
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
|
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
## Mandatory Tasks (DO NOT REMOVE)
|
## Mandatory Tasks (DO NOT REMOVE)
|
||||||
|
|
||||||
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
|
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
|
||||||
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com/docs). If N/A, write N/A here and check the checkbox.
|
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
|
||||||
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
|
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
|
||||||
|
|
||||||
## How should this be tested?
|
## How should this be tested?
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ jobs:
|
|||||||
uses: ./.github/workflows/api-v2-production-build.yml
|
uses: ./.github/workflows/api-v2-production-build.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
build-atoms:
|
||||||
|
name: Production builds
|
||||||
|
uses: ./.github/workflows/atoms-production-build.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Production builds
|
name: Production builds
|
||||||
uses: ./.github/workflows/production-build-without-database.yml
|
uses: ./.github/workflows/production-build-without-database.yml
|
||||||
|
|||||||
@@ -83,11 +83,12 @@ jobs:
|
|||||||
**/.turbo/**
|
**/.turbo/**
|
||||||
**/dist/**
|
**/dist/**
|
||||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||||
- run: |
|
- name: Log Cache Hit
|
||||||
|
if: steps.cache-api-v1-build.outputs.cache-hit == 'true'
|
||||||
|
run: echo "Cache hit for API v1 build. Skipping build."
|
||||||
|
- name: Run build
|
||||||
|
if: steps.cache-api-v1-build.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||||
if [ ${{ steps.cache-api-v1-build.outputs.cache-hit }} == 'true' ]; then
|
yarn turbo run build --filter=@calcom/api...
|
||||||
echo "Cache hit for API v1 build. Skipping build."
|
|
||||||
else
|
|
||||||
yarn turbo run build --filter=@calcom/api...
|
|
||||||
fi
|
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -49,14 +49,15 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
**/dist/**
|
**/dist/**
|
||||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||||
- run: |
|
- name: Log Cache Hit
|
||||||
|
if: steps.cache-api-v2-build.outputs.cache-hit == 'true'
|
||||||
|
run: echo "Cache hit for API v2 build. Skipping build."
|
||||||
|
- name: Run build
|
||||||
|
if: steps.cache-api-v2-build.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||||
if [ ${{ steps.cache-api-v2-build.outputs.cache-hit }} == 'true' ]; then
|
yarn workspace @calcom/api-v2 run generate-schemas
|
||||||
echo "Cache hit for API v2 build. Skipping build."
|
rm -rf apps/api/v2/node_modules
|
||||||
else
|
yarn install
|
||||||
yarn workspace @calcom/api-v2 run generate-schemas
|
yarn workspace @calcom/api-v2 run build
|
||||||
rm -rf apps/api/v2/node_modules
|
|
||||||
yarn install
|
|
||||||
yarn workspace @calcom/api-v2 run build
|
|
||||||
fi
|
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Atoms production Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Atoms
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
|
- uses: ./.github/actions/yarn-install
|
||||||
|
- name: Cache atoms production build
|
||||||
|
uses: buildjet/cache@v4
|
||||||
|
id: cache-atoms-build
|
||||||
|
env:
|
||||||
|
cache-name: atoms-build
|
||||||
|
key-1: ${{ hashFiles('yarn.lock') }}
|
||||||
|
key-2: ${{ hashFiles('packages/platform/atoms/**.[jt]s', 'packages/platform/atoms/**.[jt]sx', '!**/node_modules') }}
|
||||||
|
key-3: ${{ github.event.pull_request.number || github.ref }}
|
||||||
|
# Ensures production-build.yml will always be fresh
|
||||||
|
key-4: ${{ github.sha }}
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
**/dist/**
|
||||||
|
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||||
|
- name: Log Cache Hit
|
||||||
|
if: steps.cache-atoms-build.outputs.cache-hit == 'true'
|
||||||
|
run: echo "Cache hit for Atoms build. Skipping build."
|
||||||
|
- name: Run build
|
||||||
|
if: steps.cache-atoms-build.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||||
|
rm -rf packages/platform/atoms/node_modules
|
||||||
|
yarn install
|
||||||
|
yarn workspace @calcom/atoms run build
|
||||||
|
shell: bash
|
||||||
@@ -4,6 +4,11 @@ on:
|
|||||||
delete:
|
delete:
|
||||||
branches-ignore: [main, gh-pages]
|
branches-ignore: [main, gh-pages]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
# ensures that currently running Playwright workflow of deleted branch gets cancelled
|
# ensures that currently running Playwright workflow of deleted branch gets cancelled
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.event.ref }}
|
group: ${{ github.event.ref }}
|
||||||
@@ -20,7 +25,9 @@ jobs:
|
|||||||
- name: Checkout GitHub Pages Branch
|
- name: Checkout GitHub Pages Branch
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
|
repository: calcom/test-results
|
||||||
ref: gh-pages
|
ref: gh-pages
|
||||||
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
- name: Set Git User
|
- name: Set Git User
|
||||||
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
name: Crowdin Action
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
synchronize-with-crowdin:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: crowdin action
|
|
||||||
uses: crowdin/github-action@v1.13.0
|
|
||||||
with:
|
|
||||||
# upload sources
|
|
||||||
upload_sources: true
|
|
||||||
|
|
||||||
# upload translations (& auto-approve 'em)
|
|
||||||
upload_translations_args: '--auto-approve-imported'
|
|
||||||
upload_translations: true
|
|
||||||
push_translations: true
|
|
||||||
|
|
||||||
# download translations
|
|
||||||
download_translations: true
|
|
||||||
|
|
||||||
# GH config
|
|
||||||
commit_message: "New Crowdin translations by Github Action"
|
|
||||||
localization_branch_name: main
|
|
||||||
create_pull_request: false
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# This is just to test this file
|
||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Docs
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
|
- name: Cache Docs build
|
||||||
|
uses: buildjet/cache@v4
|
||||||
|
id: cache-docs-build
|
||||||
|
env:
|
||||||
|
cache-name: docs-build
|
||||||
|
key-1: ${{ hashFiles('yarn.lock') }}
|
||||||
|
key-2: ${{ hashFiles('docs/**.*', '!**/node_modules') }}
|
||||||
|
key-3: ${{ github.event.pull_request.number || github.ref }}
|
||||||
|
key-4: ${{ github.sha }}
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
**/docs/**
|
||||||
|
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
||||||
|
# Log cache hit
|
||||||
|
- name: Log Cache Hit
|
||||||
|
if: steps.cache-docs-build.outputs.cache-hit == 'true'
|
||||||
|
run: echo "Cache hit for Docs build. Skipping build."
|
||||||
|
- name: Run build
|
||||||
|
if: steps.cache-docs-build.outputs.cache-hit != 'true'
|
||||||
|
working-directory: docs
|
||||||
|
run: |
|
||||||
|
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||||
|
npm install -g mintlify
|
||||||
|
mintlify dev &
|
||||||
|
sleep 5 # Let it run for 5 seconds
|
||||||
|
kill $!
|
||||||
|
shell: bash
|
||||||
@@ -15,7 +15,7 @@ env:
|
|||||||
IS_E2E: true
|
IS_E2E: true
|
||||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=29000
|
||||||
REDIS_URL: "redis://localhost:6379"
|
REDIS_URL: "redis://localhost:6379"
|
||||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
@@ -45,6 +45,9 @@ jobs:
|
|||||||
- 5432:5432
|
- 5432:5432
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: >-
|
options: >-
|
||||||
@@ -62,7 +65,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
- uses: ./.github/actions/yarn-playwright-install
|
|
||||||
- uses: ./.github/actions/cache-db
|
- uses: ./.github/actions/cache-db
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
working-directory: apps/api/v2
|
working-directory: apps/api/v2
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Run i18n AI automation
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-main
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
i18n:
|
||||||
|
name: Run i18n
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: replexica/replexica@main
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
with:
|
||||||
|
api-key: ${{ secrets.CI_REPLEXICA_API_KEY }}
|
||||||
|
pull-request: true
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: Changes Requested
|
||||||
|
on:
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes-requested:
|
||||||
|
if: github.event.review.state == 'changes_requested'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Explanation
|
||||||
|
run: echo "This workflow triggers a workflow_run; it's necessary because otherwise the repo secrets aren't available for 'pull_request_review' events from externally forked pull requests"
|
||||||
|
- name: Save PR number to context.json
|
||||||
|
run: |
|
||||||
|
printf '{
|
||||||
|
"pr_number": ${{ github.event.pull_request.number }}
|
||||||
|
}' >> context.json
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: context.json
|
||||||
|
path: ./
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
name: PR Reviewed
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
label-pr:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Label PR as ready for E2E
|
|
||||||
if: github.event.review.state == 'approved'
|
|
||||||
uses: actions-ecosystem/action-add-labels@v1
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.READY_FOR_E2E_PAT }}
|
|
||||||
labels: 'ready-for-e2e'
|
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
has-files-requiring-all-checks:
|
has-files-requiring-all-checks:
|
||||||
- "!(**.md|.github/CODEOWNERS)"
|
- "!(**.md|.github/CODEOWNERS|docs/**|apps/web/public/static/locales/**/common.json)"
|
||||||
- name: Get Latest Commit SHA
|
- name: Get Latest Commit SHA
|
||||||
id: get_sha
|
id: get_sha
|
||||||
run: |
|
run: |
|
||||||
@@ -132,6 +132,20 @@ jobs:
|
|||||||
uses: ./.github/workflows/api-v2-production-build.yml
|
uses: ./.github/workflows/api-v2-production-build.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
build-atoms:
|
||||||
|
name: Production builds
|
||||||
|
needs: [changes, check-label, deps]
|
||||||
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
|
uses: ./.github/workflows/atoms-production-build.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
build-docs:
|
||||||
|
name: Production builds
|
||||||
|
needs: [changes, check-label, deps]
|
||||||
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
|
uses: ./.github/workflows/docs-build.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Production builds
|
name: Production builds
|
||||||
needs: [changes, check-label, deps]
|
needs: [changes, check-label, deps]
|
||||||
@@ -217,6 +231,8 @@ jobs:
|
|||||||
build,
|
build,
|
||||||
build-api-v1,
|
build-api-v1,
|
||||||
build-api-v2,
|
build-api-v2,
|
||||||
|
build-atoms,
|
||||||
|
build-docs,
|
||||||
e2e,
|
e2e,
|
||||||
e2e-api-v2,
|
e2e-api-v2,
|
||||||
e2e-embed,
|
e2e-embed,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Web App
|
name: Build Web App
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ jobs:
|
|||||||
- name: Checkout GitHub Pages Branch
|
- name: Checkout GitHub Pages Branch
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
repository: calcom/test-results
|
||||||
ref: gh-pages
|
ref: gh-pages
|
||||||
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
- name: Set Git User
|
- name: Set Git User
|
||||||
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||||
run: |
|
run: |
|
||||||
@@ -65,7 +67,7 @@ jobs:
|
|||||||
- name: Output Report URL as Workflow Annotation
|
- name: Output Report URL as Workflow Annotation
|
||||||
id: url
|
id: url
|
||||||
run: |
|
run: |
|
||||||
FULL_HTML_REPORT_URL=https://calcom.github.io/cal.com/$HTML_REPORT_URL_PATH
|
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results/$HTML_REPORT_URL_PATH
|
||||||
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
|
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
|
||||||
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
|
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
|
||||||
- name: Find Comment
|
- name: Find Comment
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
name: Re-draft
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- Changes Requested
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
statuses: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
download-context:
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 'Download artifact'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: context.payload.workflow_run.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
|
||||||
|
return artifact.name == "context.json"
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
let download = await github.rest.actions.downloadArtifact({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
artifact_id: matchArtifact.id,
|
||||||
|
archive_format: 'zip',
|
||||||
|
});
|
||||||
|
|
||||||
|
let fs = require('fs');
|
||||||
|
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/context.zip`, Buffer.from(download.data));
|
||||||
|
|
||||||
|
- name: 'Unzip artifact'
|
||||||
|
run: unzip context.zip
|
||||||
|
|
||||||
|
- name: 'Return Parsed JSON'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
id: return-parsed-json
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
let fs = require('fs');
|
||||||
|
let data = fs.readFileSync('./context.json');
|
||||||
|
return JSON.parse(data);
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
pr_number: ${{fromJSON(steps.return-parsed-json.outputs.result).pr_number}}
|
||||||
|
|
||||||
|
re-draft:
|
||||||
|
needs:
|
||||||
|
- download-context
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
statuses: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "This PR was rejected"
|
||||||
|
- name: Convert PR to draft when changes are requested
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
async function getPullRequestId() {
|
||||||
|
const pull_number = ${{ needs.download-context.outputs.pr_number }};
|
||||||
|
const pullRequest = await github.rest.pulls.get({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number,
|
||||||
|
});
|
||||||
|
if (!pullRequest.data.node_id) throw new Error(`pullRequestId no found for '${pull_number}'`);
|
||||||
|
return pullRequest.data.node_id;
|
||||||
|
}
|
||||||
|
const query = `
|
||||||
|
mutation($id: ID!) {
|
||||||
|
convertPullRequestToDraft(input: { pullRequestId: $id }) {
|
||||||
|
pullRequest {
|
||||||
|
id
|
||||||
|
number
|
||||||
|
isDraft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const pullRequestId = await getPullRequestId();
|
||||||
|
const variables = {
|
||||||
|
id: pullRequestId,
|
||||||
|
}
|
||||||
|
const response = await github.graphql(query, variables)
|
||||||
|
if (!response.convertPullRequestToDraft) {
|
||||||
|
throw new Error("Failed to convert pull request to draft");
|
||||||
|
}
|
||||||
|
console.info("Pull request successfully converted to draft.");
|
||||||
|
console.info(`Draft conversion response: ${JSON.stringify(response, null, 2)}`);
|
||||||
@@ -7,14 +7,14 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Unit
|
name: Unit
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
- run: yarn test
|
- run: yarn test -- --no-isolate
|
||||||
# We could add different timezones here that we need to run our tests in
|
# We could add different timezones here that we need to run our tests in
|
||||||
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
|
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly --no-isolate
|
||||||
- name: Run API v2 tests
|
- name: Run API v2 tests
|
||||||
working-directory: apps/api/v2
|
working-directory: apps/api/v2
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
diff --git a/runtime/binary.js b/runtime/binary.js
|
||||||
|
index c81267d752644043e97b35d26369a5ce266abfd0..034c11262143d1acbf9902745a441d3b0cdfc0fd 100644
|
||||||
|
--- a/runtime/binary.js
|
||||||
|
+++ b/runtime/binary.js
|
||||||
|
@@ -185,7 +185,7 @@ It should have this form: { url: "CONNECTION_STRING" }`);if(r&&typeof r=="object
|
||||||
|
It should have this form: { url: "CONNECTION_STRING" }`);if(typeof i!="string")throw new Me(`Invalid value ${JSON.stringify(i)} for datasource "${t}" provided to PrismaClient constructor.
|
||||||
|
It should have this form: { url: "CONNECTION_STRING" }`)}}}},adapter:(e,A)=>{if(e===null)return;if(e===void 0)throw new Me('"adapter" property must not be undefined, use null to conditionally disable driver adapters.');if(!cg(A).includes("driverAdapters"))throw new Me('"adapter" property can only be provided to PrismaClient constructor when "driverAdapters" preview feature is enabled.')},datasourceUrl:e=>{if(typeof e<"u"&&typeof e!="string")throw new Me(`Invalid value ${JSON.stringify(e)} for "datasourceUrl" provided to PrismaClient constructor.
|
||||||
|
Expected string or undefined.`)},errorFormat:e=>{if(e){if(typeof e!="string")throw new Me(`Invalid value ${JSON.stringify(e)} for "errorFormat" provided to PrismaClient constructor.`);if(!PR.includes(e)){let A=Ni(e,PR);throw new Me(`Invalid errorFormat ${e} provided to PrismaClient constructor.${A}`)}}},log:e=>{if(!e)return;if(!Array.isArray(e))throw new Me(`Invalid value ${JSON.stringify(e)} for "log" provided to PrismaClient constructor.`);function A(t){if(typeof t=="string"&&!GR.includes(t)){let r=Ni(t,GR);throw new Me(`Invalid log level "${t}" provided to PrismaClient constructor.${r}`)}}for(let t of e){A(t);let r={level:A,emit:n=>{let i=["stdout","event"];if(!i.includes(n)){let s=Ni(n,i);throw new Me(`Invalid value ${JSON.stringify(n)} for "emit" in logLevel provided to PrismaClient constructor.${s}`)}}};if(t&&typeof t=="object")for(let[n,i]of Object.entries(t))if(r[n])r[n](i);else throw new Me(`Invalid property ${n} for "log" provided to PrismaClient constructor`)}},__internal:e=>{if(!e)return;let A=["debug","hooks","engine","measurePerformance"];if(typeof e!="object")throw new Me(`Invalid value ${JSON.stringify(e)} for "__internal" to PrismaClient constructor`);for(let[t]of Object.entries(e))if(!A.includes(t)){let r=Ni(t,A);throw new Me(`Invalid property ${JSON.stringify(t)} for "__internal" provided to PrismaClient constructor.${r}`)}}};function YR(e,A){for(let[t,r]of Object.entries(e)){if(!vR.includes(t)){let n=Ni(t,vR);throw new Me(`Unknown property ${t} provided to PrismaClient constructor.${n}`)}VJ[t](r,A)}if(e.datasourceUrl&&e.datasources)throw new Me('Can not use "datasourceUrl" and "datasources" options at the same time. Pick one of them')}function Ni(e,A){if(A.length===0||typeof e!="string")return"";let t=qJ(e,A);return t?` Did you mean "${t}"?`:""}function qJ(e,A){if(A.length===0)return null;let t=A.map(n=>({value:n,distance:(0,JR.default)(e,n)}));t.sort((n,i)=>n.distance<i.distance?-1:1);let r=t[0];return r.distance<3?r.value:null}function VR(e){return e.length===0?Promise.resolve([]):new Promise((A,t)=>{let r=new Array(e.length),n=null,i=!1,s=0,o=()=>{i||(s++,s===e.length&&(i=!0,n?t(n):A(r)))},a=c=>{i||(i=!0,t(c))};for(let c=0;c<e.length;c++)e[c].then(g=>{r[c]=g,o()},g=>{if(!Cg(g)){a(g);return}g.batchRequestIdx===c?a(g):(n||(n=g),o())})})}var pr=ce("prisma:client");typeof globalThis=="object"&&(globalThis.NODE_CLIENT=!0);var OJ={requestArgsToMiddlewareArgs:e=>e,middlewareArgsToRequestArgs:e=>e},HJ=Symbol.for("prisma.client.transaction.id"),WJ={id:0,nextId(){return++this.id}};function _R(e){class A{constructor(r){this._middlewares=new dg;this._createPrismaPromise=ed();this.$extends=Xf;EI(e),r&&YR(r,e);let n=r?.adapter?cf(r.adapter):void 0,i=new HR.EventEmitter().on("error",()=>{});this._extensions=wa.empty(),this._previewFeatures=cg(e),this._clientVersion=e.clientVersion??TR,this._activeProvider=e.activeProvider,this._tracingHelper=DR(this._previewFeatures);let s={rootEnvPath:e.relativeEnvPaths.rootEnvPath&&Qo.default.resolve(e.dirname,e.relativeEnvPaths.rootEnvPath),schemaEnvPath:e.relativeEnvPaths.schemaEnvPath&&Qo.default.resolve(e.dirname,e.relativeEnvPaths.schemaEnvPath)},o=!n&&qi(s,{conflictCheck:"none"})||e.injectableEdgeEnv?.();try{let a=r??{},c=a.__internal??{},g=c.debug===!0;g&&ce.enable("prisma:client");let l=Qo.default.resolve(e.dirname,e.relativePath);WR.default.existsSync(l)||(l=e.dirname),pr("dirname",e.dirname),pr("relativePath",e.relativePath),pr("cwd",l);let u=c.engine||{};if(a.errorFormat?this._errorFormat=a.errorFormat:process.env.NODE_ENV==="production"?this._errorFormat="minimal":process.env.NO_COLOR?this._errorFormat="colorless":this._errorFormat="colorless",this._runtimeDataModel=e.runtimeDataModel,this._engineConfig={cwd:l,dirname:e.dirname,enableDebugLogs:g,allowTriggerPanic:u.allowTriggerPanic,datamodelPath:Qo.default.join(e.dirname,e.filename??"schema.prisma"),prismaPath:u.binaryPath??void 0,engineEndpoint:u.endpoint,generator:e.generator,showColors:this._errorFormat==="pretty",logLevel:a.log&&kR(a.log),logQueries:a.log&&!!(typeof a.log=="string"?a.log==="query":a.log.find(E=>typeof E=="string"?E==="query":E.level==="query")),env:o?.parsed??{},flags:[],clientVersion:e.clientVersion,engineVersion:e.engineVersion,previewFeatures:this._previewFeatures,activeProvider:e.activeProvider,inlineSchema:e.inlineSchema,overrideDatasources:hI(a,e.datasourceNames),inlineDatasources:e.inlineDatasources,inlineSchemaHash:e.inlineSchemaHash,tracingHelper:this._tracingHelper,logEmitter:i,isBundled:e.isBundled,adapter:n},pr("clientVersion",e.clientVersion),this._engine=tR(e,this._engineConfig),this._requestHandler=new Ig(this,i),a.log)for(let E of a.log){let h=typeof E=="string"?E:E.emit==="stdout"?E.level:null;h&&this.$on(h,d=>{Wi.log(`${Wi.tags[h]??""}`,d.message||d.query)})}this._metrics=new yn(this._engine)}catch(a){throw a.clientVersion=this._clientVersion,a}return this._appliedParent=is(this)}get[Symbol.toStringTag](){return"PrismaClient"}$use(r){this._middlewares.use(r)}$o
Line truncated
|
||||||
|
-`)),g?.kind==="batch"&&await g.lock,this._requestHandler.request({protocolQuery:d,modelName:a,action:o,clientMethod:n,dataPath:i,callsite:s,args:r,extensions:this._extensions,transaction:g,unpacker:l,otelParentCtx:u,otelChildCtx:this._tracingHelper.getActiveContext(),customDataProxyFetch:E})}catch(h){throw h.clientVersion=this._clientVersion,h}}get $metrics(){if(!this._hasPreviewFlag("metrics"))throw new AA("`metrics` preview feature must be enabled in order to access metrics API",{clientVersion:this._clientVersion});return this._metrics}_hasPreviewFlag(r){return!!this._engineConfig.previewFeatures?.includes(r)}}return A}function qR(e,A){return _J(e)?[new QA(e,A),mR]:[e,yR]}function _J(e){return Array.isArray(e)&&Array.isArray(e.raw)}var KJ=new Set(["toJSON","$$typeof","asymmetricMatch",Symbol.iterator,Symbol.toStringTag,Symbol.isConcatSpreadable,Symbol.toPrimitive]);function KR(e){return new Proxy(e,{get(A,t){if(t in A)return A[t];if(!KJ.has(t))throw new TypeError(`Invalid enum value: ${String(t)}`)}})}function jR(e){qi(e,{conflictCheck:"warn"})}0&&(module.exports={DMMF,DMMFClass,Debug,Decimal,Extensions,MetricsClient,NotFoundError,ObjectEnumValue,PrismaClientInitializationError,PrismaClientKnownRequestError,PrismaClientRustPanicError,PrismaClientUnknownRequestError,PrismaClientValidationError,Public,Sql,Types,defineDmmfProperty,empty,getPrismaClient,itxClientDenyList,join,makeStrictEnum,objectEnumNames,objectEnumValues,raw,sqltag,warnEnvConflicts,warnOnce});
|
||||||
|
+`)),g?.kind==="batch"&&await g.lock,this._requestHandler.request({protocolQuery:d,modelName:a,action:o,clientMethod:n,dataPath:i,callsite:s,args:r,extensions:this._extensions,transaction:g,unpacker:l,otelParentCtx:u,otelChildCtx:this._tracingHelper.getActiveContext(),customDataProxyFetch:E})}catch(h){throw h.clientVersion=this._clientVersion,h}}get $metrics(){if(!this._hasPreviewFlag("metrics"))throw new AA("`metrics` preview feature must be enabled in order to access metrics API",{clientVersion:this._clientVersion});return this._metrics}_hasPreviewFlag(r){return!!this._engineConfig.previewFeatures?.includes(r)}}return A}function qR(e,A){return _J(e)?[new QA(e,A),mR]:[e,yR]}function _J(e){return Array.isArray(e)&&Array.isArray(e.raw)}var KJ=new Set(["toJSON","$$typeof","asymmetricMatch",Symbol.iterator,Symbol.toStringTag,Symbol.isConcatSpreadable,Symbol.toPrimitive]);function KR(e){return new Proxy(e,{get(A,t){if(t in A)return A[t];if(!KJ.has(t))throw new TypeError(`Invalid enum value: ${String(t)}`)}})}function jR(e){qi(e,{conflictCheck:"none"})}0&&(module.exports={DMMF,DMMFClass,Debug,Decimal,Extensions,MetricsClient,NotFoundError,ObjectEnumValue,PrismaClientInitializationError,PrismaClientKnownRequestError,PrismaClientRustPanicError,PrismaClientUnknownRequestError,PrismaClientValidationError,Public,Sql,Types,defineDmmfProperty,empty,getPrismaClient,itxClientDenyList,join,makeStrictEnum,objectEnumNames,objectEnumValues,raw,sqltag,warnEnvConflicts,warnOnce});
|
||||||
|
/*! Bundled license information:
|
||||||
|
|
||||||
|
undici/lib/fetch/body.js:
|
||||||
|
diff --git a/runtime/library.js b/runtime/library.js
|
||||||
|
index 65b30894c697f97a54924dcf7acc4b7e45002f4d..b2d26124f34759bb222a08e76bcc0f6d52b3c573 100644
|
||||||
|
--- a/runtime/library.js
|
||||||
|
+++ b/runtime/library.js
|
||||||
|
@@ -126,7 +126,7 @@ It should have this form: { url: "CONNECTION_STRING" }`);if(n&&typeof n=="object
|
||||||
|
It should have this form: { url: "CONNECTION_STRING" }`);if(typeof o!="string")throw new q(`Invalid value ${JSON.stringify(o)} for datasource "${r}" provided to PrismaClient constructor.
|
||||||
|
It should have this form: { url: "CONNECTION_STRING" }`)}}}},adapter:(e,t)=>{if(e===null)return;if(e===void 0)throw new q('"adapter" property must not be undefined, use null to conditionally disable driver adapters.');if(!gn(t).includes("driverAdapters"))throw new q('"adapter" property can only be provided to PrismaClient constructor when "driverAdapters" preview feature is enabled.')},datasourceUrl:e=>{if(typeof e<"u"&&typeof e!="string")throw new q(`Invalid value ${JSON.stringify(e)} for "datasourceUrl" provided to PrismaClient constructor.
|
||||||
|
Expected string or undefined.`)},errorFormat:e=>{if(e){if(typeof e!="string")throw new q(`Invalid value ${JSON.stringify(e)} for "errorFormat" provided to PrismaClient constructor.`);if(!Cl.includes(e)){let t=Rt(e,Cl);throw new q(`Invalid errorFormat ${e} provided to PrismaClient constructor.${t}`)}}},log:e=>{if(!e)return;if(!Array.isArray(e))throw new q(`Invalid value ${JSON.stringify(e)} for "log" provided to PrismaClient constructor.`);function t(r){if(typeof r=="string"&&!Al.includes(r)){let n=Rt(r,Al);throw new q(`Invalid log level "${r}" provided to PrismaClient constructor.${n}`)}}for(let r of e){t(r);let n={level:t,emit:i=>{let o=["stdout","event"];if(!o.includes(i)){let s=Rt(i,o);throw new q(`Invalid value ${JSON.stringify(i)} for "emit" in logLevel provided to PrismaClient constructor.${s}`)}}};if(r&&typeof r=="object")for(let[i,o]of Object.entries(r))if(n[i])n[i](o);else throw new q(`Invalid property ${i} for "log" provided to PrismaClient constructor`)}},__internal:e=>{if(!e)return;let t=["debug","hooks","engine","measurePerformance"];if(typeof e!="object")throw new q(`Invalid value ${JSON.stringify(e)} for "__internal" to PrismaClient constructor`);for(let[r]of Object.entries(e))if(!t.includes(r)){let n=Rt(r,t);throw new q(`Invalid property ${JSON.stringify(r)} for "__internal" provided to PrismaClient constructor.${n}`)}}};function Ml(e,t){for(let[r,n]of Object.entries(e)){if(!Tl.includes(r)){let i=Rt(r,Tl);throw new q(`Unknown property ${r} provided to PrismaClient constructor.${i}`)}Xd[r](n,t)}if(e.datasourceUrl&&e.datasources)throw new q('Can not use "datasourceUrl" and "datasources" options at the same time. Pick one of them')}function Rt(e,t){if(t.length===0||typeof e!="string")return"";let r=em(e,t);return r?` Did you mean "${r}"?`:""}function em(e,t){if(t.length===0)return null;let r=t.map(i=>({value:i,distance:(0,Rl.default)(e,i)}));r.sort((i,o)=>i.distance<o.distance?-1:1);let n=r[0];return n.distance<3?n.value:null}function Sl(e){return e.length===0?Promise.resolve([]):new Promise((t,r)=>{let n=new Array(e.length),i=null,o=!1,s=0,a=()=>{o||(s++,s===e.length&&(o=!0,i?r(i):t(n)))},l=u=>{o||(o=!0,r(u))};for(let u=0;u<e.length;u++)e[u].then(c=>{n[u]=c,a()},c=>{if(!Pn(c)){l(c);return}c.batchRequestIdx===u?l(c):(i||(i=c),a())})})}var Ue=D("prisma:client");typeof globalThis=="object"&&(globalThis.NODE_CLIENT=!0);var tm={requestArgsToMiddlewareArgs:e=>e,middlewareArgsToRequestArgs:e=>e},rm=Symbol.for("prisma.client.transaction.id"),nm={id:0,nextId(){return++this.id}};function Dl(e){class t{constructor(n){this._middlewares=new wn;this._createPrismaPromise=Hi();this.$extends=ra;xa(e),n&&Ml(n,e);let i=n?.adapter?fs(n.adapter):void 0,o=new Fl.EventEmitter().on("error",()=>{});this._extensions=ln.empty(),this._previewFeatures=gn(e),this._clientVersion=e.clientVersion??wl,this._activeProvider=e.activeProvider,this._tracingHelper=ml(this._previewFeatures);let s={rootEnvPath:e.relativeEnvPaths.rootEnvPath&&yr.default.resolve(e.dirname,e.relativeEnvPaths.rootEnvPath),schemaEnvPath:e.relativeEnvPaths.schemaEnvPath&&yr.default.resolve(e.dirname,e.relativeEnvPaths.schemaEnvPath)},a=!i&&_t(s,{conflictCheck:"none"})||e.injectableEdgeEnv?.();try{let l=n??{},u=l.__internal??{},c=u.debug===!0;c&&D.enable("prisma:client");let p=yr.default.resolve(e.dirname,e.relativePath);Ol.default.existsSync(p)||(p=e.dirname),Ue("dirname",e.dirname),Ue("relativePath",e.relativePath),Ue("cwd",p);let d=u.engine||{};if(l.errorFormat?this._errorFormat=l.errorFormat:process.env.NODE_ENV==="production"?this._errorFormat="minimal":process.env.NO_COLOR?this._errorFormat="colorless":this._errorFormat="colorless",this._runtimeDataModel=e.runtimeDataModel,this._engineConfig={cwd:p,dirname:e.dirname,enableDebugLogs:c,allowTriggerPanic:d.allowTriggerPanic,datamodelPath:yr.default.join(e.dirname,e.filename??"schema.prisma"),prismaPath:d.binaryPath??void 0,engineEndpoint:d.endpoint,generator:e.generator,showColors:this._errorFormat==="pretty",logLevel:l.log&&gl(l.log),logQueries:l.log&&!!(typeof l.log=="string"?l.log==="query":l.log.find(f=>typeof f=="string"?f==="query":f.level==="query")),env:a?.parsed??{},flags:[],clientVersion:e.clientVersion,engineVersion:e.engineVersion,previewFeatures:this._previewFeatures,activeProvider:e.activeProvider,inlineSchema:e.inlineSchema,overrideDatasources:ba(l,e.datasourceNames),inlineDatasources:e.inlineDatasources,inlineSchemaHash:e.inlineSchemaHash,tracingHelper:this._tracingHelper,logEmitter:o,isBundled:e.isBundled,adapter:i},Ue("clientVersion",e.clientVersion),this._engine=Ua(e,this._engineConfig),this._requestHandler=new Cn(this,o),l.log)for(let f of l.log){let y=typeof f=="string"?f:f.emit==="stdout"?f.level:null;y&&this.$on(y,g=>{$t.log(`${$t.tags[y]??""}`,g.message||g.query)})}this._metrics=new dt(this._engine)}catch(l){throw l.clientVersion=this._clientVersion,l}return this._appliedParent=zt(this)}get[Symbol.toStringTag](){return"PrismaClient"}$use(n){this._middlewares.use(n)}$on(n,i){n==="
Line truncated
|
||||||
|
-`)),c?.kind==="batch"&&await c.lock,this._requestHandler.request({protocolQuery:g,modelName:l,action:a,clientMethod:i,dataPath:o,callsite:s,args:n,extensions:this._extensions,transaction:c,unpacker:p,otelParentCtx:d,otelChildCtx:this._tracingHelper.getActiveContext(),customDataProxyFetch:f})}catch(y){throw y.clientVersion=this._clientVersion,y}}get $metrics(){if(!this._hasPreviewFlag("metrics"))throw new X("`metrics` preview feature must be enabled in order to access metrics API",{clientVersion:this._clientVersion});return this._metrics}_hasPreviewFlag(n){return!!this._engineConfig.previewFeatures?.includes(n)}}return t}function Il(e,t){return im(e)?[new oe(e,t),ul]:[e,cl]}function im(e){return Array.isArray(e)&&Array.isArray(e.raw)}var om=new Set(["toJSON","$$typeof","asymmetricMatch",Symbol.iterator,Symbol.toStringTag,Symbol.isConcatSpreadable,Symbol.toPrimitive]);function _l(e){return new Proxy(e,{get(t,r){if(r in t)return t[r];if(!om.has(r))throw new TypeError(`Invalid enum value: ${String(r)}`)}})}function Nl(e){_t(e,{conflictCheck:"warn"})}0&&(module.exports={DMMF,DMMFClass,Debug,Decimal,Extensions,MetricsClient,NotFoundError,ObjectEnumValue,PrismaClientInitializationError,PrismaClientKnownRequestError,PrismaClientRustPanicError,PrismaClientUnknownRequestError,PrismaClientValidationError,Public,Sql,Types,defineDmmfProperty,empty,getPrismaClient,itxClientDenyList,join,makeStrictEnum,objectEnumNames,objectEnumValues,raw,sqltag,warnEnvConflicts,warnOnce});
|
||||||
|
+`)),c?.kind==="batch"&&await c.lock,this._requestHandler.request({protocolQuery:g,modelName:l,action:a,clientMethod:i,dataPath:o,callsite:s,args:n,extensions:this._extensions,transaction:c,unpacker:p,otelParentCtx:d,otelChildCtx:this._tracingHelper.getActiveContext(),customDataProxyFetch:f})}catch(y){throw y.clientVersion=this._clientVersion,y}}get $metrics(){if(!this._hasPreviewFlag("metrics"))throw new X("`metrics` preview feature must be enabled in order to access metrics API",{clientVersion:this._clientVersion});return this._metrics}_hasPreviewFlag(n){return!!this._engineConfig.previewFeatures?.includes(n)}}return t}function Il(e,t){return im(e)?[new oe(e,t),ul]:[e,cl]}function im(e){return Array.isArray(e)&&Array.isArray(e.raw)}var om=new Set(["toJSON","$$typeof","asymmetricMatch",Symbol.iterator,Symbol.toStringTag,Symbol.isConcatSpreadable,Symbol.toPrimitive]);function _l(e){return new Proxy(e,{get(t,r){if(r in t)return t[r];if(!om.has(r))throw new TypeError(`Invalid enum value: ${String(r)}`)}})}function Nl(e){_t(e,{conflictCheck:"none"})}0&&(module.exports={DMMF,DMMFClass,Debug,Decimal,Extensions,MetricsClient,NotFoundError,ObjectEnumValue,PrismaClientInitializationError,PrismaClientKnownRequestError,PrismaClientRustPanicError,PrismaClientUnknownRequestError,PrismaClientValidationError,Public,Sql,Types,defineDmmfProperty,empty,getPrismaClient,itxClientDenyList,join,makeStrictEnum,objectEnumNames,objectEnumValues,raw,sqltag,warnEnvConflicts,warnOnce});
|
||||||
|
/*! Bundled license information:
|
||||||
|
|
||||||
|
decimal.js/decimal.mjs:
|
||||||
+14
-11
@@ -108,10 +108,10 @@ To develop locally:
|
|||||||
nvm use
|
nvm use
|
||||||
```
|
```
|
||||||
|
|
||||||
You first might need to install the specific version and then use it:
|
You first might need to install the specific version:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nvm install && nvm use
|
nvm install
|
||||||
```
|
```
|
||||||
|
|
||||||
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
||||||
@@ -177,24 +177,25 @@ Do not commit your `yarn.lock` unless you've made changes to the `package.json`.
|
|||||||
|
|
||||||
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
|
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout HEAD~1 yarn.lock
|
git checkout HEAD~1 yarn.lock
|
||||||
git commit -m "Revert yarn.lock changes"
|
git commit -m "Revert yarn.lock changes"
|
||||||
```
|
```
|
||||||
|
|
||||||
_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit
|
_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit
|
||||||
If you've pushed the commit with the `yarn.lock`:
|
If you've pushed the commit with the `yarn.lock`:
|
||||||
|
|
||||||
1. Correct the commit locally using the above method.
|
1. Correct the commit locally using the above method.
|
||||||
2. Carefully force push:
|
2. Carefully force push:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push origin <your-branch-name> --force
|
git push origin <your-branch-name> --force
|
||||||
```
|
```
|
||||||
|
|
||||||
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
|
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
|
||||||
|
|
||||||
1. **Checkout a Previous Version**:
|
1. **Checkout a Previous Version**:
|
||||||
|
|
||||||
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
|
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -208,6 +209,7 @@ If `yarn.lock` was committed a while ago and there have been several commits sin
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Commit the Reverted Version**:
|
2. **Commit the Reverted Version**:
|
||||||
|
|
||||||
- After checking out the previous version of the `yarn.lock`, commit this change:
|
- After checking out the previous version of the `yarn.lock`, commit this change:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -215,6 +217,7 @@ If `yarn.lock` was committed a while ago and there have been several commits sin
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. **Proceed with Caution**:
|
3. **Proceed with Caution**:
|
||||||
|
|
||||||
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
|
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -173,12 +173,30 @@ yarn dx
|
|||||||
|
|
||||||
#### Development tip
|
#### Development tip
|
||||||
|
|
||||||
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **tRPC**.
|
Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\
|
||||||
|
Where {level} can be one of the following:
|
||||||
|
|
||||||
|
`0` for silly \
|
||||||
|
`1` for trace \
|
||||||
|
`2` for debug \
|
||||||
|
`3` for info \
|
||||||
|
`4` for warn \
|
||||||
|
`5` for error \
|
||||||
|
`6` for fatal
|
||||||
|
|
||||||
|
When you set `NEXT_PUBLIC_LOGGER_LEVEL={level}` in your .env file, it enables logging at that level and higher. Here's how it works:
|
||||||
|
|
||||||
|
The logger will include all logs that are at the specified level or higher. For example: \
|
||||||
|
|
||||||
|
- If you set `NEXT_PUBLIC_LOGGER_LEVEL=2`, it will log from level 2 (debug) upwards, meaning levels 2 (debug), 3 (info), 4 (warn), 5 (error), and (fatal) will be logged. \
|
||||||
|
- If you set `NEXT_PUBLIC_LOGGER_LEVEL=3`, it will log from level 3 (info) upwards, meaning levels 3 (info), 4 (warn), 5 (error), and 6 (fatal) will be logged, but level 2 (debug) and level 1 (trace) will be ignored. \
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
echo 'NEXT_PUBLIC_LOGGER_LEVEL=3' >> .env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
for Logger level to be set at info, for example.
|
||||||
|
|
||||||
#### Gitpod Setup
|
#### Gitpod Setup
|
||||||
|
|
||||||
1. Click the button below to open this project in Gitpod.
|
1. Click the button below to open this project in Gitpod.
|
||||||
@@ -399,25 +417,25 @@ Cal.com, Inc. is a commercial open source company, which means some parts of thi
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
||||||
|
|
||||||
| | AGPLv3 | EE |
|
| | AGPLv3 | EE |
|
||||||
|---|---|---|
|
| --------------------------------- | ------ | --- |
|
||||||
| Self-host for commercial purposes | ✅ | ✅ |
|
| Self-host for commercial purposes | ✅ | ✅ |
|
||||||
| Clone privately | ✅ | ✅ |
|
| Clone privately | ✅ | ✅ |
|
||||||
| Fork publicly | ✅ | ✅ |
|
| Fork publicly | ✅ | ✅ |
|
||||||
| Requires CLA | ✅ | ✅ |
|
| Requires CLA | ✅ | ✅ |
|
||||||
| Official Support| ❌ | ✅ |
|
| Official Support | ❌ | ✅ |
|
||||||
| Derivative work privately | ❌ | ✅ |
|
| Derivative work privately | ❌ | ✅ |
|
||||||
| SSO | ❌ | ✅ |
|
| SSO | ❌ | ✅ |
|
||||||
| Admin Panel | ❌ | ✅ |
|
| Admin Panel | ❌ | ✅ |
|
||||||
| Impersonation | ❌ | ✅ |
|
| Impersonation | ❌ | ✅ |
|
||||||
| Managed Event Types | ❌ | ✅ |
|
| Managed Event Types | ❌ | ✅ |
|
||||||
| Organizations | ❌ | ✅ |
|
| Organizations | ❌ | ✅ |
|
||||||
| Payments | ❌ | ✅ |
|
| Payments | ❌ | ✅ |
|
||||||
| Platform | ❌ | ✅ |
|
| Platform | ❌ | ✅ |
|
||||||
| Teams | ❌ | ✅ |
|
| Teams | ❌ | ✅ |
|
||||||
| Users | ❌ | ✅ |
|
| Users | ❌ | ✅ |
|
||||||
| Video | ❌ | ✅ |
|
| Video | ❌ | ✅ |
|
||||||
| Workflows | ❌ | ✅ |
|
| Workflows | ❌ | ✅ |
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
|
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
|
||||||
@@ -513,17 +531,18 @@ following
|
|||||||
|
|
||||||
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
|
||||||
2. On the upper right, click "Develop" => "Build App".
|
2. On the upper right, click "Develop" => "Build App".
|
||||||
3. On "OAuth", select "Create".
|
3. Select "General App" , click "Create".
|
||||||
4. Name your App.
|
4. Name your App.
|
||||||
5. Choose "User-managed app" as the app type.
|
5. Choose "User-managed app" for "Select how the app is managed".
|
||||||
6. De-select the option to publish the app on the Zoom App Marketplace.
|
6. De-select the option to publish the app on the Zoom App Marketplace, if asked.
|
||||||
7. Click "Create".
|
7. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
||||||
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
|
8. Set the "OAuth Redirect URL" under "OAuth Information" as `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||||
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
|
9. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
||||||
10. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
|
10. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left,
|
||||||
11. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
1. click the category "Meeting" and check the scope `meeting:write:meeting`.
|
||||||
12. Click "Done".
|
2. click the category "User" and check the scope `user:read:settings`.
|
||||||
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
11. Click "Done".
|
||||||
|
12. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||||
|
|
||||||
### Obtaining Daily API Credentials
|
### Obtaining Daily API Credentials
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||||
import { _AttendeeModel as Attendee } from "@calcom/prisma/zod";
|
import { _AttendeeModel as Attendee } from "@calcom/prisma/zod";
|
||||||
|
|
||||||
import { timeZone } from "~/lib/validations/shared/timeZone";
|
import { timeZone } from "~/lib/validations/shared/timeZone";
|
||||||
@@ -14,7 +15,7 @@ export const schemaAttendeeBaseBodyParams = Attendee.pick({
|
|||||||
const schemaAttendeeCreateParams = z
|
const schemaAttendeeCreateParams = z
|
||||||
.object({
|
.object({
|
||||||
bookingId: z.number().int(),
|
bookingId: z.number().int(),
|
||||||
email: z.string().email(),
|
email: emailSchema,
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
timeZone: timeZone,
|
timeZone: timeZone,
|
||||||
})
|
})
|
||||||
@@ -23,7 +24,7 @@ const schemaAttendeeCreateParams = z
|
|||||||
const schemaAttendeeEditParams = z
|
const schemaAttendeeEditParams = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
email: z.string().email().optional(),
|
email: emailSchema.optional(),
|
||||||
timeZone: timeZone.optional(),
|
timeZone: timeZone.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { _AttendeeModel, _BookingModel as Booking, _PaymentModel, _UserModel } from "@calcom/prisma/zod";
|
import {
|
||||||
|
_AttendeeModel,
|
||||||
|
_BookingModel as Booking,
|
||||||
|
_EventTypeModel,
|
||||||
|
_PaymentModel,
|
||||||
|
_TeamModel,
|
||||||
|
_UserModel,
|
||||||
|
} from "@calcom/prisma/zod";
|
||||||
import { extendedBookingCreateBody, iso8601 } from "@calcom/prisma/zod-utils";
|
import { extendedBookingCreateBody, iso8601 } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { schemaQueryUserId } from "./shared/queryUserId";
|
import { schemaQueryUserId } from "./shared/queryUserId";
|
||||||
@@ -14,6 +21,8 @@ const schemaBookingBaseBodyParams = Booking.pick({
|
|||||||
startTime: true,
|
startTime: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
rescheduledBy: true,
|
||||||
|
cancelledBy: true,
|
||||||
}).partial();
|
}).partial();
|
||||||
|
|
||||||
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
|
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
|
||||||
@@ -33,6 +42,8 @@ const schemaBookingEditParams = z
|
|||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
startTime: iso8601.optional(),
|
startTime: iso8601.optional(),
|
||||||
endTime: iso8601.optional(),
|
endTime: iso8601.optional(),
|
||||||
|
cancelledBy: z.string().email({ message: "Invalid Email" }).optional(),
|
||||||
|
rescheduledBy: z.string().email({ message: "Invalid Email" }).optional(),
|
||||||
// Not supporting responses in edit as that might require re-triggering emails
|
// Not supporting responses in edit as that might require re-triggering emails
|
||||||
// responses
|
// responses
|
||||||
})
|
})
|
||||||
@@ -42,10 +53,27 @@ export const schemaBookingEditBodyParams = schemaBookingBaseBodyParams
|
|||||||
.merge(schemaBookingEditParams)
|
.merge(schemaBookingEditParams)
|
||||||
.omit({ uid: true });
|
.omit({ uid: true });
|
||||||
|
|
||||||
|
const teamSchema = _TeamModel.pick({
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const schemaBookingReadPublic = Booking.extend({
|
export const schemaBookingReadPublic = Booking.extend({
|
||||||
|
eventType: _EventTypeModel
|
||||||
|
.pick({
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
})
|
||||||
|
.merge(
|
||||||
|
z.object({
|
||||||
|
team: teamSchema.nullish(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.nullish(),
|
||||||
attendees: z
|
attendees: z
|
||||||
.array(
|
.array(
|
||||||
_AttendeeModel.pick({
|
_AttendeeModel.pick({
|
||||||
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
@@ -83,9 +111,12 @@ export const schemaBookingReadPublic = Booking.extend({
|
|||||||
timeZone: true,
|
timeZone: true,
|
||||||
attendees: true,
|
attendees: true,
|
||||||
user: true,
|
user: true,
|
||||||
|
eventType: true,
|
||||||
payment: true,
|
payment: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
status: true,
|
status: true,
|
||||||
responses: true,
|
responses: true,
|
||||||
fromReschedule: true,
|
fromReschedule: true,
|
||||||
|
cancelledBy: true,
|
||||||
|
rescheduledBy: true,
|
||||||
});
|
});
|
||||||
@@ -168,6 +168,10 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
|||||||
metadata: jsonSchema.nullable(),
|
metadata: jsonSchema.nullable(),
|
||||||
customInputs: customInputSchema.array().optional(),
|
customInputs: customInputSchema.array().optional(),
|
||||||
link: z.string().optional(),
|
link: z.string().optional(),
|
||||||
|
hashedLink: z
|
||||||
|
.array(z.object({ link: z.string() }))
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
bookingFields: eventTypeBookingFields.optional().nullable(),
|
bookingFields: eventTypeBookingFields.optional().nullable(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { withValidation } from "next-validations";
|
import { withValidation } from "next-validations";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||||
|
|
||||||
import { baseApiParams } from "./baseApiParams";
|
import { baseApiParams } from "./baseApiParams";
|
||||||
|
|
||||||
// Extracted out as utility function so can be reused
|
// Extracted out as utility function so can be reused
|
||||||
// at different endpoints that require this validation.
|
// at different endpoints that require this validation.
|
||||||
export const schemaQueryAttendeeEmail = baseApiParams.extend({
|
export const schemaQueryAttendeeEmail = baseApiParams.extend({
|
||||||
attendeeEmail: z.string().email(),
|
attendeeEmail: emailSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemaQuerySingleOrMultipleAttendeeEmails = z.object({
|
export const schemaQuerySingleOrMultipleAttendeeEmails = z.object({
|
||||||
attendeeEmail: z.union([z.string().email(), z.array(z.string().email())]).optional(),
|
attendeeEmail: z.union([emailSchema, z.array(emailSchema)]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const withValidQueryAttendeeEmail = withValidation({
|
export const withValidQueryAttendeeEmail = withValidation({
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const expandEnum = z.enum(["team"]);
|
||||||
|
|
||||||
|
export const schemaQuerySingleOrMultipleExpand = z
|
||||||
|
.union([
|
||||||
|
expandEnum, // Allow a single value from the enum
|
||||||
|
z.array(expandEnum).refine((arr) => new Set(arr).size === arr.length, {
|
||||||
|
message: "Array values must be unique",
|
||||||
|
}), // Allow an array of enum values, with uniqueness constraint
|
||||||
|
])
|
||||||
|
.optional();
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { withValidation } from "next-validations";
|
import { withValidation } from "next-validations";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||||
|
|
||||||
import { baseApiParams } from "./baseApiParams";
|
import { baseApiParams } from "./baseApiParams";
|
||||||
|
|
||||||
// Extracted out as utility function so can be reused
|
// Extracted out as utility function so can be reused
|
||||||
// at different endpoints that require this validation.
|
// at different endpoints that require this validation.
|
||||||
export const schemaQueryUserEmail = baseApiParams.extend({
|
export const schemaQueryUserEmail = baseApiParams.extend({
|
||||||
email: z.string().email(),
|
email: emailSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const schemaQuerySingleOrMultipleUserEmails = z.object({
|
export const schemaQuerySingleOrMultipleUserEmails = z.object({
|
||||||
email: z.union([z.string().email(), z.array(z.string().email())]),
|
email: z.union([emailSchema, z.array(emailSchema)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const withValidQueryUserEmail = withValidation({
|
export const withValidQueryUserEmail = withValidation({
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
|
|||||||
isPlatform: true,
|
isPlatform: true,
|
||||||
smsLockState: true,
|
smsLockState: true,
|
||||||
smsLockReviewedByAdmin: true,
|
smsLockReviewedByAdmin: true,
|
||||||
|
bookingLimits: true,
|
||||||
|
includeManagedEventsInLimits: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const schemaTeamRequiredParams = z.object({
|
const schemaTeamRequiredParams = z.object({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||||
import { _UserModel as User } from "@calcom/prisma/zod";
|
import { _UserModel as User } from "@calcom/prisma/zod";
|
||||||
import { iso8601 } from "@calcom/prisma/zod-utils";
|
import { iso8601 } from "@calcom/prisma/zod-utils";
|
||||||
@@ -30,7 +31,7 @@ enum locales {
|
|||||||
RO = "ro",
|
RO = "ro",
|
||||||
NL = "nl",
|
NL = "nl",
|
||||||
PT_BR = "pt-BR",
|
PT_BR = "pt-BR",
|
||||||
// ES_419 = "es-419", // Disabled until Crowdin reaches at least 80% completion
|
ES_419 = "es-419",
|
||||||
KO = "ko",
|
KO = "ko",
|
||||||
JA = "ja",
|
JA = "ja",
|
||||||
PL = "pl",
|
PL = "pl",
|
||||||
@@ -92,7 +93,7 @@ export const schemaUserBaseBodyParams = User.pick({
|
|||||||
// Here we can both require or not (adding optional or nullish) and also rewrite validations for any value
|
// Here we can both require or not (adding optional or nullish) and also rewrite validations for any value
|
||||||
// for example making weekStart only accept weekdays as input
|
// for example making weekStart only accept weekdays as input
|
||||||
const schemaUserEditParams = z.object({
|
const schemaUserEditParams = z.object({
|
||||||
email: z.string().email().toLowerCase(),
|
email: emailSchema.toLowerCase(),
|
||||||
username: usernameSchema,
|
username: usernameSchema,
|
||||||
weekStart: z.nativeEnum(weekdays).optional(),
|
weekStart: z.nativeEnum(weekdays).optional(),
|
||||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||||
@@ -115,7 +116,7 @@ const schemaUserEditParams = z.object({
|
|||||||
// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end.
|
// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end.
|
||||||
|
|
||||||
const schemaUserCreateParams = z.object({
|
const schemaUserCreateParams = z.object({
|
||||||
email: z.string().email().toLowerCase(),
|
email: emailSchema.toLowerCase(),
|
||||||
username: usernameSchema,
|
username: usernameSchema,
|
||||||
weekStart: z.nativeEnum(weekdays).optional(),
|
weekStart: z.nativeEnum(weekdays).optional(),
|
||||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@sentry/nextjs": "^8.8.0",
|
"@sentry/nextjs": "^8.8.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "^13.5.4",
|
"next": "^13.5.6",
|
||||||
"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",
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
eventTypeId,
|
eventTypeId,
|
||||||
userId,
|
userId,
|
||||||
returnDateOverrides: true,
|
returnDateOverrides: true,
|
||||||
|
bypassBusyCalendarTimes: false,
|
||||||
});
|
});
|
||||||
const team = await prisma.team.findUnique({
|
const team = await prisma.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
@@ -235,6 +236,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
dateTo,
|
dateTo,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
returnDateOverrides: true,
|
returnDateOverrides: true,
|
||||||
|
bypassBusyCalendarTimes: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,40 +30,59 @@ async function authMiddleware(req: NextApiRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userWithBookingsAndTeamIds = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
include: {
|
select: {
|
||||||
|
email: true,
|
||||||
bookings: true,
|
bookings: true,
|
||||||
teams: {
|
},
|
||||||
select: {
|
});
|
||||||
teamId: true,
|
|
||||||
|
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
|
||||||
|
|
||||||
|
const filteredBookings = user?.bookings?.filter((booking) => booking.id === id);
|
||||||
|
const userIsHost = !!filteredBookings?.length;
|
||||||
|
|
||||||
|
const bookingsAsAttendee = prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
attendees: { some: { email: user.email } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingsAsEventTypeOwner = prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
eventType: {
|
||||||
|
owner: { id: userId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingsAsTeamOwnerOrAdmin = prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
eventType: {
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: { userId, role: { in: ["ADMIN", "OWNER"] }, accepted: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userWithBookingsAndTeamIds) throw new HttpError({ statusCode: 404, message: "User not found" });
|
const [resultOne, resultTwo, resultThree] = await Promise.all([
|
||||||
|
bookingsAsAttendee,
|
||||||
|
bookingsAsEventTypeOwner,
|
||||||
|
bookingsAsTeamOwnerOrAdmin,
|
||||||
|
]);
|
||||||
|
|
||||||
const userBookingIds = userWithBookingsAndTeamIds.bookings.map((booking) => booking.id);
|
const teamBookingsAsOwnerOrAdmin = [...resultOne, ...resultTwo, ...resultThree];
|
||||||
|
const userHasTeamBookings = !!teamBookingsAsOwnerOrAdmin.length;
|
||||||
|
|
||||||
if (!userBookingIds.includes(id)) {
|
if (!userIsHost && !userHasTeamBookings)
|
||||||
const teamBookings = await prisma.booking.findUnique({
|
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
eventType: {
|
|
||||||
team: {
|
|
||||||
id: {
|
|
||||||
in: userWithBookingsAndTeamIds.teams.map((team) => team.teamId),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!teamBookings) {
|
|
||||||
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default authMiddleware;
|
export default authMiddleware;
|
||||||
@@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
|
|||||||
|
|
||||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
import { bookingCancelSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
|||||||
*/
|
*/
|
||||||
async function handler(req: NextApiRequest) {
|
async function handler(req: NextApiRequest) {
|
||||||
const { id, allRemainingBookings, cancellationReason } = schemaQueryIdParseInt
|
const { id, allRemainingBookings, cancellationReason } = schemaQueryIdParseInt
|
||||||
.merge(schemaBookingCancelParams.pick({ allRemainingBookings: true, cancellationReason: true }))
|
.merge(bookingCancelSchema.pick({ allRemainingBookings: true, cancellationReason: true }))
|
||||||
.parse({
|
.parse({
|
||||||
...req.query,
|
...req.query,
|
||||||
allRemainingBookings: req.query.allRemainingBookings === "true",
|
allRemainingBookings: req.query.allRemainingBookings === "true",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { defaultResponder } from "@calcom/lib/server";
|
|||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { schemaBookingReadPublic } from "~/lib/validations/booking";
|
import { schemaBookingReadPublic } from "~/lib/validations/booking";
|
||||||
|
import { schemaQuerySingleOrMultipleExpand } from "~/lib/validations/shared/queryExpandRelations";
|
||||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,9 +90,21 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
|||||||
export async function getHandler(req: NextApiRequest) {
|
export async function getHandler(req: NextApiRequest) {
|
||||||
const { query } = req;
|
const { query } = req;
|
||||||
const { id } = schemaQueryIdParseInt.parse(query);
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
|
|
||||||
|
const queryFilterForExpand = schemaQuerySingleOrMultipleExpand.parse(req.query.expand);
|
||||||
|
const expand = Array.isArray(queryFilterForExpand)
|
||||||
|
? queryFilterForExpand
|
||||||
|
: queryFilterForExpand
|
||||||
|
? [queryFilterForExpand]
|
||||||
|
: [];
|
||||||
const booking = await prisma.booking.findUnique({
|
const booking = await prisma.booking.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { attendees: true, user: true, payment: true },
|
include: {
|
||||||
|
attendees: true,
|
||||||
|
user: true,
|
||||||
|
payment: true,
|
||||||
|
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return { booking: schemaBookingReadPublic.parse(booking) };
|
return { booking: schemaBookingReadPublic.parse(booking) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ import { getTranscriptFromRecordingId } from "~/lib/validations/shared/queryIdTr
|
|||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: OK
|
* description: OK
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* 401:
|
* 401:
|
||||||
* description: Authorization information is missing or invalid.
|
* description: Authorization information is missing or invalid.
|
||||||
* 404:
|
* 404:
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
|||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: OK
|
* description: OK
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* 401:
|
* 401:
|
||||||
* description: Authorization information is missing or invalid.
|
* description: Authorization information is missing or invalid.
|
||||||
* 404:
|
* 404:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "~/lib/utils/retrieveScopedAccessibleUsers";
|
} from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
import { schemaBookingGetParams, schemaBookingReadPublic } from "~/lib/validations/booking";
|
import { schemaBookingGetParams, schemaBookingReadPublic } from "~/lib/validations/booking";
|
||||||
import { schemaQuerySingleOrMultipleAttendeeEmails } from "~/lib/validations/shared/queryAttendeeEmail";
|
import { schemaQuerySingleOrMultipleAttendeeEmails } from "~/lib/validations/shared/queryAttendeeEmail";
|
||||||
|
import { schemaQuerySingleOrMultipleExpand } from "~/lib/validations/shared/queryExpandRelations";
|
||||||
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,10 +217,18 @@ export async function handler(req: NextApiRequest) {
|
|||||||
args.take = take;
|
args.take = take;
|
||||||
args.skip = skip;
|
args.skip = skip;
|
||||||
}
|
}
|
||||||
|
const queryFilterForExpand = schemaQuerySingleOrMultipleExpand.parse(req.query.expand);
|
||||||
|
const expand = Array.isArray(queryFilterForExpand)
|
||||||
|
? queryFilterForExpand
|
||||||
|
: queryFilterForExpand
|
||||||
|
? [queryFilterForExpand]
|
||||||
|
: [];
|
||||||
|
|
||||||
args.include = {
|
args.include = {
|
||||||
attendees: true,
|
attendees: true,
|
||||||
user: true,
|
user: true,
|
||||||
payment: true,
|
payment: true,
|
||||||
|
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryFilterForAttendeeEmails = schemaQuerySingleOrMultipleAttendeeEmails.parse(req.query);
|
const queryFilterForAttendeeEmails = schemaQuerySingleOrMultipleAttendeeEmails.parse(req.query);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import prisma from "@calcom/prisma";
|
|||||||
|
|
||||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
import checkParentEventOwnership from "../_utils/checkParentEventOwnership";
|
||||||
|
import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /event-types/{id}:
|
* /event-types/{id}:
|
||||||
@@ -49,9 +52,18 @@ async function checkPermissions(req: NextApiRequest) {
|
|||||||
const { userId, isSystemWideAdmin } = req;
|
const { userId, isSystemWideAdmin } = req;
|
||||||
const { id } = schemaQueryIdParseInt.parse(req.query);
|
const { id } = schemaQueryIdParseInt.parse(req.query);
|
||||||
if (isSystemWideAdmin) return;
|
if (isSystemWideAdmin) return;
|
||||||
/** Only event type owners can delete it */
|
|
||||||
const eventType = await prisma.eventType.findFirst({ where: { id, userId } });
|
const eventType = await prisma.eventType.findFirst({ where: { id } });
|
||||||
|
|
||||||
if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||||
|
|
||||||
|
/** Only event type owners or team owners for team events can delete it */
|
||||||
|
if (eventType.teamId) return await checkTeamEventEditPermission(req, { teamId: eventType.teamId });
|
||||||
|
|
||||||
|
if (eventType.parentId) return await checkParentEventOwnership(req);
|
||||||
|
|
||||||
|
if (eventType.userId && eventType.userId !== userId)
|
||||||
|
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultResponder(deleteHandler);
|
export default defaultResponder(deleteHandler);
|
||||||
@@ -51,6 +51,7 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
customInputs: true,
|
customInputs: true,
|
||||||
|
hashedLink: { select: { link: true } },
|
||||||
team: { select: { slug: true } },
|
team: { select: { slug: true } },
|
||||||
hosts: { select: { userId: true, isFixed: true } },
|
hosts: { select: { userId: true, isFixed: true } },
|
||||||
owner: { select: { username: true, id: true } },
|
owner: { select: { username: true, id: true } },
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async function getHandler(req: NextApiRequest) {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
customInputs: true,
|
customInputs: true,
|
||||||
|
hashedLink: { select: { link: true } },
|
||||||
team: { select: { slug: true } },
|
team: { select: { slug: true } },
|
||||||
hosts: { select: { userId: true, isFixed: true } },
|
hosts: { select: { userId: true, isFixed: true } },
|
||||||
owner: { select: { username: true, id: true } },
|
owner: { select: { username: true, id: true } },
|
||||||
|
|||||||
@@ -18,12 +18,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
|
|||||||
/** These are already parsed upstream, we can assume they're good here. */
|
/** These are already parsed upstream, we can assume they're good here. */
|
||||||
const parentId = Number(body.parentId);
|
const parentId = Number(body.parentId);
|
||||||
const parentEventType = await prisma.eventType.findUnique({
|
const parentEventType = await prisma.eventType.findUnique({
|
||||||
where: {
|
where: { id: parentId },
|
||||||
id: parentId,
|
select: { teamId: true },
|
||||||
},
|
|
||||||
select: {
|
|
||||||
teamId: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parentEventType) {
|
if (!parentEventType) {
|
||||||
@@ -44,7 +40,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
|
|||||||
where: {
|
where: {
|
||||||
teamId: parentEventType.teamId,
|
teamId: parentEventType.teamId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
OR: [{ role: "OWNER" }, { role: "ADMIN" }],
|
role: { in: ["ADMIN", "OWNER"] },
|
||||||
|
accepted: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,21 +10,17 @@ export default async function checkTeamEventEditPermission(
|
|||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
body: Pick<z.infer<typeof schemaEventTypeCreateBodyParams>, "teamId" | "userId">
|
body: Pick<z.infer<typeof schemaEventTypeCreateBodyParams>, "teamId" | "userId">
|
||||||
) {
|
) {
|
||||||
const { isSystemWideAdmin } = req;
|
|
||||||
let userId = req.userId;
|
|
||||||
if (isSystemWideAdmin && body.userId) {
|
|
||||||
userId = body.userId;
|
|
||||||
}
|
|
||||||
if (body.teamId) {
|
if (body.teamId) {
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId: req.userId,
|
||||||
teamId: body.teamId,
|
teamId: body.teamId,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
role: { in: ["ADMIN", "OWNER"] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!membership?.role || !["ADMIN", "OWNER"].includes(membership.role)) {
|
if (!membership) {
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: "No permission to operate on event-type for this team",
|
message: "No permission to operate on event-type for this team",
|
||||||
|
|||||||
@@ -66,11 +66,8 @@ describe("GET /api/slots", () => {
|
|||||||
buildMockData();
|
buildMockData();
|
||||||
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(JSON.parse(res._getData())).toMatchInlineSnapshot(`
|
const response = JSON.parse(res._getData());
|
||||||
{
|
expect(response.slots).toEqual(expect.objectContaining({}));
|
||||||
"slots": {},
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
test("Returns and event type available slots with passed timeZone", async () => {
|
test("Returns and event type available slots with passed timeZone", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
@@ -87,11 +84,8 @@ describe("GET /api/slots", () => {
|
|||||||
buildMockData();
|
buildMockData();
|
||||||
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(JSON.parse(res._getData())).toMatchInlineSnapshot(`
|
const response = JSON.parse(res._getData());
|
||||||
{
|
expect(response.slots).toEqual(expect.objectContaining({}));
|
||||||
"slots": {},
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import utc from "dayjs/plugin/utc";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
@@ -17,14 +18,22 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
|||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
let isColdStart = true;
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
try {
|
try {
|
||||||
const { usernameList, ...rest } = req.query;
|
if (isColdStart && IS_PRODUCTION) {
|
||||||
|
console.log("Cold start of /v1/slots detected");
|
||||||
|
isColdStart = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { usernameList, isTeamEvent, ...rest } = req.query;
|
||||||
|
const parsedIsTeamEvent = String(isTeamEvent).toLowerCase() === "true";
|
||||||
let slugs = usernameList;
|
let slugs = usernameList;
|
||||||
if (!Array.isArray(usernameList)) {
|
if (!Array.isArray(usernameList)) {
|
||||||
slugs = usernameList ? [usernameList] : undefined;
|
slugs = usernameList ? [usernameList] : undefined;
|
||||||
}
|
}
|
||||||
const input = getScheduleSchema.parse({ usernameList: slugs, ...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 availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
||||||
const slotsInProvidedTimeZone = timeZoneSupported
|
const slotsInProvidedTimeZone = timeZoneSupported
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export async function canUserAccessTeamWithRole(
|
|||||||
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
|
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
|
||||||
if (!isSystemWideAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
|
if (!isSystemWideAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
|
||||||
const team = await prisma.team.findFirst(args);
|
const team = await prisma.team.findFirst(args);
|
||||||
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: ${role.toString()} required` });
|
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: OWNER or ADMIN role required` });
|
||||||
return team;
|
return team;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,9 +123,11 @@ export async function patchHandler(req: NextApiRequest) {
|
|||||||
// TODO: Perhaps there is a better fix for this?
|
// TODO: Perhaps there is a better fix for this?
|
||||||
const cloneData: typeof data & {
|
const cloneData: typeof data & {
|
||||||
metadata: NonNullable<typeof data.metadata> | undefined;
|
metadata: NonNullable<typeof data.metadata> | undefined;
|
||||||
|
bookingLimits: NonNullable<typeof data.bookingLimits> | undefined;
|
||||||
} = {
|
} = {
|
||||||
...data,
|
...data,
|
||||||
smsLockReviewedByAdmin: false,
|
smsLockReviewedByAdmin: false,
|
||||||
|
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
|
||||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||||
};
|
};
|
||||||
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
|
||||||
|
|||||||
@@ -123,9 +123,11 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
// TODO: Perhaps there is a better fix for this?
|
// TODO: Perhaps there is a better fix for this?
|
||||||
const cloneData: typeof data & {
|
const cloneData: typeof data & {
|
||||||
metadata: NonNullable<typeof data.metadata> | undefined;
|
metadata: NonNullable<typeof data.metadata> | undefined;
|
||||||
|
bookingLimits: NonNullable<typeof data.bookingLimits> | undefined;
|
||||||
} = {
|
} = {
|
||||||
...data,
|
...data,
|
||||||
smsLockReviewedByAdmin: false,
|
smsLockReviewedByAdmin: false,
|
||||||
|
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits || undefined,
|
||||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,6 +217,7 @@ const generateTeamCheckoutSession = async ({
|
|||||||
metadata: {
|
metadata: {
|
||||||
pendingPaymentTeamId,
|
pendingPaymentTeamId,
|
||||||
ownerId,
|
ownerId,
|
||||||
|
dubCustomerId: ownerId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
# Set the version of docker compose to use
|
|
||||||
version: "3.9"
|
|
||||||
|
|
||||||
# The containers that compose the project
|
# The containers that compose the project
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -1,90 +1,332 @@
|
|||||||
|
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";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, test } from "vitest";
|
||||||
|
|
||||||
import type { HttpError } from "@calcom/lib/http-error";
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
import prisma from "@calcom/prisma";
|
|
||||||
|
|
||||||
import authMiddleware from "../../../pages/api/bookings/[id]/_auth-middleware";
|
import authMiddleware from "../../../pages/api/bookings/[id]/_auth-middleware";
|
||||||
|
|
||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
describe("Bookings auth middleware", () => {
|
describe("Booking ownership and access in Middleware", () => {
|
||||||
it("Returns 403 when user has no permission to the booking", async () => {
|
const adminUserId = 1;
|
||||||
const trialUser = await prisma.user.findFirstOrThrow({ where: { email: "trial@example.com" } });
|
const ownerUserId = 2;
|
||||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
const memberUserId = 3;
|
||||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
const orgOwnerUserId = 4;
|
||||||
|
const adminUserEmail = "admin@example.com";
|
||||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const ownerUserEmail = "owner@example.com";
|
||||||
method: "GET",
|
const memberUserEmail = "member@example.com";
|
||||||
body: {},
|
const orgOwnerUserEmail = "org-owner@example.com";
|
||||||
query: {
|
// mock user data
|
||||||
id: booking.id,
|
function buildMockData() {
|
||||||
|
//Create Users
|
||||||
|
prismock.user.create({
|
||||||
|
data: {
|
||||||
|
id: adminUserId,
|
||||||
|
username: "admin",
|
||||||
|
name: "Admin User",
|
||||||
|
email: adminUserEmail,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
prismock.user.create({
|
||||||
|
data: {
|
||||||
|
id: ownerUserId,
|
||||||
|
username: "owner",
|
||||||
|
name: "Owner User",
|
||||||
|
email: ownerUserEmail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prismock.user.create({
|
||||||
|
data: {
|
||||||
|
id: orgOwnerUserId,
|
||||||
|
username: "org-owner",
|
||||||
|
name: "Org Owner",
|
||||||
|
email: orgOwnerUserEmail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prismock.user.create({
|
||||||
|
data: {
|
||||||
|
id: memberUserId,
|
||||||
|
username: "member",
|
||||||
|
name: "Member User",
|
||||||
|
email: memberUserEmail,
|
||||||
|
bookings: {
|
||||||
|
create: {
|
||||||
|
id: 2,
|
||||||
|
uid: "2",
|
||||||
|
title: "Booking 2",
|
||||||
|
eventTypeId: 1,
|
||||||
|
startTime: "2024-08-30T06:45:00.000Z",
|
||||||
|
endTime: "2024-08-30T07:45:00.000Z",
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
name: "Member User",
|
||||||
|
email: memberUserEmail,
|
||||||
|
timeZone: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
//create team
|
||||||
|
prismock.team.create({
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
name: "Team 1",
|
||||||
|
slug: "team1",
|
||||||
|
members: {
|
||||||
|
createMany: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
userId: adminUserId,
|
||||||
|
role: MembershipRole.ADMIN,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: ownerUserId,
|
||||||
|
role: MembershipRole.OWNER,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: memberUserId,
|
||||||
|
role: MembershipRole.MEMBER,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
//create Org
|
||||||
|
prismock.team.create({
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
name: "Org",
|
||||||
|
slug: "org",
|
||||||
|
isOrganization: true,
|
||||||
|
children: {
|
||||||
|
connect: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
createMany: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
userId: orgOwnerUserId,
|
||||||
|
role: MembershipRole.OWNER,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: memberUserId,
|
||||||
|
role: MembershipRole.MEMBER,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: ownerUserId,
|
||||||
|
role: MembershipRole.MEMBER,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: adminUserId,
|
||||||
|
role: MembershipRole.MEMBER,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
//create eventTypes
|
||||||
|
prismock.eventType.create({
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
title: "Event 1",
|
||||||
|
slug: "event",
|
||||||
|
length: 60,
|
||||||
|
bookings: {
|
||||||
|
connect: {
|
||||||
|
id: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prismock.eventType.create({
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
title: "Event 2",
|
||||||
|
slug: "event",
|
||||||
|
length: 60,
|
||||||
|
teamId: 1,
|
||||||
|
bookings: {
|
||||||
|
connect: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
//link eventType to teams
|
||||||
|
prismock.eventType.update({
|
||||||
|
where: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
owner: {
|
||||||
|
connect: {
|
||||||
|
id: ownerUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prismock.eventType.update({
|
||||||
|
where: {
|
||||||
|
id: 2,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
team: {
|
||||||
|
connect: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
//link team to org
|
||||||
|
prismock.team.update({
|
||||||
|
where: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
parentId: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Call Prisma to create booking with attendees
|
||||||
|
prismock.booking.create({
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
uid: "1",
|
||||||
|
title: "Booking 1",
|
||||||
|
userId: 1,
|
||||||
|
startTime: "2024-08-30T06:45:00.000Z",
|
||||||
|
endTime: "2024-08-30T07:45:00.000Z",
|
||||||
|
eventTypeId: 2,
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
name: "Admin User",
|
||||||
|
email: adminUserEmail,
|
||||||
|
timeZone: "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
req.userId = trialUser.id;
|
test("should not throw error for bookings where user is an attendee", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
try {
|
method: "GET",
|
||||||
await authMiddleware(req);
|
query: {
|
||||||
} catch (error) {
|
id: 2,
|
||||||
const httpError = error as HttpError;
|
},
|
||||||
expect(httpError.statusCode).toBe(403);
|
prisma: prismock,
|
||||||
}
|
});
|
||||||
|
buildMockData();
|
||||||
|
req.userId = memberUserId;
|
||||||
|
await expect(authMiddleware(req)).resolves.not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("No error is thrown when user is the booking user", async () => {
|
test("should throw error for bookings where user is not an attendee", async () => {
|
||||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
|
||||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
|
||||||
|
|
||||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
body: {},
|
|
||||||
query: {
|
query: {
|
||||||
id: booking.id,
|
id: 1,
|
||||||
},
|
},
|
||||||
|
prisma: prismock,
|
||||||
});
|
});
|
||||||
|
buildMockData();
|
||||||
|
req.userId = memberUserId;
|
||||||
|
|
||||||
req.userId = proUser.id;
|
await expect(authMiddleware(req)).rejects.toThrow();
|
||||||
|
|
||||||
await authMiddleware(req);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("No error is thrown when user is system-wide admin", async () => {
|
test("should not throw error for booking where user is the event type owner", async () => {
|
||||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
|
|
||||||
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
|
|
||||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
|
|
||||||
|
|
||||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
body: {},
|
|
||||||
query: {
|
query: {
|
||||||
id: booking.id,
|
id: 2,
|
||||||
},
|
},
|
||||||
|
prisma: prismock,
|
||||||
});
|
});
|
||||||
|
buildMockData();
|
||||||
|
req.userId = ownerUserId;
|
||||||
|
await expect(authMiddleware(req)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
req.userId = adminUser.id;
|
test("should not throw error for booking where user is team owner or admin", async () => {
|
||||||
|
const { req: req1 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
prisma: prismock,
|
||||||
|
});
|
||||||
|
const { req: req2 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
prisma: prismock,
|
||||||
|
});
|
||||||
|
buildMockData();
|
||||||
|
|
||||||
|
req1.userId = adminUserId;
|
||||||
|
req2.userId = ownerUserId;
|
||||||
|
|
||||||
|
await expect(authMiddleware(req1)).resolves.not.toThrow();
|
||||||
|
await expect(authMiddleware(req2)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
test("should throw error for booking where user is not team owner or admin", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
prisma: prismock,
|
||||||
|
});
|
||||||
|
buildMockData();
|
||||||
|
|
||||||
|
req.userId = memberUserId;
|
||||||
|
|
||||||
|
await expect(authMiddleware(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
test("should not throw error when user is system-wide admin", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
id: 2,
|
||||||
|
},
|
||||||
|
prisma: prismock,
|
||||||
|
});
|
||||||
|
buildMockData();
|
||||||
|
req.userId = adminUserId;
|
||||||
req.isSystemWideAdmin = true;
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
await authMiddleware(req);
|
await authMiddleware(req);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("No error is thrown when user is org-wide admin", async () => {
|
it("should throw error when user is org-wide admin", async () => {
|
||||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
|
||||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
|
|
||||||
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
|
|
||||||
|
|
||||||
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
body: {},
|
|
||||||
query: {
|
query: {
|
||||||
id: booking.id,
|
id: 1,
|
||||||
},
|
},
|
||||||
|
prisma: prismock,
|
||||||
});
|
});
|
||||||
|
buildMockData();
|
||||||
req.userId = adminUser.id;
|
req.userId = orgOwnerUserId;
|
||||||
req.isOrganizationOwnerOrAdmin = true;
|
req.isOrganizationOwnerOrAdmin = true;
|
||||||
|
|
||||||
await authMiddleware(req);
|
await authMiddleware(req);
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ describe("GET /api/bookings", async () => {
|
|||||||
|
|
||||||
const responseData = await handler(req);
|
const responseData = await handler(req);
|
||||||
responseData.bookings.forEach((booking) => {
|
responseData.bookings.forEach((booking) => {
|
||||||
console.log(booking);
|
|
||||||
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime());
|
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -188,4 +187,27 @@ describe("GET /api/bookings", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Expand feature to add relational data in return payload", () => {
|
||||||
|
it("Returns only team data when expand=team is set", async () => {
|
||||||
|
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
expand: "team",
|
||||||
|
},
|
||||||
|
pagination: DefaultPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUser.id;
|
||||||
|
req.isOrganizationOwnerOrAdmin = true;
|
||||||
|
|
||||||
|
const responseData = await handler(req);
|
||||||
|
console.log("bookings=>", responseData.bookings);
|
||||||
|
responseData.bookings.forEach((booking) => {
|
||||||
|
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
|
||||||
|
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { buildEventType } from "@calcom/lib/test/builder";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import handler from "../../../../pages/api/event-types/[id]/_delete";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
describe("DELETE /api/event-types/[id]", () => {
|
||||||
|
const eventTypeId = 1234567;
|
||||||
|
const teamId = 9999;
|
||||||
|
const adminUser = 1111;
|
||||||
|
const memberUser = 2222;
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
// Mocking membership.findFirst
|
||||||
|
prismaMock.membership.findFirst.mockImplementation(({ where }) => {
|
||||||
|
const { userId, teamId, accepted, role } = where;
|
||||||
|
const mockData = [
|
||||||
|
{ userId: 1111, teamId: teamId, accepted: true, role: MembershipRole.ADMIN },
|
||||||
|
{ userId: 2222, teamId: teamId, accepted: true, role: MembershipRole.MEMBER },
|
||||||
|
];
|
||||||
|
// Return the correct user based on the query conditions
|
||||||
|
return mockData.find(
|
||||||
|
(membership) =>
|
||||||
|
membership.userId === userId &&
|
||||||
|
membership.teamId === teamId &&
|
||||||
|
membership.accepted === accepted &&
|
||||||
|
role.in.includes(membership.role)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mocking eventType.findFirst
|
||||||
|
prismaMock.eventType.findFirst.mockResolvedValue(
|
||||||
|
buildEventType({
|
||||||
|
id: eventTypeId,
|
||||||
|
teamId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mocking team.findUnique
|
||||||
|
prismaMock.team.findUnique.mockResolvedValue({
|
||||||
|
id: teamId,
|
||||||
|
members: [
|
||||||
|
{ userId: memberUser, role: MembershipRole.MEMBER, teamId: teamId },
|
||||||
|
{ userId: adminUser, role: MembershipRole.ADMIN, teamId: teamId },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error", async () => {
|
||||||
|
test("Fails to remove event type if user is not OWNER/ADMIN of team associated with event type", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
body: {},
|
||||||
|
query: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign userId to the request objects
|
||||||
|
req.userId = memberUser;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
expect(res.statusCode).toBe(403); // Check if the deletion was successful
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Success", async () => {
|
||||||
|
test("Removes event type if user is owner of team associated with event type", async () => {
|
||||||
|
// Mocks for DELETE request
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
body: {},
|
||||||
|
query: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign userId to the request objects
|
||||||
|
req.userId = adminUser;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
expect(res.statusCode).toBe(200); // Check if the deletion was successful
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,7 +81,7 @@ describe("retrieveScopedAccessibleUsers tests", () => {
|
|||||||
where: { email: "member2-acme@example.com" },
|
where: { email: "member2-acme@example.com" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(accessibleUserIds.length).toBe(3);
|
expect(accessibleUserIds.length).toBe(11);
|
||||||
expect(accessibleUserIds).toContain(memberOneUser.id);
|
expect(accessibleUserIds).toContain(memberOneUser.id);
|
||||||
expect(accessibleUserIds).toContain(memberTwoUser.id);
|
expect(accessibleUserIds).toContain(memberTwoUser.id);
|
||||||
expect(accessibleUserIds).toContain(adminUser.id);
|
expect(accessibleUserIds).toContain(adminUser.id);
|
||||||
|
|||||||
@@ -27,3 +27,8 @@ CALCOM_LICENSE_KEY=
|
|||||||
API_KEY_PREFIX=cal_
|
API_KEY_PREFIX=cal_
|
||||||
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
|
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
|
||||||
IS_E2E=false
|
IS_E2E=false
|
||||||
|
DOCS_URL=
|
||||||
|
|
||||||
|
# Axiom logging
|
||||||
|
AXIOM_DATASET=
|
||||||
|
AXIOM_TOKEN=
|
||||||
@@ -44,8 +44,8 @@ Copy `.env.example` to `.env` and fill values.
|
|||||||
|
|
||||||
## Add license Key to deployments table in DB
|
## Add license Key to deployments table in DB
|
||||||
|
|
||||||
id, logo theme licenseKey agreedLicenseAt
|
id, logo theme licenseKey agreedLicenseAt
|
||||||
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
|
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
|
||||||
|
|
||||||
your CALCOM_LICENSE_KEY env var need to contain the same value
|
your CALCOM_LICENSE_KEY env var need to contain the same value
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
|
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||||
"reporters": ["default", "jest-summarizing-reporter"]
|
"reporters": ["default", "jest-summarizing-reporter"],
|
||||||
|
"workerIdleMemoryLimit": "512MB",
|
||||||
|
"maxWorkers": 2
|
||||||
}
|
}
|
||||||
@@ -10,28 +10,33 @@
|
|||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
|
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
|
||||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
|
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build ",
|
||||||
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
|
"dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch",
|
||||||
|
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node ./dist/apps/api/v2/src/main.js",
|
"start:prod": "node ./dist/apps/api/v2/src/main.js",
|
||||||
"test": "yarn dev:build && jest",
|
"test": "yarn dev:build && jest",
|
||||||
"test:watch": "yarn dev:build && jest --watch",
|
"test:watch": "yarn dev:build && jest --watch",
|
||||||
"test:cov": "yarn dev:build && jest --coverage",
|
"test:cov": "yarn dev:build && jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
|
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
|
||||||
|
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
|
||||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.28",
|
"@calcom/platform-enums": "*",
|
||||||
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.62",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
"@golevelup/ts-jest": "^0.4.0",
|
"@golevelup/ts-jest": "^0.4.0",
|
||||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||||
|
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||||
"@nestjs/bull": "^10.1.1",
|
"@nestjs/bull": "^10.1.1",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
@@ -40,8 +45,10 @@
|
|||||||
"@nestjs/passport": "^10.0.2",
|
"@nestjs/passport": "^10.0.2",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/swagger": "^7.3.0",
|
"@nestjs/swagger": "^7.3.0",
|
||||||
"@nestjs/throttler": "^5.1.2",
|
"@nestjs/throttler": "6.2.1",
|
||||||
|
"@sentry/nestjs": "^8.37.1",
|
||||||
"@sentry/node": "^8.8.0",
|
"@sentry/node": "^8.8.0",
|
||||||
|
"@sentry/profiling-node": "^8.37.1",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"bull": "^4.12.4",
|
"bull": "^4.12.4",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -54,16 +61,17 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"nest-winston": "^1.9.4",
|
"nest-winston": "^1.9.4",
|
||||||
"nestjs-throttler-storage-redis": "^0.4.1",
|
|
||||||
"next-auth": "^4.22.1",
|
"next-auth": "^4.22.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"qs-stringify": "^1.2.1",
|
||||||
"querystring": "^0.2.1",
|
"querystring": "^0.2.1",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"stripe": "^15.3.0",
|
"stripe": "^15.3.0",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.13.0",
|
||||||
|
"winston-transport": "^4.7.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -79,6 +87,7 @@
|
|||||||
"@types/passport-jwt": "^3.0.13",
|
"@types/passport-jwt": "^3.0.13",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"jest-date-mock": "^1.0.10",
|
||||||
"prettier": "^2.8.6",
|
"prettier": "^2.8.6",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^6.3.3",
|
"supertest": "^6.3.3",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
function checkCommandExists(command: string): boolean {
|
||||||
|
try {
|
||||||
|
execSync(`${command} --version`, { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if docker is installed
|
||||||
|
if (!checkCommandExists("docker")) {
|
||||||
|
throw new Error("Docker is not installed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try docker compose first (new syntax)
|
||||||
|
try {
|
||||||
|
execSync("docker compose version", { stdio: "ignore" });
|
||||||
|
console.log("Starting containers with docker compose...");
|
||||||
|
execSync("docker compose up -d", { stdio: "inherit" });
|
||||||
|
} catch (e) {
|
||||||
|
// Fall back to docker-compose if the above fails
|
||||||
|
if (checkCommandExists("docker-compose")) {
|
||||||
|
console.log("Starting containers with docker-compose...");
|
||||||
|
execSync("docker-compose up -d", { stdio: "inherit" });
|
||||||
|
} else {
|
||||||
|
throw new Error("Neither 'docker compose' nor 'docker-compose' command is available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error: ${error instanceof Error ? error.message : "Unknown error occurred"}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@ import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common";
|
|||||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
@DocsTags("Health - development only")
|
|
||||||
@DocsExcludeController(getEnv("NODE_ENV") === "production")
|
|
||||||
export class AppController {
|
export class AppController {
|
||||||
@Get("health")
|
@Get("health")
|
||||||
@Version(VERSION_NEUTRAL)
|
@Version(VERSION_NEUTRAL)
|
||||||
|
|||||||
+373
-14
@@ -1,26 +1,385 @@
|
|||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
|
import { RateLimitRepositoryFixture } from "test/fixtures/repository/rate-limit.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
|
||||||
describe("AppController (e2e)", () => {
|
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
let app: INestApplication;
|
import { User, PlatformOAuthClient, Team, RateLimit } from "@calcom/prisma/client";
|
||||||
|
|
||||||
beforeEach(async () => {
|
describe("AppController", () => {
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
describe("Rate limiting", () => {
|
||||||
imports: [AppModule],
|
let app: INestApplication;
|
||||||
}).compile();
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
let rateLimitRepositoryFixture: RateLimitRepositoryFixture;
|
||||||
|
const userEmail = "app-rate-limits-e2e@api.com";
|
||||||
|
let user: User;
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
let organization: Team;
|
||||||
await app.init();
|
let oAuthClient: PlatformOAuthClient;
|
||||||
});
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
|
||||||
it("/ (GET)", () => {
|
let apiKeyString: string;
|
||||||
return request(app.getHttpServer()).get("/health").expect("OK");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
let rateLimit: RateLimit;
|
||||||
await app.close();
|
let apiKeyStringWithRateLimit: string;
|
||||||
|
|
||||||
|
let apiKeyStringWithMultipleLimits: string;
|
||||||
|
let firstRateLimitWithMultipleLimits: RateLimit;
|
||||||
|
let secondRateLimitWithMultipleLimits: RateLimit;
|
||||||
|
|
||||||
|
const mockDefaultLimit = 5;
|
||||||
|
const mockDefaultTtl = 2500;
|
||||||
|
const mockDefaultBlockDuration = 5000;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultLimit").mockReturnValue(mockDefaultLimit);
|
||||||
|
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultTtl").mockReturnValue(mockDefaultTtl);
|
||||||
|
jest
|
||||||
|
.spyOn(CustomThrottlerGuard.prototype, "getDefaultBlockDuration")
|
||||||
|
.mockReturnValue(mockDefaultBlockDuration);
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
username: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||||
|
apiKeyString = `cal_test_${keyString}`;
|
||||||
|
|
||||||
|
rateLimitRepositoryFixture = new RateLimitRepositoryFixture(moduleRef);
|
||||||
|
const { apiKey, keyString: keyStringWithRateLimit } = await apiKeysRepositoryFixture.createApiKey(
|
||||||
|
user.id,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
apiKeyStringWithRateLimit = `cal_test_${keyStringWithRateLimit}`;
|
||||||
|
rateLimit = await rateLimitRepositoryFixture.createRateLimit("long", apiKey.id, 2000, 3, 4000);
|
||||||
|
|
||||||
|
const { apiKey: apiKeyWithMultipleLimits, keyString: keyStringWithMultipleLimits } =
|
||||||
|
await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||||
|
apiKeyStringWithMultipleLimits = `cal_test_${keyStringWithMultipleLimits}`;
|
||||||
|
firstRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
|
||||||
|
"short",
|
||||||
|
apiKeyWithMultipleLimits.id,
|
||||||
|
1000,
|
||||||
|
2,
|
||||||
|
2000
|
||||||
|
);
|
||||||
|
secondRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
|
||||||
|
"long",
|
||||||
|
apiKeyWithMultipleLimits.id,
|
||||||
|
2000,
|
||||||
|
3,
|
||||||
|
4000
|
||||||
|
);
|
||||||
|
|
||||||
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
|
organization = await organizationsRepositoryFixture.create({ name: "ecorp" });
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
await profilesRepositoryFixture.create({
|
||||||
|
uid: "asd-asd",
|
||||||
|
username: userEmail,
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
organization: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 1023,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
it(
|
||||||
|
"api key with default rate limit - should enforce rate limits and reset after block duration",
|
||||||
|
async () => {
|
||||||
|
const limit = mockDefaultLimit;
|
||||||
|
const blockDuration = mockDefaultBlockDuration;
|
||||||
|
|
||||||
|
for (let i = 1; i <= limit; i++) {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set({ Authorization: `Bearer ${apiKeyString}` })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||||
|
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
|
||||||
|
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedResponse = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||||
|
.expect(429);
|
||||||
|
|
||||||
|
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||||
|
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
|
||||||
|
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
|
||||||
|
blockDuration / 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||||
|
|
||||||
|
const afterBlockResponse = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||||
|
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
|
||||||
|
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||||
|
},
|
||||||
|
15 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"api key with custom rate limit - should enforce rate limits and reset after block duration",
|
||||||
|
async () => {
|
||||||
|
const limit = rateLimit.limit;
|
||||||
|
const blockDuration = rateLimit.blockDuration;
|
||||||
|
const name = rateLimit.name;
|
||||||
|
|
||||||
|
for (let i = 1; i <= limit; i++) {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set({ Authorization: `Bearer ${apiKeyStringWithRateLimit}` })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||||
|
expect(response.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - i).toString());
|
||||||
|
expect(Number(response.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedResponse = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
|
||||||
|
.expect(429);
|
||||||
|
|
||||||
|
expect(blockedResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||||
|
expect(blockedResponse.headers[`x-ratelimit-remaining-${name}`]).toBe("0");
|
||||||
|
expect(Number(blockedResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThanOrEqual(
|
||||||
|
blockDuration / 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||||
|
|
||||||
|
const afterBlockResponse = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(afterBlockResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||||
|
expect(afterBlockResponse.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - 1).toString());
|
||||||
|
expect(Number(afterBlockResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
|
||||||
|
},
|
||||||
|
15 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"api key with multiple rate limits - should enforce both short and long rate limits",
|
||||||
|
async () => {
|
||||||
|
const shortLimit = firstRateLimitWithMultipleLimits.limit;
|
||||||
|
const longLimit = secondRateLimitWithMultipleLimits.limit;
|
||||||
|
const shortName = firstRateLimitWithMultipleLimits.name;
|
||||||
|
const longName = secondRateLimitWithMultipleLimits.name;
|
||||||
|
const shortBlock = firstRateLimitWithMultipleLimits.blockDuration;
|
||||||
|
const longBlock = secondRateLimitWithMultipleLimits.blockDuration;
|
||||||
|
|
||||||
|
let requestsMade = 0;
|
||||||
|
// note(Lauris): exhaust short limit to have remaining 0 for it
|
||||||
|
for (let i = 1; i <= shortLimit; i++) {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
requestsMade++;
|
||||||
|
|
||||||
|
expect(response.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
|
||||||
|
expect(response.headers[`x-ratelimit-remaining-${shortName}`]).toBe((shortLimit - i).toString());
|
||||||
|
expect(Number(response.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(response.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||||
|
expect(response.headers[`x-ratelimit-remaining-${longName}`]).toBe((longLimit - i).toString());
|
||||||
|
expect(Number(response.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// note(Lauris): short limit exhausted, now exhaust long limit to have remaining 0 for it
|
||||||
|
for (let i = requestsMade; i < longLimit; i++) {
|
||||||
|
const responseAfterShortLimit = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
requestsMade++;
|
||||||
|
|
||||||
|
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||||
|
shortLimit.toString()
|
||||||
|
);
|
||||||
|
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
|
||||||
|
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||||
|
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||||
|
(longLimit - requestsMade).toString()
|
||||||
|
);
|
||||||
|
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// note(Lauris): both have remaining 0 so now exceed both
|
||||||
|
const blockedResponseLong = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||||
|
.expect(429);
|
||||||
|
|
||||||
|
expect(blockedResponseLong.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
|
||||||
|
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
|
||||||
|
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThanOrEqual(
|
||||||
|
firstRateLimitWithMultipleLimits.blockDuration / 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(blockedResponseLong.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||||
|
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${longName}`]).toBe("0");
|
||||||
|
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThanOrEqual(
|
||||||
|
secondRateLimitWithMultipleLimits.blockDuration / 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
// note(Lauris): wait for short limit to reset
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, shortBlock));
|
||||||
|
const responseAfterShortLimitReload = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||||
|
.expect(200);
|
||||||
|
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||||
|
shortLimit.toString()
|
||||||
|
);
|
||||||
|
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
|
||||||
|
(shortLimit - 1).toString()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${shortName}`])
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
|
||||||
|
longLimit.toString()
|
||||||
|
);
|
||||||
|
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||||
|
(longLimit - requestsMade).toString()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${longName}`])
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// note(Lauris): wait for long limit to reset
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, longBlock));
|
||||||
|
const responseAfterLongLimitReload = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||||
|
.expect(200);
|
||||||
|
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||||
|
shortLimit.toString()
|
||||||
|
);
|
||||||
|
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
|
||||||
|
(shortLimit - 1).toString()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${shortName}`])
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
|
||||||
|
longLimit.toString()
|
||||||
|
);
|
||||||
|
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||||
|
(longLimit - 1).toString()
|
||||||
|
);
|
||||||
|
expect(Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
},
|
||||||
|
30 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"non api key with default rate limit - should enforce rate limits and reset after block duration",
|
||||||
|
async () => {
|
||||||
|
const limit = mockDefaultLimit;
|
||||||
|
const blockDuration = mockDefaultBlockDuration;
|
||||||
|
|
||||||
|
for (let i = 1; i <= limit; i++) {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||||
|
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
|
||||||
|
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedResponse = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||||
|
.expect(429);
|
||||||
|
|
||||||
|
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||||
|
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
|
||||||
|
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
|
||||||
|
blockDuration / 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||||
|
|
||||||
|
const afterBlockResponse = await request(app.getHttpServer())
|
||||||
|
.get("/v2/me")
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||||
|
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
|
||||||
|
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||||
|
},
|
||||||
|
15 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await userRepositoryFixture.deleteByEmail(userEmail);
|
||||||
|
await organizationsRepositoryFixture.delete(organization.id);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import appConfig from "@/config/app";
|
import appConfig from "@/config/app";
|
||||||
|
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
|
||||||
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
|
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
|
||||||
|
import { RedirectsMiddleware } from "@/middleware/app.redirects.middleware";
|
||||||
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
|
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
|
||||||
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
|
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
|
||||||
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
|
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
|
||||||
@@ -11,17 +13,19 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
|
|||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { RedisService } from "@/modules/redis/redis.service";
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||||
import { BullModule } from "@nestjs/bull";
|
import { BullModule } from "@nestjs/bull";
|
||||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core";
|
||||||
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
import { SentryModule, SentryGlobalFilter } from "@sentry/nestjs/setup";
|
||||||
|
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SentryModule.forRoot(),
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
ignoreEnvFile: true,
|
ignoreEnvFile: true,
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
@@ -36,11 +40,15 @@ import { AppController } from "./app.controller";
|
|||||||
imports: [RedisModule],
|
imports: [RedisModule],
|
||||||
inject: [RedisService],
|
inject: [RedisService],
|
||||||
useFactory: (redisService: RedisService) => ({
|
useFactory: (redisService: RedisService) => ({
|
||||||
|
// note(Lauris): IMPORTANT: rate limiting is enforced by CustomThrottlerGuard, but we need to have at least one
|
||||||
|
// entry in the throttlers array otherwise CustomThrottlerGuard is not invoked at all. If we specify only ThrottlerModule
|
||||||
|
// without .forRootAsync then throttler options are not passed to CustomThrottlerGuard containing redis connection etc.
|
||||||
|
// So we need to specify at least one dummy throttler here and CustomThrottlerGuard is actually handling the default and custom rate limits.
|
||||||
throttlers: [
|
throttlers: [
|
||||||
{
|
{
|
||||||
name: "long",
|
name: "dummy",
|
||||||
ttl: seconds(60), // Time to live for the long period in seconds
|
ttl: seconds(60),
|
||||||
limit: 120, // Maximum number of requests within the long ttl
|
limit: 120,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||||
@@ -53,13 +61,24 @@ import { AppController } from "./app.controller";
|
|||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: SentryGlobalFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ThrottlerStorageRedisService,
|
||||||
|
useFactory: (redisService: RedisService) => {
|
||||||
|
return new ThrottlerStorageRedisService(redisService.redis);
|
||||||
|
},
|
||||||
|
inject: [RedisService],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: ResponseInterceptor,
|
useClass: ResponseInterceptor,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: ThrottlerGuard,
|
useClass: CustomThrottlerGuard,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -77,6 +96,8 @@ export class AppModule implements NestModule {
|
|||||||
.forRoutes("*")
|
.forRoutes("*")
|
||||||
.apply(AppLoggerMiddleware)
|
.apply(AppLoggerMiddleware)
|
||||||
.forRoutes("*")
|
.forRoutes("*")
|
||||||
|
.apply(RedirectsMiddleware)
|
||||||
|
.forRoutes("/")
|
||||||
.apply(RewriterMiddleware)
|
.apply(RewriterMiddleware)
|
||||||
.forRoutes("/");
|
.forRoutes("/");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { ValidationError } from "@nestjs/common";
|
|||||||
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
|
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||||
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
|
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
|
||||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import * as Sentry from "@sentry/node";
|
|
||||||
import * as cookieParser from "cookie-parser";
|
import * as cookieParser from "cookie-parser";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
@@ -71,9 +70,6 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
|||||||
|
|
||||||
// Exception filters, new filters go at the bottom, keep the order
|
// Exception filters, new filters go at the bottom, keep the order
|
||||||
const { httpAdapter } = app.get(HttpAdapterHost);
|
const { httpAdapter } = app.get(HttpAdapterHost);
|
||||||
if (process.env.SENTRY_DSN) {
|
|
||||||
Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
|
|
||||||
}
|
|
||||||
app.useGlobalFilters(new PrismaExceptionFilter());
|
app.useGlobalFilters(new PrismaExceptionFilter());
|
||||||
app.useGlobalFilters(new ZodExceptionFilter());
|
app.useGlobalFilters(new ZodExceptionFilter());
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,5 @@
|
|||||||
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
|
import { BookingsController_2024_04_15 } from "@/ee/bookings/2024-04-15/controllers/bookings.controller";
|
||||||
|
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||||
import { BillingModule } from "@/modules/billing/billing.module";
|
import { BillingModule } from "@/modules/billing/billing.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
@@ -10,7 +11,7 @@ import { Module } from "@nestjs/common";
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
|
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
|
||||||
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
|
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController_2024_04_15],
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule_2024_04_15 {}
|
||||||
+125
-17
@@ -1,8 +1,8 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
|
||||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
@@ -14,6 +14,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
|||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
@@ -21,9 +22,9 @@ import { withApiAuth } from "test/utils/withApiAuth";
|
|||||||
|
|
||||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
import { handleNewBooking } from "@calcom/platform-libraries";
|
||||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
import { ApiSuccessResponse, ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
describe("Bookings Endpoints", () => {
|
describe("Bookings Endpoints 2024-04-15", () => {
|
||||||
describe("User Authenticated", () => {
|
describe("User Authenticated", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ describe("Bookings Endpoints", () => {
|
|||||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
let schedulesService: SchedulesService_2024_04_15;
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
let apiKeyString: string;
|
||||||
|
|
||||||
const userEmail = "bookings-controller-e2e@api.com";
|
const userEmail = "bookings-controller-e2e@api.com";
|
||||||
let user: User;
|
let user: User;
|
||||||
@@ -56,11 +59,13 @@ describe("Bookings Endpoints", () => {
|
|||||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
user = await userRepositoryFixture.create({
|
user = await userRepositoryFixture.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||||
|
apiKeyString = keyString;
|
||||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
name: "working time",
|
name: "working time",
|
||||||
timeZone: "Europe/Rome",
|
timeZone: "Europe/Rome",
|
||||||
@@ -106,7 +111,7 @@ describe("Bookings Endpoints", () => {
|
|||||||
guests: [],
|
guests: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const body: CreateBookingInput = {
|
const body: CreateBookingInput_2024_04_15 = {
|
||||||
start: bookingStart,
|
start: bookingStart,
|
||||||
end: bookingEnd,
|
end: bookingEnd,
|
||||||
eventTypeId: bookingEventTypeId,
|
eventTypeId: bookingEventTypeId,
|
||||||
@@ -139,6 +144,105 @@ describe("Bookings Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should fail to create a booking with no_available_users_found_error", async () => {
|
||||||
|
const bookingStart = "2040-05-21T09:30:00.000Z";
|
||||||
|
const bookingEnd = "2040-05-21T10:30:00.000Z";
|
||||||
|
const bookingEventTypeId = eventTypeId;
|
||||||
|
const bookingTimeZone = "Europe/London";
|
||||||
|
const bookingLanguage = "en";
|
||||||
|
const bookingHashedLink = "";
|
||||||
|
const bookingMetadata = {
|
||||||
|
timeFormat: "12",
|
||||||
|
meetingType: "organizer-phone",
|
||||||
|
};
|
||||||
|
const bookingResponses = {
|
||||||
|
name: "tester",
|
||||||
|
email: "tester@example.com",
|
||||||
|
location: {
|
||||||
|
value: "link",
|
||||||
|
optionValue: "",
|
||||||
|
},
|
||||||
|
notes: "test",
|
||||||
|
guests: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const body: CreateBookingInput_2024_04_15 = {
|
||||||
|
start: bookingStart,
|
||||||
|
end: bookingEnd,
|
||||||
|
eventTypeId: bookingEventTypeId,
|
||||||
|
timeZone: bookingTimeZone,
|
||||||
|
language: bookingLanguage,
|
||||||
|
metadata: bookingMetadata,
|
||||||
|
hashedLink: bookingHashedLink,
|
||||||
|
responses: bookingResponses,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.expect(400)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiErrorResponse = response.body;
|
||||||
|
expect(responseBody.error.message).toEqual("no_available_users_found_error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a booking with api key to get owner id", async () => {
|
||||||
|
const bookingStart = "2040-05-22T09:30:00.000Z";
|
||||||
|
const bookingEnd = "2040-05-22T10:30:00.000Z";
|
||||||
|
const bookingEventTypeId = eventTypeId;
|
||||||
|
const bookingTimeZone = "Europe/London";
|
||||||
|
const bookingLanguage = "en";
|
||||||
|
const bookingHashedLink = "";
|
||||||
|
const bookingMetadata = {
|
||||||
|
timeFormat: "12",
|
||||||
|
meetingType: "organizer-phone",
|
||||||
|
};
|
||||||
|
const bookingResponses = {
|
||||||
|
name: "tester",
|
||||||
|
email: "tester@example.com",
|
||||||
|
location: {
|
||||||
|
value: "link",
|
||||||
|
optionValue: "",
|
||||||
|
},
|
||||||
|
notes: "test",
|
||||||
|
guests: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const body: CreateBookingInput_2024_04_15 = {
|
||||||
|
start: bookingStart,
|
||||||
|
end: bookingEnd,
|
||||||
|
eventTypeId: bookingEventTypeId,
|
||||||
|
timeZone: bookingTimeZone,
|
||||||
|
language: bookingLanguage,
|
||||||
|
metadata: bookingMetadata,
|
||||||
|
hashedLink: bookingHashedLink,
|
||||||
|
responses: bookingResponses,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
|
||||||
|
response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.userPrimaryEmail).toBeDefined();
|
||||||
|
expect(responseBody.data.userPrimaryEmail).toEqual(userEmail);
|
||||||
|
expect(responseBody.data.id).toBeDefined();
|
||||||
|
expect(responseBody.data.uid).toBeDefined();
|
||||||
|
expect(responseBody.data.startTime).toEqual(bookingStart);
|
||||||
|
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
|
||||||
|
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
|
||||||
|
expect(responseBody.data.metadata).toEqual(bookingMetadata);
|
||||||
|
|
||||||
|
createdBooking = responseBody.data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("should reschedule a booking", () => {
|
describe("should reschedule a booking", () => {
|
||||||
it("should reschedule with updated start time, end time & metadata", async () => {
|
it("should reschedule with updated start time, end time & metadata", async () => {
|
||||||
const newBookingStart = "2040-05-21T12:30:00.000Z";
|
const newBookingStart = "2040-05-21T12:30:00.000Z";
|
||||||
@@ -162,7 +266,7 @@ describe("Bookings Endpoints", () => {
|
|||||||
guests: [],
|
guests: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const body: CreateBookingInput = {
|
const body: CreateBookingInput_2024_04_15 = {
|
||||||
rescheduleUid: createdBooking.uid,
|
rescheduleUid: createdBooking.uid,
|
||||||
start: newBookingStart,
|
start: newBookingStart,
|
||||||
end: newBookingEnd,
|
end: newBookingEnd,
|
||||||
@@ -201,19 +305,23 @@ describe("Bookings Endpoints", () => {
|
|||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get("/v2/bookings?filters[status]=upcoming")
|
.get("/v2/bookings?filters[status]=upcoming")
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const responseBody: GetBookingsOutput = response.body;
|
const responseBody: GetBookingsOutput_2024_04_15 = response.body;
|
||||||
const fetchedBooking = responseBody.data.bookings[0];
|
|
||||||
|
|
||||||
expect(responseBody.data.bookings.length).toEqual(1);
|
expect(responseBody.data.bookings.length).toEqual(2);
|
||||||
|
const fetchedBooking = responseBody.data.bookings.find(
|
||||||
|
(booking) => booking.id === createdBooking.id
|
||||||
|
);
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
expect(responseBody.data).toBeDefined();
|
expect(responseBody.data).toBeDefined();
|
||||||
expect(fetchedBooking).toBeDefined();
|
expect(fetchedBooking).toBeDefined();
|
||||||
|
|
||||||
expect(fetchedBooking.id).toEqual(createdBooking.id);
|
if (fetchedBooking) {
|
||||||
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
|
expect(fetchedBooking.id).toEqual(createdBooking.id);
|
||||||
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
|
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
|
||||||
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
|
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
|
||||||
expect(fetchedBooking.user?.email).toEqual(userEmail);
|
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
|
||||||
|
expect(fetchedBooking.user?.email).toEqual(userEmail);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,7 +329,7 @@ describe("Bookings Endpoints", () => {
|
|||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get(`/v2/bookings/${createdBooking.uid}`)
|
.get(`/v2/bookings/${createdBooking.uid}`)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const responseBody: GetBookingOutput = response.body;
|
const responseBody: GetBookingOutput_2024_04_15 = response.body;
|
||||||
const bookingInfo = responseBody.data;
|
const bookingInfo = responseBody.data;
|
||||||
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
+107
-41
@@ -1,10 +1,12 @@
|
|||||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||||
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input";
|
||||||
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
|
import { MarkNoShowInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/mark-no-show.input";
|
||||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
|
||||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||||
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
|
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
|
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
||||||
|
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
@@ -28,26 +30,33 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { NextApiRequest } from "next/types";
|
import { NextApiRequest } from "next/types";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||||
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
||||||
import {
|
import {
|
||||||
|
handleNewRecurringBooking,
|
||||||
handleNewBooking,
|
handleNewBooking,
|
||||||
BookingResponse,
|
BookingResponse,
|
||||||
HttpError,
|
HttpError,
|
||||||
handleNewRecurringBooking,
|
|
||||||
handleInstantMeeting,
|
handleInstantMeeting,
|
||||||
handleMarkNoShow,
|
handleMarkNoShow,
|
||||||
getAllUserBookings,
|
getAllUserBookings,
|
||||||
getBookingInfo,
|
getBookingInfo,
|
||||||
handleCancelBooking,
|
handleCancelBooking,
|
||||||
getBookingForReschedule,
|
getBookingForReschedule,
|
||||||
|
ErrorCode,
|
||||||
} from "@calcom/platform-libraries";
|
} from "@calcom/platform-libraries";
|
||||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
import {
|
||||||
|
GetBookingsInput_2024_04_15,
|
||||||
|
CancelBookingInput_2024_04_15,
|
||||||
|
Status_2024_04_15,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
import { ApiResponse } from "@calcom/platform-types";
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -75,33 +84,36 @@ const DEFAULT_PLATFORM_PARAMS = {
|
|||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: "/v2/bookings",
|
path: "/v2/bookings",
|
||||||
version: API_VERSIONS_VALUES,
|
version: [VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14],
|
||||||
})
|
})
|
||||||
@UseGuards(PermissionsGuard)
|
@UseGuards(PermissionsGuard)
|
||||||
@DocsTags("Bookings")
|
@DocsExcludeController(true)
|
||||||
export class BookingsController {
|
export class BookingsController_2024_04_15 {
|
||||||
private readonly logger = new Logger("BookingsController");
|
private readonly logger = new Logger("BookingsController_2024_04_15");
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly oAuthFlowService: OAuthFlowService,
|
private readonly oAuthFlowService: OAuthFlowService,
|
||||||
private readonly prismaReadService: PrismaReadService,
|
private readonly prismaReadService: PrismaReadService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
private readonly billingService: BillingService
|
private readonly billingService: BillingService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly apiKeyRepository: ApiKeyRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@Permissions([BOOKING_READ])
|
@Permissions([BOOKING_READ])
|
||||||
@ApiQuery({ name: "filters[status]", enum: Status, required: true })
|
@ApiQuery({ name: "filters[status]", enum: Status_2024_04_15, required: true })
|
||||||
@ApiQuery({ name: "limit", type: "number", required: false })
|
@ApiQuery({ name: "limit", type: "number", required: false })
|
||||||
@ApiQuery({ name: "cursor", type: "number", required: false })
|
@ApiQuery({ name: "cursor", type: "number", required: false })
|
||||||
async getBookings(
|
async getBookings(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Query() queryParams: GetBookingsInput
|
@Query() queryParams: GetBookingsInput_2024_04_15
|
||||||
): Promise<GetBookingsOutput> {
|
): Promise<GetBookingsOutput_2024_04_15> {
|
||||||
const { filters, cursor, limit } = queryParams;
|
const { filters, cursor, limit } = queryParams;
|
||||||
|
const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"];
|
||||||
const bookings = await getAllUserBookings({
|
const bookings = await getAllUserBookings({
|
||||||
bookingListingByStatus: filters.status,
|
bookingListingByStatus: [bookingListingByStatus],
|
||||||
skip: cursor ?? 0,
|
skip: cursor ?? 0,
|
||||||
take: limit ?? 10,
|
take: limit ?? 10,
|
||||||
filters,
|
filters,
|
||||||
@@ -118,7 +130,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("/:bookingUid")
|
@Get("/:bookingUid")
|
||||||
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput> {
|
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput_2024_04_15> {
|
||||||
const { bookingInfo } = await getBookingInfo(bookingUid);
|
const { bookingInfo } = await getBookingInfo(bookingUid);
|
||||||
|
|
||||||
if (!bookingInfo) {
|
if (!bookingInfo) {
|
||||||
@@ -148,10 +160,10 @@ export class BookingsController {
|
|||||||
@Post("/")
|
@Post("/")
|
||||||
async createBooking(
|
async createBooking(
|
||||||
@Req() req: BookingRequest,
|
@Req() req: BookingRequest,
|
||||||
@Body() body: CreateBookingInput,
|
@Body() body: CreateBookingInput_2024_04_15
|
||||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
|
||||||
): Promise<ApiResponse<Partial<BookingResponse>>> {
|
): Promise<ApiResponse<Partial<BookingResponse>>> {
|
||||||
const oAuthClientId = clientId?.toString();
|
const clientIdHeader = req.get(X_CAL_CLIENT_ID);
|
||||||
|
const oAuthClientId = clientIdHeader?.toString();
|
||||||
const { orgSlug, locationUrl } = body;
|
const { orgSlug, locationUrl } = body;
|
||||||
req.headers["x-cal-force-slug"] = orgSlug;
|
req.headers["x-cal-force-slug"] = orgSlug;
|
||||||
try {
|
try {
|
||||||
@@ -179,10 +191,10 @@ export class BookingsController {
|
|||||||
async cancelBooking(
|
async cancelBooking(
|
||||||
@Req() req: BookingRequest,
|
@Req() req: BookingRequest,
|
||||||
@Param("bookingId") bookingId: string,
|
@Param("bookingId") bookingId: string,
|
||||||
@Body() _: CancelBookingInput,
|
@Body() _: CancelBookingInput_2024_04_15
|
||||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
|
||||||
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
|
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
|
||||||
const oAuthClientId = clientId?.toString();
|
const clientIdHeader = req.get(X_CAL_CLIENT_ID);
|
||||||
|
const oAuthClientId = clientIdHeader?.toString();
|
||||||
if (bookingId) {
|
if (bookingId) {
|
||||||
try {
|
try {
|
||||||
req.body.id = parseInt(bookingId);
|
req.body.id = parseInt(bookingId);
|
||||||
@@ -212,9 +224,9 @@ export class BookingsController {
|
|||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
async markNoShow(
|
async markNoShow(
|
||||||
@GetUser("id") userId: number,
|
@GetUser("id") userId: number,
|
||||||
@Body() body: MarkNoShowInput,
|
@Body() body: MarkNoShowInput_2024_04_15,
|
||||||
@Param("bookingUid") bookingUid: string
|
@Param("bookingUid") bookingUid: string
|
||||||
): Promise<MarkNoShowOutput> {
|
): Promise<MarkNoShowOutput_2024_04_15> {
|
||||||
try {
|
try {
|
||||||
const markNoShowResponse = await handleMarkNoShow({
|
const markNoShowResponse = await handleMarkNoShow({
|
||||||
bookingUid: bookingUid,
|
bookingUid: bookingUid,
|
||||||
@@ -233,13 +245,20 @@ export class BookingsController {
|
|||||||
@Post("/recurring")
|
@Post("/recurring")
|
||||||
async createRecurringBooking(
|
async createRecurringBooking(
|
||||||
@Req() req: BookingRequest,
|
@Req() req: BookingRequest,
|
||||||
@Body() _: CreateRecurringBookingInput[],
|
@Body() _: CreateRecurringBookingInput_2024_04_15[]
|
||||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
|
||||||
): Promise<ApiResponse<BookingResponse[]>> {
|
): Promise<ApiResponse<BookingResponse[]>> {
|
||||||
const oAuthClientId = clientId?.toString();
|
const clientIdHeader = req.get(X_CAL_CLIENT_ID);
|
||||||
|
const oAuthClientId = clientIdHeader?.toString();
|
||||||
try {
|
try {
|
||||||
|
const recurringEventId = uuidv4();
|
||||||
|
for (const recurringEvent of req.body) {
|
||||||
|
if (!recurringEvent.recurringEventId) {
|
||||||
|
recurringEvent.recurringEventId = recurringEventId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
|
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
|
||||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
await this.createNextApiRecurringBookingRequest(req, oAuthClientId)
|
||||||
);
|
);
|
||||||
|
|
||||||
createdBookings.forEach(async (booking) => {
|
createdBookings.forEach(async (booking) => {
|
||||||
@@ -264,10 +283,10 @@ export class BookingsController {
|
|||||||
@Post("/instant")
|
@Post("/instant")
|
||||||
async createInstantBooking(
|
async createInstantBooking(
|
||||||
@Req() req: BookingRequest,
|
@Req() req: BookingRequest,
|
||||||
@Body() _: CreateBookingInput,
|
@Body() _: CreateBookingInput_2024_04_15
|
||||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
|
||||||
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
|
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
|
||||||
const oAuthClientId = clientId?.toString();
|
const clientIdHeader = req.get(X_CAL_CLIENT_ID);
|
||||||
|
const oAuthClientId = clientIdHeader?.toString();
|
||||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||||
try {
|
try {
|
||||||
const instantMeeting = await handleInstantMeeting(
|
const instantMeeting = await handleInstantMeeting(
|
||||||
@@ -296,9 +315,17 @@ export class BookingsController {
|
|||||||
|
|
||||||
private async getOwnerId(req: Request): Promise<number | undefined> {
|
private async getOwnerId(req: Request): Promise<number | undefined> {
|
||||||
try {
|
try {
|
||||||
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
|
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
|
||||||
if (accessToken) {
|
if (bearerToken) {
|
||||||
return this.oAuthFlowService.getOwnerId(accessToken);
|
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||||
|
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||||
|
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||||
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
|
return keyData?.userId;
|
||||||
|
} else {
|
||||||
|
// Access Token
|
||||||
|
return this.oAuthFlowService.getOwnerId(bearerToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
@@ -329,13 +356,49 @@ export class BookingsController {
|
|||||||
oAuthClientId?: string,
|
oAuthClientId?: string,
|
||||||
platformBookingLocation?: string
|
platformBookingLocation?: string
|
||||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||||
|
const requestId = req.get("X-Request-Id");
|
||||||
|
const clone = { ...req };
|
||||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||||
const oAuthParams = oAuthClientId
|
const oAuthParams = oAuthClientId
|
||||||
? await this.getOAuthClientsParams(oAuthClientId)
|
? await this.getOAuthClientsParams(oAuthClientId)
|
||||||
: DEFAULT_PLATFORM_PARAMS;
|
: DEFAULT_PLATFORM_PARAMS;
|
||||||
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
|
this.logger.log(`createNextApiBookingRequest_2024_04_15`, {
|
||||||
req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
requestId,
|
||||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
ownerId: userId,
|
||||||
|
platformBookingLocation,
|
||||||
|
oAuthClientId,
|
||||||
|
...oAuthParams,
|
||||||
|
});
|
||||||
|
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
|
||||||
|
clone.body = { ...clone.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||||
|
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createNextApiRecurringBookingRequest(
|
||||||
|
req: BookingRequest,
|
||||||
|
oAuthClientId?: string,
|
||||||
|
platformBookingLocation?: string
|
||||||
|
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||||
|
const clone = { ...req };
|
||||||
|
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||||
|
const oAuthParams = oAuthClientId
|
||||||
|
? await this.getOAuthClientsParams(oAuthClientId)
|
||||||
|
: DEFAULT_PLATFORM_PARAMS;
|
||||||
|
const requestId = req.get("X-Request-Id");
|
||||||
|
this.logger.log(`createNextApiRecurringBookingRequest_2024_04_15`, {
|
||||||
|
requestId,
|
||||||
|
ownerId: userId,
|
||||||
|
platformBookingLocation,
|
||||||
|
oAuthClientId,
|
||||||
|
...oAuthParams,
|
||||||
|
});
|
||||||
|
Object.assign(clone, {
|
||||||
|
userId,
|
||||||
|
...oAuthParams,
|
||||||
|
platformBookingLocation,
|
||||||
|
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||||
|
});
|
||||||
|
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleBookingErrors(
|
private handleBookingErrors(
|
||||||
@@ -353,6 +416,9 @@ export class BookingsController {
|
|||||||
|
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
const error = err as Error;
|
const error = err as Error;
|
||||||
|
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
|
||||||
|
throw new HttpException(error.message, 400);
|
||||||
|
}
|
||||||
throw new InternalServerErrorException(error?.message ?? errMsg);
|
throw new InternalServerErrorException(error?.message ?? errMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-5
@@ -40,7 +40,7 @@ class Response {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateBookingInput {
|
export class CreateBookingInput_2024_04_15 {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
end?: string;
|
end?: string;
|
||||||
@@ -59,10 +59,6 @@ export class CreateBookingInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
rescheduleUid?: string;
|
rescheduleUid?: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
recurringEventId?: string;
|
|
||||||
|
|
||||||
@IsTimeZone()
|
@IsTimeZone()
|
||||||
timeZone!: string;
|
timeZone!: string;
|
||||||
|
|
||||||
+7
-3
@@ -1,9 +1,9 @@
|
|||||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator";
|
||||||
|
|
||||||
import type { AppsStatus } from "@calcom/platform-libraries";
|
import type { AppsStatus } from "@calcom/platform-libraries";
|
||||||
|
|
||||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2024_04_15 {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
noEmail?: boolean;
|
noEmail?: boolean;
|
||||||
@@ -21,4 +21,8 @@ export class CreateRecurringBookingInput extends CreateBookingInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
currentRecurringIndex?: number;
|
currentRecurringIndex?: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
recurringEventId?: string;
|
||||||
}
|
}
|
||||||
+1
-1
@@ -9,7 +9,7 @@ class Attendee {
|
|||||||
noShow!: boolean;
|
noShow!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MarkNoShowInput {
|
export class MarkNoShowInput_2024_04_15 {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
noShowHost?: boolean;
|
noShowHost?: boolean;
|
||||||
+5
-5
@@ -88,7 +88,7 @@ class EventType {
|
|||||||
timeZone!: string | null;
|
timeZone!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
class GetBookingData {
|
class GetBookingData_2024_04_15 {
|
||||||
@IsString()
|
@IsString()
|
||||||
title!: string;
|
title!: string;
|
||||||
|
|
||||||
@@ -159,15 +159,15 @@ class GetBookingData {
|
|||||||
eventType!: EventType | null;
|
eventType!: EventType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetBookingOutput {
|
export class GetBookingOutput_2024_04_15 {
|
||||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: GetBookingData,
|
type: GetBookingData_2024_04_15,
|
||||||
})
|
})
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => GetBookingData)
|
@Type(() => GetBookingData_2024_04_15)
|
||||||
data!: GetBookingData;
|
data!: GetBookingData_2024_04_15;
|
||||||
}
|
}
|
||||||
+5
-5
@@ -202,7 +202,7 @@ class GetBookingsDataEntry {
|
|||||||
rescheduled?: any;
|
rescheduled?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
class GetBookingsData {
|
class GetBookingsData_2024_04_15 {
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => GetBookingsDataEntry)
|
@Type(() => GetBookingsDataEntry)
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@@ -215,15 +215,15 @@ class GetBookingsData {
|
|||||||
nextCursor!: number | null;
|
nextCursor!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetBookingsOutput {
|
export class GetBookingsOutput_2024_04_15 {
|
||||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: GetBookingsData,
|
type: GetBookingsData_2024_04_15,
|
||||||
})
|
})
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => GetBookingsData)
|
@Type(() => GetBookingsData_2024_04_15)
|
||||||
data!: GetBookingsData;
|
data!: GetBookingsData_2024_04_15;
|
||||||
}
|
}
|
||||||
+5
-5
@@ -12,7 +12,7 @@ class Attendee {
|
|||||||
noShow!: boolean;
|
noShow!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class HandleMarkNoShowData {
|
class HandleMarkNoShowData_2024_04_15 {
|
||||||
@IsString()
|
@IsString()
|
||||||
message!: string;
|
message!: string;
|
||||||
|
|
||||||
@@ -27,15 +27,15 @@ class HandleMarkNoShowData {
|
|||||||
attendees?: Attendee[];
|
attendees?: Attendee[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MarkNoShowOutput {
|
export class MarkNoShowOutput_2024_04_15 {
|
||||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: HandleMarkNoShowData,
|
type: HandleMarkNoShowData_2024_04_15,
|
||||||
})
|
})
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => HandleMarkNoShowData)
|
@Type(() => HandleMarkNoShowData_2024_04_15)
|
||||||
data!: HandleMarkNoShowData;
|
data!: HandleMarkNoShowData_2024_04_15;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
|
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||||
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
|
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||||
|
import { BillingModule } from "@/modules/billing/billing.module";
|
||||||
|
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
|
||||||
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule, BookingSeatModule],
|
||||||
|
providers: [
|
||||||
|
TokensRepository,
|
||||||
|
OAuthFlowService,
|
||||||
|
OAuthClientRepository,
|
||||||
|
BookingsService_2024_08_13,
|
||||||
|
InputBookingsService_2024_08_13,
|
||||||
|
OutputBookingsService_2024_08_13,
|
||||||
|
BookingsRepository_2024_08_13,
|
||||||
|
EventTypesRepository_2024_06_14,
|
||||||
|
BookingSeatRepository,
|
||||||
|
ApiKeyRepository,
|
||||||
|
],
|
||||||
|
controllers: [BookingsController_2024_08_13],
|
||||||
|
})
|
||||||
|
export class BookingsModule_2024_08_13 {}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingsRepository_2024_08_13 {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||||
|
|
||||||
|
async getById(id: number) {
|
||||||
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByIdsWithAttendeesAndUserAndEvent(ids: number[]) {
|
||||||
|
return this.dbRead.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
attendees: true,
|
||||||
|
user: true,
|
||||||
|
eventType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids: number[]) {
|
||||||
|
return this.dbRead.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
attendees: {
|
||||||
|
include: {
|
||||||
|
bookingSeat: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: true,
|
||||||
|
eventType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUid(bookingUid: string) {
|
||||||
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
uid: bookingUid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUidWithUser(bookingUid: string) {
|
||||||
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
uid: bookingUid,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByIdWithAttendeesAndUserAndEvent(id: number) {
|
||||||
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
attendees: true,
|
||||||
|
user: true,
|
||||||
|
eventType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByIdWithAttendeesWithBookingSeatAndUserAndEvent(id: number) {
|
||||||
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
attendees: {
|
||||||
|
include: {
|
||||||
|
bookingSeat: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: true,
|
||||||
|
eventType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUidWithAttendeesAndUserAndEvent(uid: string) {
|
||||||
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
uid,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
attendees: true,
|
||||||
|
user: true,
|
||||||
|
eventType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid: string) {
|
||||||
|
return this.dbRead.prisma.booking.findUnique({
|
||||||
|
where: {
|
||||||
|
uid,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
attendees: {
|
||||||
|
include: {
|
||||||
|
bookingSeat: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: true,
|
||||||
|
eventType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecurringByUid(uid: string) {
|
||||||
|
return this.dbRead.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
recurringEventId: uid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecurringByUidWithAttendeesAndUserAndEvent(uid: string) {
|
||||||
|
return this.dbRead.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
recurringEventId: uid,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
attendees: true,
|
||||||
|
user: true,
|
||||||
|
eventType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByFromReschedule(fromReschedule: string) {
|
||||||
|
return this.dbRead.prisma.booking.findFirst({
|
||||||
|
where: {
|
||||||
|
fromReschedule,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
attendees: true,
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
|
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
|
describe("With api key", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
let apiKeyString: string;
|
||||||
|
|
||||||
|
const userEmail = "bookings-controller-e2e@api.com";
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
let eventTypeId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
userEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||||
|
apiKeyString = keyString;
|
||||||
|
|
||||||
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: "working time",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||||
|
const event = await eventTypesRepositoryFixture.create(
|
||||||
|
{ title: "peer coding", slug: "peer-coding", length: 60 },
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
eventTypeId = event.id;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create bookings", () => {
|
||||||
|
it("should create a booking with api key", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Key",
|
||||||
|
email: "mr_key@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.hosts[0].id).toEqual(user.id);
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
|
||||||
|
expect(data.duration).toEqual(60);
|
||||||
|
expect(data.eventTypeId).toEqual(eventTypeId);
|
||||||
|
expect(data.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||||
|
expect(data.absentHost).toEqual(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||||
|
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||||
|
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||||
|
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||||
|
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||||
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
|
import { VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
|
||||||
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Logger,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags as DocsTags,
|
||||||
|
ApiHeader,
|
||||||
|
getSchemaPath,
|
||||||
|
ApiBody,
|
||||||
|
ApiExtraModels,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
CancelBookingInput,
|
||||||
|
CancelBookingInputPipe,
|
||||||
|
GetBookingOutput_2024_08_13,
|
||||||
|
GetBookingsOutput_2024_08_13,
|
||||||
|
RescheduleBookingInput,
|
||||||
|
RescheduleBookingInputPipe,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
import {
|
||||||
|
CreateBookingInputPipe,
|
||||||
|
CreateBookingInput,
|
||||||
|
GetBookingsInput_2024_08_13,
|
||||||
|
ReassignToUserBookingInput_2024_08_13,
|
||||||
|
MarkAbsentBookingInput_2024_08_13,
|
||||||
|
CreateBookingInput_2024_08_13,
|
||||||
|
CreateInstantBookingInput_2024_08_13,
|
||||||
|
CreateRecurringBookingInput_2024_08_13,
|
||||||
|
DeclineBookingInput_2024_08_13,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/bookings",
|
||||||
|
version: VERSION_2024_08_13_VALUE,
|
||||||
|
})
|
||||||
|
@UseGuards(PermissionsGuard)
|
||||||
|
@DocsTags("Bookings")
|
||||||
|
@ApiHeader({
|
||||||
|
name: "cal-api-version",
|
||||||
|
description: `Must be set to \`2024-08-13\``,
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
export class BookingsController_2024_08_13 {
|
||||||
|
private readonly logger = new Logger("BookingsController_2024_08_13");
|
||||||
|
|
||||||
|
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||||
|
|
||||||
|
@Post("/")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Create a booking",
|
||||||
|
description: `
|
||||||
|
POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:
|
||||||
|
If eventTypeId in the request body is id of a regular event, then regular booking is created.
|
||||||
|
|
||||||
|
If it is an id of a recurring event type, then recurring booking is created.
|
||||||
|
|
||||||
|
Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.
|
||||||
|
|
||||||
|
For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body.
|
||||||
|
|
||||||
|
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
oneOf: [
|
||||||
|
{ $ref: getSchemaPath(CreateBookingInput_2024_08_13) },
|
||||||
|
{ $ref: getSchemaPath(CreateInstantBookingInput_2024_08_13) },
|
||||||
|
{ $ref: getSchemaPath(CreateRecurringBookingInput_2024_08_13) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Accepts different types of booking input: CreateBookingInput_2024_08_13, CreateInstantBookingInput_2024_08_13, or CreateRecurringBookingInput_2024_08_13",
|
||||||
|
})
|
||||||
|
@ApiExtraModels(
|
||||||
|
CreateBookingInput_2024_08_13,
|
||||||
|
CreateInstantBookingInput_2024_08_13,
|
||||||
|
CreateRecurringBookingInput_2024_08_13
|
||||||
|
)
|
||||||
|
async createBooking(
|
||||||
|
@Body(new CreateBookingInputPipe())
|
||||||
|
body: CreateBookingInput,
|
||||||
|
@Req() request: Request
|
||||||
|
): Promise<CreateBookingOutput_2024_08_13> {
|
||||||
|
const booking = await this.bookingsService.createBooking(request, body);
|
||||||
|
|
||||||
|
if (Array.isArray(booking)) {
|
||||||
|
await this.bookingsService.billBookings(booking);
|
||||||
|
} else {
|
||||||
|
await this.bookingsService.billBooking(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: booking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/:bookingUid")
|
||||||
|
@UseGuards(BookingUidGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get a booking",
|
||||||
|
description: `\`:bookingUid\` can be
|
||||||
|
|
||||||
|
1. uid of a normal booking
|
||||||
|
|
||||||
|
2. uid of one of the recurring booking recurrences
|
||||||
|
|
||||||
|
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).`,
|
||||||
|
})
|
||||||
|
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput_2024_08_13> {
|
||||||
|
const booking = await this.bookingsService.getBooking(bookingUid);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: booking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/")
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@ApiHeader({
|
||||||
|
name: "Authorization",
|
||||||
|
description:
|
||||||
|
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
@Permissions([BOOKING_READ])
|
||||||
|
@ApiOperation({ summary: "Get all bookings" })
|
||||||
|
async getBookings(
|
||||||
|
@Query() queryParams: GetBookingsInput_2024_08_13,
|
||||||
|
@GetUser() user: User
|
||||||
|
): Promise<GetBookingsOutput_2024_08_13> {
|
||||||
|
const bookings = await this.bookingsService.getBookings(queryParams, user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: bookings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/reschedule")
|
||||||
|
@UseGuards(BookingUidGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Reschedule a booking",
|
||||||
|
description:
|
||||||
|
"Reschedule a booking by passing `:bookingUid` of the booking that should be rescheduled and pass request body with a new start time to create a new booking.",
|
||||||
|
})
|
||||||
|
async rescheduleBooking(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@Body(new RescheduleBookingInputPipe())
|
||||||
|
body: RescheduleBookingInput,
|
||||||
|
@Req() request: Request
|
||||||
|
): Promise<RescheduleBookingOutput_2024_08_13> {
|
||||||
|
const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body);
|
||||||
|
await this.bookingsService.billRescheduledBooking(newBooking, bookingUid);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: newBooking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/cancel")
|
||||||
|
@UseGuards(BookingUidGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Cancel a booking",
|
||||||
|
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||||
|
For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||||
|
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
|
||||||
|
})
|
||||||
|
async cancelBooking(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@Body(new CancelBookingInputPipe())
|
||||||
|
body: CancelBookingInput
|
||||||
|
): Promise<CancelBookingOutput_2024_08_13> {
|
||||||
|
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: cancelledBooking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/mark-absent")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||||
|
@ApiHeader({
|
||||||
|
name: "Authorization",
|
||||||
|
description:
|
||||||
|
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: "Mark a booking absence" })
|
||||||
|
async markNoShow(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@Body() body: MarkAbsentBookingInput_2024_08_13,
|
||||||
|
@GetUser("id") ownerId: number
|
||||||
|
): Promise<MarkAbsentBookingOutput_2024_08_13> {
|
||||||
|
const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: booking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/reassign")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||||
|
@ApiHeader({
|
||||||
|
name: "Authorization",
|
||||||
|
description:
|
||||||
|
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: "Automatically reassign booking to a new host" })
|
||||||
|
async reassignBooking(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@GetUser() user: UserWithProfile
|
||||||
|
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||||
|
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: booking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/reassign/:userId")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||||
|
@ApiHeader({
|
||||||
|
name: "Authorization",
|
||||||
|
description:
|
||||||
|
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: "Reassign a booking to a specific user" })
|
||||||
|
async reassignBookingToUser(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@Param("userId") userId: number,
|
||||||
|
@GetUser("id") reassignedById: number,
|
||||||
|
@Body() body: ReassignToUserBookingInput_2024_08_13
|
||||||
|
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||||
|
const booking = await this.bookingsService.reassignBookingToUser(
|
||||||
|
bookingUid,
|
||||||
|
userId,
|
||||||
|
reassignedById,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: booking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/confirm")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||||
|
@ApiHeader({
|
||||||
|
name: "Authorization",
|
||||||
|
description:
|
||||||
|
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: "Confirm booking that requires a confirmation" })
|
||||||
|
async confirmBooking(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@GetUser() user: UserWithProfile
|
||||||
|
): Promise<GetBookingOutput_2024_08_13> {
|
||||||
|
const booking = await this.bookingsService.confirmBooking(bookingUid, user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: booking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/decline")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||||
|
@ApiHeader({
|
||||||
|
name: "Authorization",
|
||||||
|
description:
|
||||||
|
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: "Decline booking that requires a confirmation" })
|
||||||
|
async declineBooking(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@Body() body: DeclineBookingInput_2024_08_13,
|
||||||
|
@GetUser() user: UserWithProfile
|
||||||
|
): Promise<GetBookingOutput_2024_08_13> {
|
||||||
|
const booking = await this.bookingsService.declineBooking(bookingUid, user, body.reason);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: booking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
AttendeeScheduledEmail,
|
||||||
|
OrganizerScheduledEmail,
|
||||||
|
AttendeeDeclinedEmail,
|
||||||
|
} from "@calcom/platform-libraries";
|
||||||
|
import { BookingOutput_2024_08_13, GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
jest.spyOn(AttendeeScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(OrganizerScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(AttendeeDeclinedEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
|
describe("Bookings confirmation", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let oAuthClient: PlatformOAuthClient;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
|
||||||
|
const userEmail = "bookings-controller-e2e@api.com";
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
let eventTypeId: number;
|
||||||
|
const eventTypeSlug = "peer-coding";
|
||||||
|
|
||||||
|
let createdBooking1: Booking;
|
||||||
|
let createdBooking2: Booking;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
userEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: "working time",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||||
|
const event = await eventTypesRepositoryFixture.create(
|
||||||
|
{ title: "peer coding", slug: eventTypeSlug, length: 60 },
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
eventTypeId = event.id;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should confirm a booking", async () => {
|
||||||
|
const status = "PENDING";
|
||||||
|
createdBooking1 = await bookingsRepositoryFixture.create({
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
|
||||||
|
title: "peer coding",
|
||||||
|
uid: "peer-coding-one",
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: "via 10, rome, italy",
|
||||||
|
customInputs: {},
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "Bob",
|
||||||
|
email: "bob@gmail.com",
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
email: "bob@gmail.com",
|
||||||
|
name: "Bob",
|
||||||
|
locale: "it",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
expect(createdBooking1.status).toEqual("PENDING");
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${createdBooking1.uid}/confirm`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
|
||||||
|
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
|
||||||
|
expect(dbBooking?.status).toEqual("ACCEPTED");
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decline a booking", async () => {
|
||||||
|
const status = "PENDING";
|
||||||
|
createdBooking2 = await bookingsRepositoryFixture.create({
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: new Date(Date.UTC(2050, 0, 7, 10, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2050, 0, 7, 11, 0, 0)),
|
||||||
|
title: "peer coding",
|
||||||
|
uid: "peer-coding-two",
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: "via 10, rome, italy",
|
||||||
|
customInputs: {},
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "Bob",
|
||||||
|
email: "bob@gmail.com",
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
email: "bob@gmail.com",
|
||||||
|
name: "Bob",
|
||||||
|
locale: "it",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
expect(createdBooking2.status).toEqual("PENDING");
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${createdBooking2.uid}/decline`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.status).toEqual("rejected");
|
||||||
|
|
||||||
|
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
|
||||||
|
expect(dbBooking?.status).toEqual("REJECTED");
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||||
|
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
|
||||||
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { Booking, User } from "@prisma/client";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||||
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
AttendeeScheduledEmail,
|
||||||
|
OrganizerScheduledEmail,
|
||||||
|
OrganizerReassignedEmail,
|
||||||
|
} from "@calcom/platform-libraries";
|
||||||
|
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
jest.spyOn(AttendeeScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(OrganizerScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(OrganizerReassignedEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
|
describe("Reassign bookings", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
let team: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let oAuthClient: PlatformOAuthClient;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||||
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
|
||||||
|
const teamUserEmail = "orgUser1team1@api.com";
|
||||||
|
const teamUserEmail2 = "orgUser2team1@api.com";
|
||||||
|
let teamUser1: User;
|
||||||
|
let teamUser2: User;
|
||||||
|
|
||||||
|
let teamRoundRobinEventTypeId: number;
|
||||||
|
|
||||||
|
let roundRobinBooking: Booking;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
teamUserEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
|
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
|
||||||
|
team = await teamRepositoryFixture.create({
|
||||||
|
name: "team 1",
|
||||||
|
isOrganization: false,
|
||||||
|
parent: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
|
teamUser1 = await userRepositoryFixture.create({
|
||||||
|
email: teamUserEmail,
|
||||||
|
locale: "it",
|
||||||
|
name: "orgUser1team1",
|
||||||
|
});
|
||||||
|
|
||||||
|
teamUser2 = await userRepositoryFixture.create({
|
||||||
|
email: teamUserEmail2,
|
||||||
|
locale: "it",
|
||||||
|
name: "orgUser2team1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: "working time",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
|
||||||
|
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${teamUser1.id}`,
|
||||||
|
username: teamUserEmail,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: organization.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser1.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${teamUser2.id}`,
|
||||||
|
username: teamUserEmail2,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: organization.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
user: { connect: { id: teamUser1.id } },
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
user: { connect: { id: teamUser2.id } },
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "ROUND_ROBIN",
|
||||||
|
team: {
|
||||||
|
connect: { id: team.id },
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
connect: [{ id: teamUser1.id }, { id: teamUser2.id }],
|
||||||
|
},
|
||||||
|
title: "Round Robin Event Type",
|
||||||
|
slug: "round-robin-event-type",
|
||||||
|
length: 60,
|
||||||
|
assignAllTeamMembers: false,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
teamRoundRobinEventTypeId = team1EventType.id;
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: false,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser1.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: team1EventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: false,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: team1EventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
roundRobinBooking = await bookingsRepositoryFixture.create({
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser1.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
|
||||||
|
title: "round robin coding lets goo",
|
||||||
|
uid: "round-robin-coding",
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: teamRoundRobinEventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: "via 10, rome, italy",
|
||||||
|
customInputs: {},
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "Bob",
|
||||||
|
email: "bob@gmail.com",
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
email: "bob@gmail.com",
|
||||||
|
name: "Bob",
|
||||||
|
locale: "it",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reassign round robin booking", async () => {
|
||||||
|
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
|
||||||
|
expect(booking?.userId).toEqual(teamUser1.id);
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||||
|
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
|
||||||
|
expect(data.reassignedTo).toEqual({
|
||||||
|
id: teamUser2.id,
|
||||||
|
name: teamUser2.name,
|
||||||
|
email: teamUser2.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
|
||||||
|
expect(reassigned?.userId).toEqual(teamUser2.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reassign round robin booking to a specific user", async () => {
|
||||||
|
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
|
||||||
|
expect(booking?.userId).toEqual(teamUser2.id);
|
||||||
|
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: teamRoundRobinEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "alice",
|
||||||
|
email: "alice@gmail.com",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
language: "es",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
|
||||||
|
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
|
||||||
|
expect(data.reassignedTo).toEqual({
|
||||||
|
id: teamUser1.id,
|
||||||
|
name: teamUser1.name,
|
||||||
|
email: teamUser1.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
|
||||||
|
expect(reassigned?.userId).toEqual(teamUser1.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||||
|
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(teamUser1.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
|
import { AttendeeCancelledEmail, OrganizerCancelledEmail } from "@calcom/platform-libraries";
|
||||||
|
import {
|
||||||
|
CreateRecurringBookingInput_2024_08_13,
|
||||||
|
RecurringBookingOutput_2024_08_13,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
|
describe("Creating recurring bookings", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let oAuthClient: PlatformOAuthClient;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
|
||||||
|
const userEmail = "bookings-controller-e2e@api.com";
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
const maxRecurrenceCount = 3;
|
||||||
|
let recurringEventTypeId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
userEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: "working time",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||||
|
|
||||||
|
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||||
|
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||||
|
{
|
||||||
|
title: "peer coding recurring",
|
||||||
|
slug: "peer-coding-recurring",
|
||||||
|
length: 60,
|
||||||
|
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
recurringEventTypeId = recurringEvent.id;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
|
||||||
|
const recurrenceCount = 1000;
|
||||||
|
|
||||||
|
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: recurringEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper Recurring",
|
||||||
|
email: "mr_proper_recurring@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
recurrenceCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
|
||||||
|
const recurrenceCount = maxRecurrenceCount - 1;
|
||||||
|
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: recurringEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper Recurring",
|
||||||
|
email: "mr_proper_recurring@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
recurrenceCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||||
|
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(maxRecurrenceCount - 1);
|
||||||
|
|
||||||
|
const firstBooking = data[0];
|
||||||
|
expect(firstBooking.id).toBeDefined();
|
||||||
|
expect(firstBooking.uid).toBeDefined();
|
||||||
|
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
||||||
|
expect(firstBooking.status).toEqual("accepted");
|
||||||
|
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
|
||||||
|
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
|
||||||
|
expect(firstBooking.duration).toEqual(60);
|
||||||
|
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||||
|
expect(firstBooking.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(firstBooking.location).toEqual(body.location);
|
||||||
|
expect(firstBooking.recurringBookingUid).toBeDefined();
|
||||||
|
expect(firstBooking.absentHost).toEqual(false);
|
||||||
|
|
||||||
|
const secondBooking = data[1];
|
||||||
|
expect(secondBooking.id).toBeDefined();
|
||||||
|
expect(secondBooking.uid).toBeDefined();
|
||||||
|
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
||||||
|
expect(secondBooking.status).toEqual("accepted");
|
||||||
|
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
|
||||||
|
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
|
||||||
|
expect(secondBooking.duration).toEqual(60);
|
||||||
|
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||||
|
expect(secondBooking.recurringBookingUid).toBeDefined();
|
||||||
|
expect(secondBooking.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(secondBooking.location).toEqual(body.location);
|
||||||
|
expect(secondBooking.absentHost).toEqual(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected recurring booking but received non array response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
|
||||||
|
const recurrenceCount = maxRecurrenceCount;
|
||||||
|
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: recurringEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper Recurring",
|
||||||
|
email: "mr_proper_recurring@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
recurrenceCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||||
|
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(maxRecurrenceCount);
|
||||||
|
|
||||||
|
const firstBooking = data[0];
|
||||||
|
expect(firstBooking.id).toBeDefined();
|
||||||
|
expect(firstBooking.uid).toBeDefined();
|
||||||
|
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
||||||
|
expect(firstBooking.status).toEqual("accepted");
|
||||||
|
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
|
||||||
|
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
|
||||||
|
expect(firstBooking.duration).toEqual(60);
|
||||||
|
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||||
|
expect(firstBooking.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(firstBooking.location).toEqual(body.location);
|
||||||
|
expect(firstBooking.meetingUrl).toEqual(body.location);
|
||||||
|
expect(firstBooking.recurringBookingUid).toBeDefined();
|
||||||
|
expect(firstBooking.absentHost).toEqual(false);
|
||||||
|
|
||||||
|
const secondBooking = data[1];
|
||||||
|
expect(secondBooking.id).toBeDefined();
|
||||||
|
expect(secondBooking.uid).toBeDefined();
|
||||||
|
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
||||||
|
expect(secondBooking.status).toEqual("accepted");
|
||||||
|
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
|
||||||
|
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
|
||||||
|
expect(secondBooking.duration).toEqual(60);
|
||||||
|
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||||
|
expect(secondBooking.recurringBookingUid).toBeDefined();
|
||||||
|
expect(secondBooking.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(secondBooking.location).toEqual(body.location);
|
||||||
|
expect(secondBooking.absentHost).toEqual(false);
|
||||||
|
|
||||||
|
const thirdBooking = data[2];
|
||||||
|
expect(thirdBooking.id).toBeDefined();
|
||||||
|
expect(thirdBooking.uid).toBeDefined();
|
||||||
|
expect(thirdBooking.hosts[0].id).toEqual(user.id);
|
||||||
|
expect(thirdBooking.status).toEqual("accepted");
|
||||||
|
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
|
||||||
|
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
|
||||||
|
expect(thirdBooking.duration).toEqual(60);
|
||||||
|
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||||
|
expect(thirdBooking.recurringBookingUid).toBeDefined();
|
||||||
|
expect(thirdBooking.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(thirdBooking.location).toEqual(body.location);
|
||||||
|
expect(thirdBooking.absentHost).toEqual(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected recurring booking but received non array response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
|
||||||
|
return Array.isArray(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Recurring bookings cancel all subsequent bookings", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let oAuthClient: PlatformOAuthClient;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
|
||||||
|
const userEmail = "bookings-controller-e2e@api.com";
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
const maxRecurrenceCount = 4;
|
||||||
|
let recurringEventTypeId: number;
|
||||||
|
|
||||||
|
let recurringBooking: RecurringBookingOutput_2024_08_13[];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
userEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: "working time",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||||
|
|
||||||
|
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||||
|
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||||
|
{
|
||||||
|
title: "peer coding recurring",
|
||||||
|
slug: "peer-coding-recurring",
|
||||||
|
length: 60,
|
||||||
|
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
recurringEventTypeId = recurringEvent.id;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should create a recurring booking", async () => {
|
||||||
|
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: recurringEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper Recurring",
|
||||||
|
email: "mr_proper_recurring@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||||
|
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(maxRecurrenceCount);
|
||||||
|
recurringBooking = data;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected recurring booking but received non array response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cancel all recurrences after a specific recurrence", async () => {
|
||||||
|
const recurringBookingUid = recurringBooking[2].recurringBookingUid;
|
||||||
|
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
|
||||||
|
for (const booking of bookings) {
|
||||||
|
expect(booking.status).toEqual("ACCEPTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
// note(Lauris): cancel all recurrences after and including third recurrence in the series of 4 recurrences
|
||||||
|
// aka cancel 3rd and 4th recurrences.
|
||||||
|
const thirdRecurrenceUid = recurringBooking[2].uid;
|
||||||
|
const fourthRecurringUid = recurringBooking[3].uid;
|
||||||
|
const body: CancelBookingInput_2024_08_13 = {
|
||||||
|
cancelSubsequentBookings: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${thirdRecurrenceUid}/cancel`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||||
|
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(4);
|
||||||
|
|
||||||
|
const firstRecurrence = data.find((booking) => booking.uid === recurringBooking[0].uid);
|
||||||
|
expect(firstRecurrence).toBeDefined();
|
||||||
|
expect(firstRecurrence?.status).toEqual("accepted");
|
||||||
|
|
||||||
|
const secondRecurrence = data.find((booking) => booking.uid === recurringBooking[1].uid);
|
||||||
|
expect(secondRecurrence).toBeDefined();
|
||||||
|
expect(secondRecurrence?.status).toEqual("accepted");
|
||||||
|
|
||||||
|
const thirdRecurrence = data.find((booking) => booking.uid === thirdRecurrenceUid);
|
||||||
|
expect(thirdRecurrence).toBeDefined();
|
||||||
|
expect(thirdRecurrence?.status).toEqual("cancelled");
|
||||||
|
|
||||||
|
const fourthRecurrence = data.find((booking) => booking.uid === fourthRecurringUid);
|
||||||
|
expect(fourthRecurrence).toBeDefined();
|
||||||
|
expect(fourthRecurrence?.status).toEqual("cancelled");
|
||||||
|
|
||||||
|
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
|
||||||
|
|
||||||
|
const bookingFirst = bookings.find((booking) => booking.uid === recurringBooking[0].uid);
|
||||||
|
expect(bookingFirst?.status).toEqual("ACCEPTED");
|
||||||
|
const bookingSecond = bookings.find((booking) => booking.uid === recurringBooking[1].uid);
|
||||||
|
expect(bookingSecond?.status).toEqual("ACCEPTED");
|
||||||
|
const bookingThird = bookings.find((booking) => booking.uid === recurringBooking[2].uid);
|
||||||
|
expect(bookingThird?.status).toEqual("CANCELLED");
|
||||||
|
const bookingFourth = bookings.find((booking) => booking.uid === recurringBooking[3].uid);
|
||||||
|
expect(bookingFourth?.status).toEqual("CANCELLED");
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected recurring booking but received non array response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Recurring bookings cancel all remaining bookings (bookings with start time greater than this moment)", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let oAuthClient: PlatformOAuthClient;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
|
||||||
|
const userEmail = "bookings-controller-e2e@api.com";
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
const maxRecurrenceCount = 4;
|
||||||
|
let recurringEventTypeId: number;
|
||||||
|
|
||||||
|
let recurringBooking: RecurringBookingOutput_2024_08_13[];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||||
|
return "<html><body>Mocked Email Content</body></html>";
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
userEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: "working time",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||||
|
|
||||||
|
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||||
|
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||||
|
{
|
||||||
|
title: "peer coding recurring",
|
||||||
|
slug: "peer-coding-recurring",
|
||||||
|
length: 60,
|
||||||
|
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
recurringEventTypeId = recurringEvent.id;
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should create a recurring booking", async () => {
|
||||||
|
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2040, 1, 3, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: recurringEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper Recurring",
|
||||||
|
email: "mr_proper_recurring@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||||
|
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(maxRecurrenceCount);
|
||||||
|
recurringBooking = data;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected recurring booking but received non array response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cancel all remaning recurrences", async () => {
|
||||||
|
const recurringBookingUid = recurringBooking[0].recurringBookingUid;
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${recurringBookingUid}/cancel`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||||
|
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(4);
|
||||||
|
for (const booking of data) {
|
||||||
|
expect(booking.status).toEqual("cancelled");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected recurring booking but received non array response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
+449
@@ -0,0 +1,449 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||||
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
CreateBookingInput_2024_08_13,
|
||||||
|
BookingOutput_2024_08_13,
|
||||||
|
RecurringBookingOutput_2024_08_13,
|
||||||
|
GetBookingsOutput_2024_08_13,
|
||||||
|
GetSeatedBookingOutput_2024_08_13,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
|
describe("Team bookings", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
let team1: Team;
|
||||||
|
let team2: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let oAuthClient: PlatformOAuthClient;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||||
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
|
||||||
|
const teamUserEmail = "orgUser1team1@api.com";
|
||||||
|
const teamUserEmail2 = "orgUser2team1@api.com";
|
||||||
|
let teamUser: User;
|
||||||
|
let teamUser2: User;
|
||||||
|
|
||||||
|
let team1EventTypeId: number;
|
||||||
|
let team2EventTypeId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
teamUserEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
|
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
|
||||||
|
team1 = await teamRepositoryFixture.create({
|
||||||
|
name: "team 1",
|
||||||
|
isOrganization: false,
|
||||||
|
parent: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
team2 = await teamRepositoryFixture.create({
|
||||||
|
name: "team 2",
|
||||||
|
isOrganization: false,
|
||||||
|
parent: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
|
teamUser = await userRepositoryFixture.create({
|
||||||
|
email: teamUserEmail,
|
||||||
|
locale: "it",
|
||||||
|
name: "orgUser1team1",
|
||||||
|
});
|
||||||
|
|
||||||
|
teamUser2 = await userRepositoryFixture.create({
|
||||||
|
email: teamUserEmail2,
|
||||||
|
locale: "es",
|
||||||
|
name: "orgUser2team1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: "working time",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(teamUser.id, userSchedule);
|
||||||
|
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${teamUser.id}`,
|
||||||
|
username: teamUserEmail,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: organization.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${teamUser2.id}`,
|
||||||
|
username: teamUserEmail2,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: organization.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
user: { connect: { id: teamUser.id } },
|
||||||
|
team: { connect: { id: team1.id } },
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
user: { connect: { id: teamUser.id } },
|
||||||
|
team: { connect: { id: team2.id } },
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
user: { connect: { id: teamUser2.id } },
|
||||||
|
team: { connect: { id: team2.id } },
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
|
team: {
|
||||||
|
connect: { id: team1.id },
|
||||||
|
},
|
||||||
|
title: "Collective Event Type",
|
||||||
|
slug: "collective-event-type",
|
||||||
|
length: 60,
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
team1EventTypeId = team1EventType.id;
|
||||||
|
|
||||||
|
const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
|
team: {
|
||||||
|
connect: { id: team2.id },
|
||||||
|
},
|
||||||
|
title: "Collective Event Type 2",
|
||||||
|
slug: "collective-event-type-2",
|
||||||
|
length: 60,
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
team2EventTypeId = team2EventType.id;
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: true,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: team1EventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: true,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: team2EventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostsRepositoryFixture.create({
|
||||||
|
isFixed: true,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: teamUser2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: team2EventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create team bookings", () => {
|
||||||
|
it("should create a team 1 booking", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: team1EventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "alice",
|
||||||
|
email: "alice@gmail.com",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
language: "es",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.hosts.length).toEqual(1);
|
||||||
|
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
|
||||||
|
expect(data.duration).toEqual(60);
|
||||||
|
expect(data.eventTypeId).toEqual(team1EventTypeId);
|
||||||
|
expect(data.attendees.length).toEqual(1);
|
||||||
|
expect(data.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||||
|
expect(data.absentHost).toEqual(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a team 2 booking", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: team2EventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "bob",
|
||||||
|
email: "bob@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.hosts.length).toEqual(1);
|
||||||
|
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString());
|
||||||
|
expect(data.duration).toEqual(60);
|
||||||
|
expect(data.eventTypeId).toEqual(team2EventTypeId);
|
||||||
|
expect(data.attendees.length).toEqual(2);
|
||||||
|
expect(data.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(data.attendees[1]).toEqual({
|
||||||
|
name: teamUser2.name,
|
||||||
|
timeZone: teamUser2.timeZone,
|
||||||
|
language: teamUser2.locale,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||||
|
expect(data.absentHost).toEqual(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("get team bookings", () => {
|
||||||
|
it("should should get bookings by teamId", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/bookings?teamId=${team1.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
const data: (
|
||||||
|
| BookingOutput_2024_08_13
|
||||||
|
| RecurringBookingOutput_2024_08_13
|
||||||
|
| GetSeatedBookingOutput_2024_08_13
|
||||||
|
)[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(1);
|
||||||
|
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should should get bookings by teamId", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/bookings?teamId=${team2.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
const data: (
|
||||||
|
| BookingOutput_2024_08_13
|
||||||
|
| RecurringBookingOutput_2024_08_13
|
||||||
|
| GetSeatedBookingOutput_2024_08_13
|
||||||
|
)[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(1);
|
||||||
|
expect(data[0].eventTypeId).toEqual(team2EventTypeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should should get bookings by teamIds", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/bookings?teamIds=${team1.id},${team2.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
const data: (
|
||||||
|
| BookingOutput_2024_08_13
|
||||||
|
| RecurringBookingOutput_2024_08_13
|
||||||
|
| GetSeatedBookingOutput_2024_08_13
|
||||||
|
)[] = responseBody.data;
|
||||||
|
expect(data.length).toEqual(2);
|
||||||
|
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
|
||||||
|
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||||
|
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(teamUser.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+1282
File diff suppressed because it is too large.
Load diff
+251
@@ -0,0 +1,251 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||||
|
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||||
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { EventType, User } from "@prisma/client";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
|
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
|
describe("User bookings", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let oAuthClient: PlatformOAuthClient;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
|
||||||
|
const userEmail = "bookings-controller-e2e@api.com";
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
let variableLengthEventType: EventType;
|
||||||
|
const variableLengthEventTypeSlug = "variable-length-event";
|
||||||
|
let normalEventType: EventType;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
userEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: "working time",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||||
|
|
||||||
|
variableLengthEventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: "variable length event",
|
||||||
|
slug: variableLengthEventTypeSlug,
|
||||||
|
length: 15,
|
||||||
|
metadata: { multipleDuration: [15, 30, 60] },
|
||||||
|
},
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
normalEventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{ title: "normal event", slug: "normal-event", length: 15 },
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create bookings", () => {
|
||||||
|
it("should not be able to specify length of booking for non variable length event type", async () => {
|
||||||
|
const lengthInMinutes = 30;
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: normalEventType.id,
|
||||||
|
lengthInMinutes,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper",
|
||||||
|
email: "mr_proper@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "customValue",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a booking with default length", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: variableLengthEventType.id,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper",
|
||||||
|
email: "mr_proper@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "customValue",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(
|
||||||
|
new Date(Date.UTC(2030, 0, 8, 10, variableLengthEventType.length, 0)).toISOString()
|
||||||
|
);
|
||||||
|
expect(data.duration).toEqual(variableLengthEventType.length);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a booking with specified length that is not default length", async () => {
|
||||||
|
const lengthInMinutes = 30;
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
lengthInMinutes,
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: variableLengthEventType.id,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper",
|
||||||
|
email: "mr_proper@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "customValue",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, lengthInMinutes, 0)).toISOString());
|
||||||
|
expect(data.duration).toEqual(lengthInMinutes);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||||
|
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingUidGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
const bookingUid = request.params.bookingUid;
|
||||||
|
|
||||||
|
if (!bookingUid) {
|
||||||
|
throw new BadRequestException("Booking UID missing in the request path");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
BookingOutput_2024_08_13,
|
||||||
|
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||||
|
GetSeatedBookingOutput_2024_08_13,
|
||||||
|
RecurringBookingOutput_2024_08_13,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||||
|
export class CancelBookingOutput_2024_08_13 {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@ApiProperty({
|
||||||
|
oneOf: [
|
||||||
|
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||||
|
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
|
||||||
|
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
|
||||||
|
{ $ref: getSchemaPath(GetSeatedBookingOutput_2024_08_13) },
|
||||||
|
{ $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) },
|
||||||
|
{ type: "array", items: { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) } },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects",
|
||||||
|
})
|
||||||
|
@Type(() => Object)
|
||||||
|
data!:
|
||||||
|
| BookingOutput_2024_08_13
|
||||||
|
| RecurringBookingOutput_2024_08_13
|
||||||
|
| RecurringBookingOutput_2024_08_13[]
|
||||||
|
| GetSeatedBookingOutput_2024_08_13
|
||||||
|
| GetRecurringSeatedBookingOutput_2024_08_13
|
||||||
|
| GetRecurringSeatedBookingOutput_2024_08_13[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
BookingOutput_2024_08_13,
|
||||||
|
RecurringBookingOutput_2024_08_13,
|
||||||
|
CreateSeatedBookingOutput_2024_08_13,
|
||||||
|
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||||
|
export class CreateBookingOutput_2024_08_13 {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@ApiProperty({
|
||||||
|
oneOf: [
|
||||||
|
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||||
|
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects",
|
||||||
|
})
|
||||||
|
@Type(() => Object)
|
||||||
|
data!:
|
||||||
|
| BookingOutput_2024_08_13
|
||||||
|
| RecurringBookingOutput_2024_08_13[]
|
||||||
|
| CreateSeatedBookingOutput_2024_08_13
|
||||||
|
| CreateRecurringSeatedBookingOutput_2024_08_13[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||||
|
export class MarkAbsentBookingOutput_2024_08_13 {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
oneOf: [
|
||||||
|
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||||
|
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object",
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Object)
|
||||||
|
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
export class ReassignBookingOutput_2024_08_13 {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
oneOf: [{ $ref: getSchemaPath(ReassignBookingOutputData_2024_08_13) }],
|
||||||
|
description:
|
||||||
|
"Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object",
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Object)
|
||||||
|
data!: ReassignBookingOutputData_2024_08_13;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsEnum, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
BookingOutput_2024_08_13,
|
||||||
|
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||||
|
CreateSeatedBookingOutput_2024_08_13,
|
||||||
|
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||||
|
GetSeatedBookingOutput_2024_08_13,
|
||||||
|
RecurringBookingOutput_2024_08_13,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||||
|
export class RescheduleBookingOutput_2024_08_13 {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
oneOf: [
|
||||||
|
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||||
|
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
|
||||||
|
{ $ref: getSchemaPath(CreateSeatedBookingOutput_2024_08_13) },
|
||||||
|
{ $ref: getSchemaPath(CreateRecurringSeatedBookingOutput_2024_08_13) },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object",
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Object)
|
||||||
|
data!:
|
||||||
|
| BookingOutput_2024_08_13
|
||||||
|
| RecurringBookingOutput_2024_08_13
|
||||||
|
| CreateSeatedBookingOutput_2024_08_13
|
||||||
|
| CreateRecurringSeatedBookingOutput_2024_08_13;
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
|
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||||
|
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
handleNewBooking,
|
||||||
|
handleNewRecurringBooking,
|
||||||
|
getAllUserBookings,
|
||||||
|
handleInstantMeeting,
|
||||||
|
handleCancelBooking,
|
||||||
|
roundRobinReassignment,
|
||||||
|
roundRobinManualReassignment,
|
||||||
|
handleMarkNoShow,
|
||||||
|
confirmBookingHandler,
|
||||||
|
} from "@calcom/platform-libraries";
|
||||||
|
import {
|
||||||
|
CreateBookingInput_2024_08_13,
|
||||||
|
CreateBookingInput,
|
||||||
|
CreateRecurringBookingInput_2024_08_13,
|
||||||
|
GetBookingsInput_2024_08_13,
|
||||||
|
CreateInstantBookingInput_2024_08_13,
|
||||||
|
MarkAbsentBookingInput_2024_08_13,
|
||||||
|
ReassignToUserBookingInput_2024_08_13,
|
||||||
|
BookingOutput_2024_08_13,
|
||||||
|
RecurringBookingOutput_2024_08_13,
|
||||||
|
GetSeatedBookingOutput_2024_08_13,
|
||||||
|
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||||
|
RescheduleBookingInput,
|
||||||
|
CancelBookingInput,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
type CreatedBooking = {
|
||||||
|
hosts: { id: number }[];
|
||||||
|
uid: string;
|
||||||
|
start: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingsService_2024_08_13 {
|
||||||
|
private readonly logger = new Logger("BookingsService");
|
||||||
|
constructor(
|
||||||
|
private readonly inputService: InputBookingsService_2024_08_13,
|
||||||
|
private readonly outputService: OutputBookingsService_2024_08_13,
|
||||||
|
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||||
|
private readonly bookingSeatRepository: BookingSeatRepository,
|
||||||
|
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||||
|
private readonly prismaReadService: PrismaReadService,
|
||||||
|
private readonly billingService: BillingService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly usersRepository: UsersRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createBooking(request: Request, body: CreateBookingInput) {
|
||||||
|
try {
|
||||||
|
if ("instant" in body && body.instant) {
|
||||||
|
return await this.createInstantBooking(request, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
|
||||||
|
const isRecurring = !!eventType?.recurringEvent;
|
||||||
|
const isSeated = !!eventType?.seatsPerTimeSlot;
|
||||||
|
|
||||||
|
if (isRecurring && isSeated) {
|
||||||
|
return await this.createRecurringSeatedBooking(request, body);
|
||||||
|
}
|
||||||
|
if (isRecurring && !isSeated) {
|
||||||
|
return await this.createRecurringBooking(request, body);
|
||||||
|
}
|
||||||
|
if (isSeated) {
|
||||||
|
return await this.createSeatedBooking(request, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.createRegularBooking(request, body);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "no_available_users_found_error") {
|
||||||
|
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) {
|
||||||
|
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||||
|
const booking = await handleInstantMeeting(bookingRequest);
|
||||||
|
|
||||||
|
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
|
||||||
|
booking.bookingId
|
||||||
|
);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with id=${booking.bookingId} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.outputService.getOutputBooking(databaseBooking);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
|
||||||
|
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||||
|
const bookings = await handleNewRecurringBooking(bookingRequest);
|
||||||
|
const ids = bookings.map((booking) => booking.id || 0);
|
||||||
|
return this.outputService.getOutputRecurringBookings(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
|
||||||
|
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||||
|
const bookings = await handleNewRecurringBooking(bookingRequest);
|
||||||
|
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||||
|
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||||
|
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||||
|
const booking = await handleNewBooking(bookingRequest);
|
||||||
|
|
||||||
|
if (!booking.uid) {
|
||||||
|
throw new Error("Booking missing uid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(booking.uid);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.outputService.getOutputBooking(databaseBooking);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||||
|
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||||
|
const booking = await handleNewBooking(bookingRequest);
|
||||||
|
|
||||||
|
if (!booking.uid) {
|
||||||
|
throw new Error("Booking missing uid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(
|
||||||
|
booking.uid
|
||||||
|
);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBooking(uid: string) {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
|
||||||
|
|
||||||
|
if (booking) {
|
||||||
|
const isRecurring = !!booking.recurringEventId;
|
||||||
|
const isSeated = !!booking.eventType?.seatsPerTimeSlot;
|
||||||
|
|
||||||
|
if (isRecurring && !isSeated) {
|
||||||
|
return this.outputService.getOutputRecurringBooking(booking);
|
||||||
|
}
|
||||||
|
if (isRecurring && isSeated) {
|
||||||
|
return this.outputService.getOutputRecurringSeatedBooking(booking);
|
||||||
|
}
|
||||||
|
if (isSeated) {
|
||||||
|
return this.outputService.getOutputSeatedBooking(booking);
|
||||||
|
}
|
||||||
|
return this.outputService.getOutputBooking(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid);
|
||||||
|
if (!recurringBooking.length) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${uid} was not found in the database`);
|
||||||
|
}
|
||||||
|
const ids = recurringBooking.map((booking) => booking.id);
|
||||||
|
const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot;
|
||||||
|
if (isRecurringSeated) {
|
||||||
|
return this.outputService.getOutputRecurringSeatedBookings(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.outputService.getOutputRecurringBookings(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
|
||||||
|
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
|
||||||
|
bookingListingByStatus: queryParams.status || [],
|
||||||
|
skip: queryParams.skip ?? 0,
|
||||||
|
// note(Lauris): we substract -1 because getAllUSerBookings child function adds +1 for some reason
|
||||||
|
take: queryParams.take ? queryParams.take - 1 : 100,
|
||||||
|
filters: this.inputService.transformGetBookingsFilters(queryParams),
|
||||||
|
ctx: {
|
||||||
|
user,
|
||||||
|
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||||
|
},
|
||||||
|
sort: this.inputService.transformGetBookingsSort(queryParams),
|
||||||
|
});
|
||||||
|
// note(Lauris): fetchedBookings don't have attendees information and responses and i don't want to add them to the handler query,
|
||||||
|
// because its used elsewhere in code that does not need that information, so i get ids, fetch bookings and then return them formatted in same order as ids.
|
||||||
|
const ids = fetchedBookings.bookings.map((booking) => booking.id);
|
||||||
|
const bookings = await this.bookingsRepository.getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids);
|
||||||
|
|
||||||
|
const bookingMap = new Map(bookings.map((booking) => [booking.id, booking]));
|
||||||
|
const orderedBookings = ids.map((id) => bookingMap.get(id));
|
||||||
|
|
||||||
|
const formattedBookings: (
|
||||||
|
| BookingOutput_2024_08_13
|
||||||
|
| RecurringBookingOutput_2024_08_13
|
||||||
|
| GetSeatedBookingOutput_2024_08_13
|
||||||
|
| GetRecurringSeatedBookingOutput_2024_08_13
|
||||||
|
)[] = [];
|
||||||
|
for (const booking of orderedBookings) {
|
||||||
|
if (!booking) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = {
|
||||||
|
...booking,
|
||||||
|
eventType: booking.eventType,
|
||||||
|
eventTypeId: booking.eventTypeId,
|
||||||
|
startTime: new Date(booking.startTime),
|
||||||
|
endTime: new Date(booking.endTime),
|
||||||
|
absentHost: !!booking.noShowHost,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecurring = !!formatted.recurringEventId;
|
||||||
|
const isSeated = !!formatted.eventType?.seatsPerTimeSlot;
|
||||||
|
if (isRecurring && !isSeated) {
|
||||||
|
formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted));
|
||||||
|
} else if (isRecurring && isSeated) {
|
||||||
|
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted));
|
||||||
|
} else if (isSeated) {
|
||||||
|
formattedBookings.push(this.outputService.getOutputSeatedBooking(formatted));
|
||||||
|
} else {
|
||||||
|
formattedBookings.push(this.outputService.getOutputBooking(formatted));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedBookings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
|
||||||
|
try {
|
||||||
|
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||||
|
request,
|
||||||
|
bookingUid,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
const booking = await handleNewBooking(bookingRequest);
|
||||||
|
if (!booking.uid) {
|
||||||
|
throw new Error("Booking missing uid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseBooking =
|
||||||
|
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecurring = !!databaseBooking.recurringEventId;
|
||||||
|
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
|
||||||
|
|
||||||
|
if (isRecurring && !isSeated) {
|
||||||
|
return this.outputService.getOutputRecurringBooking(databaseBooking);
|
||||||
|
}
|
||||||
|
if (isRecurring && isSeated) {
|
||||||
|
return this.outputService.getOutputCreateRecurringSeatedBooking(
|
||||||
|
databaseBooking,
|
||||||
|
booking?.seatReferenceUid || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isSeated) {
|
||||||
|
return this.outputService.getOutputCreateSeatedBooking(
|
||||||
|
databaseBooking,
|
||||||
|
booking.seatReferenceUid || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.outputService.getOutputBooking(databaseBooking);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "no_available_users_found_error") {
|
||||||
|
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
|
||||||
|
if (this.inputService.isCancelSeatedBody(body)) {
|
||||||
|
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
|
||||||
|
|
||||||
|
if (!seat) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Invalid seatUid: this seat does not exist or has already been cancelled."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seat && bookingUid !== seat.booking.uid) {
|
||||||
|
throw new BadRequestException("Invalid seatUid: this seat does not belong to this booking.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
|
||||||
|
await handleCancelBooking(bookingRequest);
|
||||||
|
|
||||||
|
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
|
||||||
|
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getBooking(bookingUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAllRecurringBookingsByIndividualUid(bookingUid: string) {
|
||||||
|
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||||
|
const recurringBookingUid = booking?.recurringEventId;
|
||||||
|
if (!recurringBookingUid) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking with bookingUid=${bookingUid} is not part of a recurring booking.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.getBooking(recurringBookingUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||||
|
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||||
|
|
||||||
|
await handleMarkNoShow({
|
||||||
|
bookingUid,
|
||||||
|
attendees: bodyTransformed.attendees,
|
||||||
|
noShowHost: bodyTransformed.noShowHost,
|
||||||
|
userId: bookingOwnerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new Error(`Booking with uid=${bookingUid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecurring = !!booking.recurringEventId;
|
||||||
|
if (isRecurring) {
|
||||||
|
return this.outputService.getOutputRecurringBooking(booking);
|
||||||
|
}
|
||||||
|
return this.outputService.getOutputBooking(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
async billBookings(bookings: CreatedBooking[]) {
|
||||||
|
for (const booking of bookings) {
|
||||||
|
await this.billBooking(booking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async billBooking(booking: CreatedBooking) {
|
||||||
|
const hostId = booking.hosts?.[0]?.id;
|
||||||
|
if (!hostId) {
|
||||||
|
this.logger.error(`Booking with uid=${booking.uid} has no host`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.billingService.increaseUsageByUserId(hostId, {
|
||||||
|
uid: booking.uid,
|
||||||
|
startTime: new Date(booking.start),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async billRescheduledBooking(newBooking: CreatedBooking, oldBookingUid: string) {
|
||||||
|
const hostId = newBooking.hosts[0].id;
|
||||||
|
if (!hostId) {
|
||||||
|
this.logger.error(`Booking with uid=${newBooking.uid} has no host`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.billingService.increaseUsageByUserId(hostId, {
|
||||||
|
uid: newBooking.uid,
|
||||||
|
startTime: new Date(newBooking.start),
|
||||||
|
fromReschedule: oldBookingUid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||||
|
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||||
|
|
||||||
|
await roundRobinReassignment({
|
||||||
|
bookingId: booking.id,
|
||||||
|
orgId: profile?.organizationId || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||||
|
if (!reassigned) {
|
||||||
|
throw new NotFoundException(`Reassigned booking with uid=${bookingUid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reassignBookingToUser(
|
||||||
|
bookingUid: string,
|
||||||
|
newUserId: number,
|
||||||
|
reassignedById: number,
|
||||||
|
body: ReassignToUserBookingInput_2024_08_13
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.usersRepository.findByIdWithProfile(newUserId);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`User with id=${newUserId} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = this.usersService.getUserMainProfile(user);
|
||||||
|
|
||||||
|
const reassigned = await roundRobinManualReassignment({
|
||||||
|
bookingId: booking.id,
|
||||||
|
newUserId,
|
||||||
|
orgId: profile?.organizationId || null,
|
||||||
|
reassignReason: body.reason,
|
||||||
|
reassignedById,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||||
|
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await confirmBookingHandler({
|
||||||
|
ctx: {
|
||||||
|
user: requestUser,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
bookingId: booking.id,
|
||||||
|
confirmed: true,
|
||||||
|
recurringEventId: booking.recurringEventId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getBooking(bookingUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async declineBooking(bookingUid: string, requestUser: UserWithProfile, reason?: string) {
|
||||||
|
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await confirmBookingHandler({
|
||||||
|
ctx: {
|
||||||
|
user: requestUser,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
bookingId: booking.id,
|
||||||
|
confirmed: false,
|
||||||
|
recurringEventId: booking.recurringEventId,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getBooking(bookingUid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
|
import {
|
||||||
|
bookingResponsesSchema,
|
||||||
|
seatedBookingResponsesSchema,
|
||||||
|
} from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
|
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
|
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||||
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { NextApiRequest } from "next/types";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||||
|
import { EventTypeMetaDataSchema } from "@calcom/platform-libraries";
|
||||||
|
import {
|
||||||
|
CancelBookingInput,
|
||||||
|
CancelBookingInput_2024_08_13,
|
||||||
|
CancelSeatedBookingInput_2024_08_13,
|
||||||
|
CreateBookingInput_2024_08_13,
|
||||||
|
CreateInstantBookingInput_2024_08_13,
|
||||||
|
CreateRecurringBookingInput_2024_08_13,
|
||||||
|
GetBookingsInput_2024_08_13,
|
||||||
|
MarkAbsentBookingInput_2024_08_13,
|
||||||
|
RescheduleBookingInput,
|
||||||
|
RescheduleBookingInput_2024_08_13,
|
||||||
|
RescheduleSeatedBookingInput_2024_08_13,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
import { EventType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
|
||||||
|
|
||||||
|
const DEFAULT_PLATFORM_PARAMS = {
|
||||||
|
platformClientId: "",
|
||||||
|
platformCancelUrl: "",
|
||||||
|
platformRescheduleUrl: "",
|
||||||
|
platformBookingUrl: "",
|
||||||
|
arePlatformEmailsEnabled: false,
|
||||||
|
platformBookingLocation: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
type OAuthRequestParams = {
|
||||||
|
platformClientId: string;
|
||||||
|
platformRescheduleUrl: string;
|
||||||
|
platformCancelUrl: string;
|
||||||
|
platformBookingUrl: string;
|
||||||
|
platformBookingLocation?: string;
|
||||||
|
arePlatformEmailsEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum Frequency {
|
||||||
|
"YEARLY",
|
||||||
|
"MONTHLY",
|
||||||
|
"WEEKLY",
|
||||||
|
"DAILY",
|
||||||
|
"HOURLY",
|
||||||
|
"MINUTELY",
|
||||||
|
"SECONDLY",
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurringEventSchema = z.object({
|
||||||
|
dtstart: z.string().optional(),
|
||||||
|
interval: z.number().int(),
|
||||||
|
count: z.number().int(),
|
||||||
|
freq: z.nativeEnum(Frequency),
|
||||||
|
until: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InputBookingsService_2024_08_13 {
|
||||||
|
private readonly logger = new Logger("InputBookingsService_2024_08_13");
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly oAuthFlowService: OAuthFlowService,
|
||||||
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
|
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||||
|
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly apiKeyRepository: ApiKeyRepository,
|
||||||
|
private readonly bookingSeatRepository: BookingSeatRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createBookingRequest(
|
||||||
|
request: Request,
|
||||||
|
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13
|
||||||
|
): Promise<BookingRequest> {
|
||||||
|
const bodyTransformed = await this.transformInputCreateBooking(body);
|
||||||
|
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||||
|
const requestId = request.get("X-Request-Id");
|
||||||
|
|
||||||
|
const newRequest = { ...request };
|
||||||
|
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||||
|
const oAuthParams = oAuthClientId
|
||||||
|
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||||
|
: DEFAULT_PLATFORM_PARAMS;
|
||||||
|
|
||||||
|
const location = request.body.location || request.body.meetingUrl;
|
||||||
|
this.logger.log(`createBookingRequest_2024_04_15`, {
|
||||||
|
requestId,
|
||||||
|
ownerId: userId,
|
||||||
|
location,
|
||||||
|
oAuthClientId,
|
||||||
|
...oAuthParams,
|
||||||
|
});
|
||||||
|
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
|
||||||
|
|
||||||
|
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||||
|
|
||||||
|
return newRequest as unknown as BookingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13) {
|
||||||
|
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
|
||||||
|
inputBooking.eventTypeId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!eventType) {
|
||||||
|
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateBookingLengthInMinutes(inputBooking, eventType);
|
||||||
|
|
||||||
|
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
|
||||||
|
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
|
||||||
|
inputBooking.attendee.timeZone
|
||||||
|
);
|
||||||
|
const endTime = startTime.plus({ minutes: lengthInMinutes });
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startTime.toISO(),
|
||||||
|
end: endTime.toISO(),
|
||||||
|
eventTypeId: inputBooking.eventTypeId,
|
||||||
|
timeZone: inputBooking.attendee.timeZone,
|
||||||
|
language: inputBooking.attendee.language || "en",
|
||||||
|
metadata: inputBooking.metadata || {},
|
||||||
|
hasHashedBookingLink: false,
|
||||||
|
guests: inputBooking.guests,
|
||||||
|
// note(Lauris): responses with name and email are required by the handleNewBooking
|
||||||
|
responses: inputBooking.bookingFieldsResponses
|
||||||
|
? {
|
||||||
|
...inputBooking.bookingFieldsResponses,
|
||||||
|
name: inputBooking.attendee.name,
|
||||||
|
email: inputBooking.attendee.email,
|
||||||
|
}
|
||||||
|
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validateBookingLengthInMinutes(inputBooking: CreateBookingInput_2024_08_13, eventType: EventType) {
|
||||||
|
const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType.metadata);
|
||||||
|
if (inputBooking.lengthInMinutes && !eventTypeMetadata?.multipleDuration) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Can't specify 'lengthInMinutes' because event type does not have multiple possible lengths. Please, remove the 'lengthInMinutes' field from the request."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
inputBooking.lengthInMinutes &&
|
||||||
|
!eventTypeMetadata?.multipleDuration?.includes(inputBooking.lengthInMinutes)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Provided 'lengthInMinutes' is not one of the possible lengths for the event type. The possible lengths are: ${eventTypeMetadata?.multipleDuration?.join(
|
||||||
|
", "
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRecurringBookingRequest(
|
||||||
|
request: Request,
|
||||||
|
body: CreateRecurringBookingInput_2024_08_13
|
||||||
|
): Promise<BookingRequest> {
|
||||||
|
// note(Lauris): update to this.transformInputCreate when rescheduling is implemented
|
||||||
|
const bodyTransformed = await this.transformInputCreateRecurringBooking(body);
|
||||||
|
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||||
|
|
||||||
|
const newRequest = { ...request };
|
||||||
|
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||||
|
const oAuthParams = oAuthClientId
|
||||||
|
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||||
|
: DEFAULT_PLATFORM_PARAMS;
|
||||||
|
|
||||||
|
const location = request.body.location || request.body.meetingUrl;
|
||||||
|
Object.assign(newRequest, {
|
||||||
|
userId,
|
||||||
|
...oAuthParams,
|
||||||
|
platformBookingLocation: location,
|
||||||
|
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
newRequest.body = bodyTransformed.map((event) => ({
|
||||||
|
...event,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return newRequest as unknown as BookingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async transformInputCreateRecurringBooking(inputBooking: CreateRecurringBookingInput_2024_08_13) {
|
||||||
|
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
|
||||||
|
inputBooking.eventTypeId
|
||||||
|
);
|
||||||
|
if (!eventType) {
|
||||||
|
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
|
||||||
|
}
|
||||||
|
if (!eventType.recurringEvent) {
|
||||||
|
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
|
||||||
|
const repeatsEvery = occurrence.interval;
|
||||||
|
|
||||||
|
if (inputBooking.recurrenceCount && inputBooking.recurrenceCount > occurrence.count) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Provided recurrence count is higher than the event type's recurring event count."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const repeatsTimes = inputBooking.recurrenceCount || occurrence.count;
|
||||||
|
// note(Lauris): timeBetween 0=yearly, 1=monthly and 2=weekly
|
||||||
|
const timeBetween = occurrence.freq;
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
const recurringEventId = uuidv4();
|
||||||
|
|
||||||
|
let startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
|
||||||
|
inputBooking.attendee.timeZone
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < repeatsTimes; i++) {
|
||||||
|
const endTime = startTime.plus({ minutes: eventType.length });
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
start: startTime.toISO(),
|
||||||
|
end: endTime.toISO(),
|
||||||
|
eventTypeId: inputBooking.eventTypeId,
|
||||||
|
recurringEventId,
|
||||||
|
timeZone: inputBooking.attendee.timeZone,
|
||||||
|
language: inputBooking.attendee.language || "en",
|
||||||
|
metadata: inputBooking.metadata || {},
|
||||||
|
hasHashedBookingLink: false,
|
||||||
|
guests: inputBooking.guests,
|
||||||
|
// note(Lauris): responses with name and email are required by the handleNewBooking
|
||||||
|
responses: inputBooking.bookingFieldsResponses
|
||||||
|
? {
|
||||||
|
...inputBooking.bookingFieldsResponses,
|
||||||
|
name: inputBooking.attendee.name,
|
||||||
|
email: inputBooking.attendee.email,
|
||||||
|
}
|
||||||
|
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email },
|
||||||
|
schedulingType: eventType.schedulingType,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (timeBetween) {
|
||||||
|
case 0: // Yearly
|
||||||
|
startTime = startTime.plus({ years: repeatsEvery });
|
||||||
|
break;
|
||||||
|
case 1: // Monthly
|
||||||
|
startTime = startTime.plus({ months: repeatsEvery });
|
||||||
|
break;
|
||||||
|
case 2: // Weekly
|
||||||
|
startTime = startTime.plus({ weeks: repeatsEvery });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Unsupported timeBetween value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRescheduleBookingRequest(
|
||||||
|
request: Request,
|
||||||
|
bookingUid: string,
|
||||||
|
body: RescheduleBookingInput
|
||||||
|
): Promise<BookingRequest> {
|
||||||
|
const bodyTransformed = this.isRescheduleSeatedBody(body)
|
||||||
|
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
|
||||||
|
: await this.transformInputRescheduleBooking(bookingUid, body);
|
||||||
|
|
||||||
|
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||||
|
|
||||||
|
const newRequest = { ...request };
|
||||||
|
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||||
|
const oAuthParams = oAuthClientId
|
||||||
|
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||||
|
: DEFAULT_PLATFORM_PARAMS;
|
||||||
|
|
||||||
|
const location = await this.getRescheduleBookingLocation(bookingUid);
|
||||||
|
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
|
||||||
|
|
||||||
|
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||||
|
|
||||||
|
return newRequest as unknown as BookingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
||||||
|
return body.hasOwnProperty("seatUid");
|
||||||
|
}
|
||||||
|
|
||||||
|
async transformInputRescheduleSeatedBooking(
|
||||||
|
bookingUid: string,
|
||||||
|
inputBooking: RescheduleSeatedBookingInput_2024_08_13
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||||
|
// todo create booking seat module, repository and fetch the seat to get info
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||||
|
}
|
||||||
|
if (!booking.eventTypeId) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`);
|
||||||
|
}
|
||||||
|
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId);
|
||||||
|
if (!eventType) {
|
||||||
|
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seat = await this.bookingSeatRepository.getByReferenceUid(inputBooking.seatUid);
|
||||||
|
if (!seat) {
|
||||||
|
throw new NotFoundException(`Seat with uid=${inputBooking.seatUid} does not exist.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { responses: bookingResponses } = seatedBookingResponsesSchema.parse(seat.data);
|
||||||
|
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
|
||||||
|
|
||||||
|
if (!attendee) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} and seatUid=${inputBooking.seatUid} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
|
||||||
|
const endTime = startTime.plus({ minutes: eventType.length });
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startTime.toISO(),
|
||||||
|
end: endTime.toISO(),
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
timeZone: attendee.timeZone,
|
||||||
|
language: attendee.locale,
|
||||||
|
metadata: seat.metadata || {},
|
||||||
|
hasHashedBookingLink: false,
|
||||||
|
guests: [],
|
||||||
|
responses: { ...bookingResponses },
|
||||||
|
rescheduleUid: inputBooking.seatUid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||||
|
}
|
||||||
|
if (!booking.eventTypeId) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`);
|
||||||
|
}
|
||||||
|
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId);
|
||||||
|
if (!eventType) {
|
||||||
|
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingResponses = bookingResponsesSchema.parse(booking.responses);
|
||||||
|
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
|
||||||
|
|
||||||
|
if (!attendee) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
|
||||||
|
const endTime = startTime.plus({ minutes: eventType.length });
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: startTime.toISO(),
|
||||||
|
end: endTime.toISO(),
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
timeZone: attendee.timeZone,
|
||||||
|
language: attendee.locale,
|
||||||
|
metadata: booking.metadata || {},
|
||||||
|
hasHashedBookingLink: false,
|
||||||
|
guests: bookingResponses.guests,
|
||||||
|
responses: { ...bookingResponses, rescheduledReason: inputBooking.reschedulingReason },
|
||||||
|
rescheduleUid: bookingUid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRescheduleBookingLocation(rescheduleBookingUid: string) {
|
||||||
|
const booking = await this.bookingsRepository.getByUid(rescheduleBookingUid);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid=${rescheduleBookingUid} not found`);
|
||||||
|
}
|
||||||
|
return booking.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBookingRequestOwnerId(req: Request): Promise<number | undefined> {
|
||||||
|
try {
|
||||||
|
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
|
||||||
|
if (bearerToken) {
|
||||||
|
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||||
|
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||||
|
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||||
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
|
return keyData?.userId;
|
||||||
|
} else {
|
||||||
|
// Access Token
|
||||||
|
return this.oAuthFlowService.getOwnerId(bearerToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBookingRequestOAuthClientParams(clientId: string) {
|
||||||
|
const params = DEFAULT_PLATFORM_PARAMS;
|
||||||
|
try {
|
||||||
|
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
|
||||||
|
if (client) {
|
||||||
|
params.platformClientId = clientId;
|
||||||
|
params.platformCancelUrl = client.bookingCancelRedirectUri ?? "";
|
||||||
|
params.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
|
||||||
|
params.platformBookingUrl = client.bookingRedirectUri ?? "";
|
||||||
|
params.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transformGetBookingsFilters(queryParams: GetBookingsInput_2024_08_13) {
|
||||||
|
return {
|
||||||
|
attendeeEmail: queryParams.attendeeEmail,
|
||||||
|
attendeeName: queryParams.attendeeName,
|
||||||
|
afterStartDate: queryParams.afterStart,
|
||||||
|
beforeEndDate: queryParams.beforeEnd,
|
||||||
|
teamIds: queryParams.teamsIds || (queryParams.teamId ? [queryParams.teamId] : undefined),
|
||||||
|
eventTypeIds:
|
||||||
|
queryParams.eventTypeIds || (queryParams.eventTypeId ? [queryParams.eventTypeId] : undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
transformGetBookingsSort(queryParams: GetBookingsInput_2024_08_13) {
|
||||||
|
if (!queryParams.sortStart && !queryParams.sortEnd && !queryParams.sortCreated) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sortStart: queryParams.sortStart,
|
||||||
|
sortEnd: queryParams.sortEnd,
|
||||||
|
sortCreated: queryParams.sortCreated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCancelBookingRequest(
|
||||||
|
request: Request,
|
||||||
|
bookingUid: string,
|
||||||
|
body: CancelBookingInput
|
||||||
|
): Promise<BookingRequest> {
|
||||||
|
const bodyTransformed = this.isCancelSeatedBody(body)
|
||||||
|
? await this.transformInputCancelSeatedBooking(bookingUid, body)
|
||||||
|
: await this.transformInputCancelBooking(bookingUid, body);
|
||||||
|
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||||
|
|
||||||
|
const newRequest = { ...request };
|
||||||
|
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||||
|
const oAuthParams = oAuthClientId
|
||||||
|
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||||
|
: DEFAULT_PLATFORM_PARAMS;
|
||||||
|
|
||||||
|
Object.assign(newRequest, { userId, ...oAuthParams });
|
||||||
|
|
||||||
|
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||||
|
|
||||||
|
return newRequest as unknown as BookingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 {
|
||||||
|
return body.hasOwnProperty("seatUid");
|
||||||
|
}
|
||||||
|
|
||||||
|
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
||||||
|
const recurringBooking = await this.bookingsRepository.getRecurringByUid(bookingUid);
|
||||||
|
// note(Lauris): isRecurring means that recurringEventId was passed as uid. isRecurring does not refer to the uid of 1 individual booking within a recurring booking consisting of many bookings.
|
||||||
|
// That is what recurringEventId refers to.
|
||||||
|
const isRecurringUid = !!recurringBooking.length;
|
||||||
|
|
||||||
|
if (isRecurringUid && inputBooking.cancelSubsequentBookings) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Cannot cancel subsequent bookings for recurring event - you have to provide uid of one of the individual bookings of a recurring booking."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecurringUid) {
|
||||||
|
return {
|
||||||
|
// note(Lauris): set uid as one of the oldest individual recurring ids
|
||||||
|
uid: recurringBooking[0].uid,
|
||||||
|
cancellationReason: inputBooking.cancellationReason,
|
||||||
|
allRemainingBookings: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputBooking.cancelSubsequentBookings) {
|
||||||
|
return {
|
||||||
|
uid: bookingUid,
|
||||||
|
cancellationReason: inputBooking.cancellationReason,
|
||||||
|
cancelSubsequentBookings: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid: bookingUid,
|
||||||
|
cancellationReason: inputBooking.cancellationReason,
|
||||||
|
allRemainingBookings: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async transformInputCancelSeatedBooking(
|
||||||
|
bookingUid: string,
|
||||||
|
inputBooking: CancelSeatedBookingInput_2024_08_13
|
||||||
|
) {
|
||||||
|
// note(Lauris): for recurring seated booking it is not possible to cancel all remaining bookings
|
||||||
|
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
|
||||||
|
return {
|
||||||
|
uid: bookingUid,
|
||||||
|
cancellationReason: "",
|
||||||
|
allRemainingBookings: false,
|
||||||
|
seatReferenceUid: inputBooking.seatUid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
transformInputMarkAbsentBooking(inputBooking: MarkAbsentBookingInput_2024_08_13) {
|
||||||
|
return {
|
||||||
|
noShowHost: inputBooking.host,
|
||||||
|
attendees: inputBooking.attendees?.map((attendee) => ({
|
||||||
|
email: attendee.email,
|
||||||
|
noShow: attendee.absent,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { bookingMetadataSchema } from "@calcom/platform-libraries";
|
||||||
|
import {
|
||||||
|
BookingOutput_2024_08_13,
|
||||||
|
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||||
|
CreateSeatedBookingOutput_2024_08_13,
|
||||||
|
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||||
|
GetSeatedBookingOutput_2024_08_13,
|
||||||
|
ReassignBookingOutput_2024_08_13,
|
||||||
|
RecurringBookingOutput_2024_08_13,
|
||||||
|
SeatedAttendee,
|
||||||
|
} from "@calcom/platform-types";
|
||||||
|
import { Booking, BookingSeat } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
export const bookingResponsesSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
guests: z.array(z.string()).optional(),
|
||||||
|
rescheduleReason: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const seatedBookingResponsesSchema = z
|
||||||
|
.object({
|
||||||
|
responses: z
|
||||||
|
.object({
|
||||||
|
email: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
type DatabaseBooking = Booking & {
|
||||||
|
eventType: {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
} | null;
|
||||||
|
attendees: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
timeZone: string;
|
||||||
|
locale: string | null;
|
||||||
|
noShow: boolean | null;
|
||||||
|
bookingSeat?: BookingSeat | null;
|
||||||
|
}[];
|
||||||
|
} & { user: { id: number; name: string | null; email: string } | null };
|
||||||
|
|
||||||
|
type BookingWithUser = Booking & { user: { id: number; name: string | null; email: string } | null };
|
||||||
|
|
||||||
|
const bookingUserMetadataSchema = bookingMetadataSchema.transform((data) => {
|
||||||
|
if (data === null) return {};
|
||||||
|
// note(Lauris): return only user defined metadata
|
||||||
|
const { videoCallUrl, ...rest } = data;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
const seatedBookingMetadataSchema = z.object({}).catchall(z.string());
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutputBookingsService_2024_08_13 {
|
||||||
|
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
|
||||||
|
|
||||||
|
getOutputBooking(databaseBooking: DatabaseBooking) {
|
||||||
|
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||||
|
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||||
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
|
|
||||||
|
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
|
||||||
|
const metadata = bookingUserMetadataSchema.parse(databaseBooking.metadata);
|
||||||
|
|
||||||
|
const booking = {
|
||||||
|
id: databaseBooking.id,
|
||||||
|
uid: databaseBooking.uid,
|
||||||
|
title: databaseBooking.title,
|
||||||
|
description: databaseBooking.description,
|
||||||
|
hosts: [databaseBooking.user],
|
||||||
|
status: databaseBooking.status.toLowerCase(),
|
||||||
|
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||||
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
|
start: databaseBooking.startTime,
|
||||||
|
end: databaseBooking.endTime,
|
||||||
|
duration,
|
||||||
|
eventType: databaseBooking.eventType,
|
||||||
|
// note(Lauris): eventTypeId is deprecated
|
||||||
|
eventTypeId: databaseBooking.eventTypeId,
|
||||||
|
attendees: databaseBooking.attendees.map((attendee) => ({
|
||||||
|
name: attendee.name,
|
||||||
|
email: attendee.email,
|
||||||
|
timeZone: attendee.timeZone,
|
||||||
|
language: attendee.locale,
|
||||||
|
absent: !!attendee.noShow,
|
||||||
|
})),
|
||||||
|
guests: bookingResponses.guests,
|
||||||
|
location: databaseBooking.location,
|
||||||
|
// note(Lauris): meetingUrl is deprecated
|
||||||
|
meetingUrl: databaseBooking.location,
|
||||||
|
absentHost: !!databaseBooking.noShowHost,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
|
// note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually
|
||||||
|
bookingTransformed.bookingFieldsResponses = bookingResponses;
|
||||||
|
bookingTransformed.metadata = metadata;
|
||||||
|
return bookingTransformed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOutputRecurringBookings(bookingsIds: number[]) {
|
||||||
|
const transformed = [];
|
||||||
|
|
||||||
|
for (const bookingId of bookingsIds) {
|
||||||
|
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(bookingId);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with id=${bookingId} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
transformed.push(this.getOutputRecurringBooking(databaseBooking));
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputRecurringBooking(databaseBooking: DatabaseBooking) {
|
||||||
|
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||||
|
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||||
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
|
|
||||||
|
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
|
||||||
|
const metadata = bookingUserMetadataSchema.parse(databaseBooking.metadata);
|
||||||
|
|
||||||
|
const booking = {
|
||||||
|
id: databaseBooking.id,
|
||||||
|
uid: databaseBooking.uid,
|
||||||
|
title: databaseBooking.title,
|
||||||
|
description: databaseBooking.description,
|
||||||
|
hosts: [databaseBooking.user],
|
||||||
|
status: databaseBooking.status.toLowerCase(),
|
||||||
|
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||||
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
|
start: databaseBooking.startTime,
|
||||||
|
end: databaseBooking.endTime,
|
||||||
|
duration,
|
||||||
|
eventType: databaseBooking.eventType,
|
||||||
|
// note(Lauris): eventTypeId is deprecated
|
||||||
|
eventTypeId: databaseBooking.eventTypeId,
|
||||||
|
attendees: databaseBooking.attendees.map((attendee) => ({
|
||||||
|
name: attendee.name,
|
||||||
|
email: attendee.email,
|
||||||
|
timeZone: attendee.timeZone,
|
||||||
|
language: attendee.locale,
|
||||||
|
absent: !!attendee.noShow,
|
||||||
|
})),
|
||||||
|
guests: bookingResponses.guests,
|
||||||
|
location: databaseBooking.location,
|
||||||
|
// note(Lauris): meetingUrl is deprecated
|
||||||
|
meetingUrl: databaseBooking.location,
|
||||||
|
recurringBookingUid: databaseBooking.recurringEventId,
|
||||||
|
absentHost: !!databaseBooking.noShowHost,
|
||||||
|
bookingFieldsResponses: databaseBooking.responses,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, {
|
||||||
|
strategy: "excludeAll",
|
||||||
|
});
|
||||||
|
// note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually
|
||||||
|
bookingTransformed.bookingFieldsResponses = bookingResponses;
|
||||||
|
bookingTransformed.metadata = metadata;
|
||||||
|
return bookingTransformed;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputCreateSeatedBooking(
|
||||||
|
databaseBooking: DatabaseBooking,
|
||||||
|
seatUid: string
|
||||||
|
): CreateSeatedBookingOutput_2024_08_13 {
|
||||||
|
const getSeatedBookingOutput = this.getOutputSeatedBooking(databaseBooking);
|
||||||
|
return { ...getSeatedBookingOutput, seatUid };
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
|
||||||
|
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||||
|
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||||
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
|
|
||||||
|
const booking = {
|
||||||
|
id: databaseBooking.id,
|
||||||
|
uid: databaseBooking.uid,
|
||||||
|
title: databaseBooking.title,
|
||||||
|
description: databaseBooking.description,
|
||||||
|
hosts: [databaseBooking.user],
|
||||||
|
status: databaseBooking.status.toLowerCase(),
|
||||||
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
|
start: databaseBooking.startTime,
|
||||||
|
end: databaseBooking.endTime,
|
||||||
|
duration,
|
||||||
|
eventType: databaseBooking.eventType,
|
||||||
|
// note(Lauris): eventTypeId is deprecated
|
||||||
|
eventTypeId: databaseBooking.eventTypeId,
|
||||||
|
attendees: [],
|
||||||
|
location: databaseBooking.location,
|
||||||
|
// note(Lauris): meetingUrl is deprecated
|
||||||
|
meetingUrl: databaseBooking.location,
|
||||||
|
absentHost: !!databaseBooking.noShowHost,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
|
|
||||||
|
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
|
||||||
|
parsed.attendees = databaseBooking.attendees.map((attendee) => {
|
||||||
|
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
|
||||||
|
|
||||||
|
const attendeeData = {
|
||||||
|
name: attendee.name,
|
||||||
|
email: attendee.email,
|
||||||
|
timeZone: attendee.timeZone,
|
||||||
|
language: attendee.locale,
|
||||||
|
absent: !!attendee.noShow,
|
||||||
|
seatUid: attendee.bookingSeat?.referenceUid,
|
||||||
|
bookingFieldsResponses: {},
|
||||||
|
};
|
||||||
|
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||||
|
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||||
|
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
|
||||||
|
// note(Lauris): as of now email is not returned for privacy
|
||||||
|
delete attendeeParsed.bookingFieldsResponses.email;
|
||||||
|
|
||||||
|
return attendeeParsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOutputRecurringSeatedBookings(bookingsIds: number[]) {
|
||||||
|
const transformed = [];
|
||||||
|
|
||||||
|
for (const bookingId of bookingsIds) {
|
||||||
|
const databaseBooking =
|
||||||
|
await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(bookingId);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with id=${bookingId} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking));
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOutputCreateRecurringSeatedBookings(bookings: { uid: string; seatUid: string }[]) {
|
||||||
|
const transformed = [];
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const databaseBooking =
|
||||||
|
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||||
|
}
|
||||||
|
transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid));
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputCreateRecurringSeatedBooking(
|
||||||
|
databaseBooking: DatabaseBooking,
|
||||||
|
seatUid: string
|
||||||
|
): CreateRecurringSeatedBookingOutput_2024_08_13 {
|
||||||
|
const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(databaseBooking);
|
||||||
|
return { ...getRecurringSeatedBookingOutput, seatUid };
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking) {
|
||||||
|
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||||
|
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||||
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
|
|
||||||
|
const booking = {
|
||||||
|
id: databaseBooking.id,
|
||||||
|
uid: databaseBooking.uid,
|
||||||
|
title: databaseBooking.title,
|
||||||
|
description: databaseBooking.description,
|
||||||
|
hosts: [databaseBooking.user],
|
||||||
|
status: databaseBooking.status.toLowerCase(),
|
||||||
|
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||||
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
|
start: databaseBooking.startTime,
|
||||||
|
end: databaseBooking.endTime,
|
||||||
|
duration,
|
||||||
|
eventType: databaseBooking.eventType,
|
||||||
|
// note(Lauris): eventTypeId is deprecated
|
||||||
|
eventTypeId: databaseBooking.eventTypeId,
|
||||||
|
attendees: [],
|
||||||
|
location: databaseBooking.location,
|
||||||
|
// note(Lauris): meetingUrl is deprecated
|
||||||
|
meetingUrl: databaseBooking.location,
|
||||||
|
recurringBookingUid: databaseBooking.recurringEventId,
|
||||||
|
absentHost: !!databaseBooking.noShowHost,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
|
||||||
|
strategy: "excludeAll",
|
||||||
|
});
|
||||||
|
|
||||||
|
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
|
||||||
|
parsed.attendees = databaseBooking.attendees.map((attendee) => {
|
||||||
|
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
|
||||||
|
|
||||||
|
const attendeeData = {
|
||||||
|
name: attendee.name,
|
||||||
|
email: attendee.email,
|
||||||
|
timeZone: attendee.timeZone,
|
||||||
|
language: attendee.locale,
|
||||||
|
absent: !!attendee.noShow,
|
||||||
|
seatUid: attendee.bookingSeat?.referenceUid,
|
||||||
|
bookingFieldsResponses: {},
|
||||||
|
};
|
||||||
|
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||||
|
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||||
|
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
|
||||||
|
// note(Lauris): as of now email is not returned for privacy
|
||||||
|
delete attendeeParsed.bookingFieldsResponses.email;
|
||||||
|
return attendeeParsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutputReassignedBooking(
|
||||||
|
databaseBooking: Pick<BookingWithUser, "uid" | "user">
|
||||||
|
): ReassignBookingOutput_2024_08_13 {
|
||||||
|
return {
|
||||||
|
bookingUid: databaseBooking.uid,
|
||||||
|
reassignedTo: {
|
||||||
|
id: databaseBooking?.user?.id || 0,
|
||||||
|
name: databaseBooking?.user?.name || "unknown",
|
||||||
|
email: databaseBooking?.user?.email || "unknown",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import { ApiResponse } from "@calcom/platform-types";
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
@@ -12,6 +13,16 @@ export interface CredentialSyncCalendarApp {
|
|||||||
check(userId: number): Promise<ApiResponse>;
|
check(userId: number): Promise<ApiResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ICSFeedCalendarApp {
|
||||||
|
save(
|
||||||
|
userId: number,
|
||||||
|
userEmail: string,
|
||||||
|
urls: string[],
|
||||||
|
readonly?: boolean
|
||||||
|
): Promise<CreateIcsFeedOutputResponseDto>;
|
||||||
|
check(userId: number): Promise<ApiResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OAuthCalendarApp extends CalendarApp {
|
export interface OAuthCalendarApp extends CalendarApp {
|
||||||
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
|
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import { CalendarsController } from "@/ee/calendars/controllers/calendars.contro
|
|||||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||||
|
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
||||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
@@ -20,6 +21,7 @@ import { Module } from "@nestjs/common";
|
|||||||
OutlookService,
|
OutlookService,
|
||||||
GoogleCalendarService,
|
GoogleCalendarService,
|
||||||
AppleCalendarService,
|
AppleCalendarService,
|
||||||
|
IcsFeedService,
|
||||||
SelectedCalendarsRepository,
|
SelectedCalendarsRepository,
|
||||||
AppsRepository,
|
AppsRepository,
|
||||||
CalendarsRepository,
|
CalendarsRepository,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CreateIcsFeedOutput, CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||||
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||||
@@ -18,6 +19,7 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
|
|||||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||||
|
import { IcsCalendarServiceMock } from "test/mocks/ics-calendar-service-mock";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +29,8 @@ import {
|
|||||||
GOOGLE_CALENDAR_ID,
|
GOOGLE_CALENDAR_ID,
|
||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||||
|
import { ICS_CALENDAR } from "@calcom/platform-constants/apps";
|
||||||
|
import { IcsFeedCalendarService } from "@calcom/platform-libraries";
|
||||||
|
|
||||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
let googleCalendarCredentials: Credential;
|
let googleCalendarCredentials: Credential;
|
||||||
let accessTokenSecret: string;
|
let accessTokenSecret: string;
|
||||||
let refreshTokenSecret: string;
|
let refreshTokenSecret: string;
|
||||||
|
let icsCalendarCredentials: CreateIcsFeedOutput;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
@@ -192,6 +197,52 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should fail to create a new ics feed calendar credentials with invalid urls`, async () => {
|
||||||
|
const body = {
|
||||||
|
urls: ["https://cal.com/ics/feed.ics", "https://not-an-ics-feed.com"],
|
||||||
|
readOnly: false,
|
||||||
|
};
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/calendars/${ICS_CALENDAR}/save`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
.send(body)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should create a new ics feed calendar credentials`, async () => {
|
||||||
|
const body = {
|
||||||
|
urls: ["https://cal.com/ics/feed.ics"],
|
||||||
|
readOnly: false,
|
||||||
|
};
|
||||||
|
jest
|
||||||
|
.spyOn(IcsFeedCalendarService.prototype, "listCalendars")
|
||||||
|
.mockImplementation(IcsCalendarServiceMock.prototype.listCalendars);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/calendars/${ICS_CALENDAR}/save`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
.send(body)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: CreateIcsFeedOutputResponseDto = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.userId).toBeDefined();
|
||||||
|
expect(responseBody.data.userId).toEqual(user.id);
|
||||||
|
expect(responseBody.data.id).toBeDefined();
|
||||||
|
icsCalendarCredentials = responseBody.data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/${ICS_CALENDAR}/check with access token`, async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/v2/calendars/${ICS_CALENDAR}/check`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
|
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
|
||||||
const body = {
|
const body = {
|
||||||
id: 10,
|
id: 10,
|
||||||
@@ -217,6 +268,7 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
await teamRepositoryFixture.delete(organization.id);
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
await credentialsRepositoryFixture.delete(office365Credentials.id);
|
await credentialsRepositoryFixture.delete(office365Credentials.id);
|
||||||
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
|
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
|
||||||
|
await credentialsRepositoryFixture.delete(icsCalendarCredentials.id);
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
|
import { CreateIcsFeedInputDto } from "@/ee/calendars/input/create-ics.input";
|
||||||
|
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||||
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
|
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
|
||||||
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
||||||
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||||
|
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
||||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
@@ -31,7 +34,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -44,6 +47,7 @@ import {
|
|||||||
GOOGLE_CALENDAR,
|
GOOGLE_CALENDAR,
|
||||||
OFFICE_365_CALENDAR,
|
OFFICE_365_CALENDAR,
|
||||||
APPLE_CALENDAR,
|
APPLE_CALENDAR,
|
||||||
|
CREDENTIAL_CALENDARS,
|
||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
@@ -58,11 +62,31 @@ export class CalendarsController {
|
|||||||
private readonly outlookService: OutlookService,
|
private readonly outlookService: OutlookService,
|
||||||
private readonly googleCalendarService: GoogleCalendarService,
|
private readonly googleCalendarService: GoogleCalendarService,
|
||||||
private readonly appleCalendarService: AppleCalendarService,
|
private readonly appleCalendarService: AppleCalendarService,
|
||||||
|
private readonly icsFeedService: IcsFeedService,
|
||||||
private readonly calendarsRepository: CalendarsRepository
|
private readonly calendarsRepository: CalendarsRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Post("/ics-feed/save")
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@ApiOperation({ summary: "Save an ICS feed" })
|
||||||
|
async createIcsFeed(
|
||||||
|
@GetUser("id") userId: number,
|
||||||
|
@GetUser("email") userEmail: string,
|
||||||
|
@Body() body: CreateIcsFeedInputDto
|
||||||
|
): Promise<CreateIcsFeedOutputResponseDto> {
|
||||||
|
return await this.icsFeedService.save(userId, userEmail, body.urls, body.readOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/ics-feed/check")
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@ApiOperation({ summary: "Check an ICS feed" })
|
||||||
|
async checkIcsFeed(@GetUser("id") userId: number): Promise<ApiResponse> {
|
||||||
|
return await this.icsFeedService.check(userId);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@Get("/busy-times")
|
@Get("/busy-times")
|
||||||
|
@ApiOperation({ summary: "Get busy times" })
|
||||||
async getBusyTimes(
|
async getBusyTimes(
|
||||||
@Query() queryParams: CalendarBusyTimesInput,
|
@Query() queryParams: CalendarBusyTimesInput,
|
||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
@@ -91,6 +115,7 @@ export class CalendarsController {
|
|||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@ApiOperation({ summary: "Get all calendars" })
|
||||||
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
|
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
|
||||||
const calendars = await this.calendarsService.getCalendars(userId);
|
const calendars = await this.calendarsService.getCalendars(userId);
|
||||||
|
|
||||||
@@ -103,6 +128,7 @@ export class CalendarsController {
|
|||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@Get("/:calendar/connect")
|
@Get("/:calendar/connect")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Get connect URL" })
|
||||||
async redirect(
|
async redirect(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Headers("Authorization") authorization: string,
|
@Headers("Authorization") authorization: string,
|
||||||
@@ -125,6 +151,7 @@ export class CalendarsController {
|
|||||||
@Get("/:calendar/save")
|
@Get("/:calendar/save")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Redirect(undefined, 301)
|
@Redirect(undefined, 301)
|
||||||
|
@ApiOperation({ summary: "Save a calendar" })
|
||||||
async save(
|
async save(
|
||||||
@Query("state") state: string,
|
@Query("state") state: string,
|
||||||
@Query("code") code: string,
|
@Query("code") code: string,
|
||||||
@@ -154,6 +181,7 @@ export class CalendarsController {
|
|||||||
|
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@Post("/:calendar/credentials")
|
@Post("/:calendar/credentials")
|
||||||
|
@ApiOperation({ summary: "Sync credentials" })
|
||||||
async syncCredentials(
|
async syncCredentials(
|
||||||
@GetUser() user: User,
|
@GetUser() user: User,
|
||||||
@Param("calendar") calendar: string,
|
@Param("calendar") calendar: string,
|
||||||
@@ -167,7 +195,7 @@ export class CalendarsController {
|
|||||||
default:
|
default:
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Invalid calendar type, available calendars are: ",
|
"Invalid calendar type, available calendars are: ",
|
||||||
CALENDARS.join(", ")
|
CREDENTIAL_CALENDARS.join(", ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +204,7 @@ export class CalendarsController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||||
@Permissions([APPS_READ])
|
@Permissions([APPS_READ])
|
||||||
|
@ApiOperation({ summary: "Check a calendar connection" })
|
||||||
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
|
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
|
||||||
switch (calendar) {
|
switch (calendar) {
|
||||||
case OFFICE_365_CALENDAR:
|
case OFFICE_365_CALENDAR:
|
||||||
@@ -195,6 +224,7 @@ export class CalendarsController {
|
|||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@Post("/:calendar/disconnect")
|
@Post("/:calendar/disconnect")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Disconnect a calendar" })
|
||||||
async deleteCalendarCredentials(
|
async deleteCalendarCredentials(
|
||||||
@Param("calendar") calendar: string,
|
@Param("calendar") calendar: string,
|
||||||
@Body() body: DeleteCalendarCredentialsInputBodyDto,
|
@Body() body: DeleteCalendarCredentialsInputBodyDto,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsBoolean,
|
||||||
|
IsOptional,
|
||||||
|
Validate,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from "class-validator";
|
||||||
|
import { IsNotEmpty, IsArray } from "class-validator";
|
||||||
|
|
||||||
|
// Custom constraint to validate ICS URLs
|
||||||
|
@ValidatorConstraint({ async: false })
|
||||||
|
export class IsICSUrlConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(url: unknown) {
|
||||||
|
if (typeof url !== "string") return false;
|
||||||
|
|
||||||
|
// Check if it's a valid URL and ends with .ics
|
||||||
|
try {
|
||||||
|
const urlObject = new URL(url);
|
||||||
|
return (
|
||||||
|
(urlObject.protocol === "http:" || urlObject.protocol === "https:") &&
|
||||||
|
urlObject.pathname.endsWith(".ics")
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage() {
|
||||||
|
return "The URL must be a valid ICS URL (ending with .ics)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateIcsFeedInputDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: ["https://cal.com/ics/feed.ics", "http://cal.com/ics/feed.ics"],
|
||||||
|
description: "An array of ICS URLs",
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
example: "https://cal.com/ics/feed.ics",
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsNotEmpty({ each: true })
|
||||||
|
@Validate(IsICSUrlConstraint, { each: true }) // Apply the custom validator to each element in the array
|
||||||
|
urls!: string[];
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@ApiProperty({
|
||||||
|
example: false,
|
||||||
|
description: "Whether to allowing writing to the calendar or not",
|
||||||
|
type: "boolean",
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
readOnly?: boolean = true;
|
||||||
|
}
|
||||||
Loaded 100 of 2552 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user