Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4135c1038e | ||
|
|
fc5190a33b | ||
|
|
f29328e6a2 | ||
|
|
621f7639ff | ||
|
|
63f2f6edba | ||
|
|
199f41fb1a | ||
|
|
3ea9faa414 | ||
|
|
518ec4f605 | ||
|
|
4db0da5da4 | ||
|
|
115ded8429 | ||
|
|
71485d1b57 | ||
|
|
87071c4f19 | ||
|
|
412f15814e | ||
|
|
a57085cf64 | ||
|
|
65d1a34e39 | ||
|
|
e4abc85382 | ||
|
|
eb1eb722f2 | ||
|
|
033bc45dd1 | ||
|
|
affe85d02c | ||
|
|
d6f9a833bd | ||
|
|
e4281a852a | ||
|
|
e4b2464548 | ||
|
|
8c99a2a7af | ||
|
|
2236dfe411 | ||
|
|
a67369460a | ||
|
|
b2526300a7 | ||
|
|
d69395434f | ||
|
|
2720c9cc67 | ||
|
|
f1465a15f6 | ||
|
|
1d24f2654d | ||
|
|
f7eb86577c | ||
|
|
cc515f00b7 | ||
|
|
e695f75113 | ||
|
|
0d1b6ea5b2 | ||
|
|
26637cda08 | ||
|
|
529054aa76 | ||
|
|
8deee738c2 | ||
|
|
6848362683 | ||
|
|
09fc7e1a4c | ||
|
|
2f73be0ee6 | ||
|
|
3cdf59f2cc | ||
|
|
1f9880204e | ||
|
|
d0be48ebde | ||
|
|
5c8abbb3e9 | ||
|
|
7bb42ba577 | ||
|
|
2e3b3257db | ||
|
|
d28e20e92e | ||
|
|
8b89d6635a | ||
|
|
13ec810cb9 | ||
|
|
367c8a96d8 | ||
|
|
e94c030c45 | ||
|
|
6d0c7e4214 | ||
|
|
e11cb9e3a5 | ||
|
|
809abd2f4f | ||
|
|
d1d50b0d91 | ||
|
|
fc716f5921 | ||
|
|
5348464972 | ||
|
|
3fac6e698e | ||
|
|
dcafbf672a | ||
|
|
dff89a4df8 | ||
|
|
ed04062540 | ||
|
|
23fda4a9e6 | ||
|
|
14c3028c2a | ||
|
|
dfe49e1732 | ||
|
|
6148c6baea | ||
|
|
5366d891bb | ||
|
|
2ee021a87f | ||
|
|
dd0c2dbff2 | ||
|
|
f044c2d7c7 | ||
|
|
703bcd861e | ||
|
|
d4d96eb5cd | ||
|
|
d8356e9a5b | ||
|
|
9348279818 | ||
|
|
5d77e4ce1d | ||
|
|
968e782dbe | ||
|
|
01fb3dd03b | ||
|
|
c3537f3290 | ||
|
|
3bb42f276e | ||
|
|
51fd4102ae | ||
|
|
9d1ef0a649 | ||
|
|
f80dc0738a | ||
|
|
678ab3f453 | ||
|
|
199d3e4c3f | ||
|
|
79a6aef0e7 | ||
|
|
4d49fb0636 | ||
|
|
0be1387d0f | ||
|
|
58ab278813 | ||
|
|
4de142cb7c | ||
|
|
b4d27a9326 | ||
|
|
31f3d9778e | ||
|
|
0a59c95b93 | ||
|
|
9e3465eeb6 | ||
|
|
31fc4724e0 | ||
|
|
f81f0a26ec | ||
|
|
9a80bb6194 | ||
|
|
901fc36c97 | ||
|
|
2831fb2b57 | ||
|
|
426d31712e | ||
|
|
09ecd445bb | ||
|
|
08d65c85de | ||
|
|
b9cef10ef2 | ||
|
|
0dc41592f2 | ||
|
|
aabf3c54ea | ||
|
|
c2a57fd72b | ||
|
|
52386e08f2 | ||
|
|
b724d367fc | ||
|
|
07924751ad | ||
|
|
defa8df7ca | ||
|
|
bf8580fa88 | ||
|
|
c7b1e4dfa1 | ||
|
|
139a7c8249 | ||
|
|
158da51a5d | ||
|
|
f9ad99e572 | ||
|
|
1c65f5c150 | ||
|
|
0fb75b715d | ||
|
|
9364055283 | ||
|
|
1929b23ea8 | ||
|
|
efc7be0b6b | ||
|
|
327159c2ae | ||
|
|
af801df421 | ||
|
|
79c1aa60a2 | ||
|
|
a9535d3fd4 | ||
|
|
0ae6506bc1 | ||
|
|
a8c03262c2 | ||
|
|
687669ce17 | ||
|
|
bf6dd665f0 | ||
|
|
9250b91bb0 | ||
|
|
b934c74c30 | ||
|
|
96810b5ba1 | ||
|
|
ee08118ed3 | ||
|
|
051353e7f1 | ||
|
|
19f52429b0 | ||
|
|
4ed15d2755 | ||
|
|
154af1367a | ||
|
|
1de60bcfeb | ||
|
|
3679854c43 | ||
|
|
ce64c494f4 | ||
|
|
df4aa24913 | ||
|
|
aa54c013f8 | ||
|
|
0014ca6865 | ||
|
|
6c00c9b2b8 | ||
|
|
46fc67f70d | ||
|
|
e91fe12219 | ||
|
|
2a8f7412dd | ||
|
|
64d634e406 | ||
|
|
39cfe18ffe | ||
|
|
eac45c5e23 | ||
|
|
446c2b0f0e | ||
|
|
92e5aae901 | ||
|
|
bf3db721e2 | ||
|
|
8d4561c866 | ||
|
|
6627a211d7 | ||
|
|
0763a64b30 | ||
|
|
d333a31221 | ||
|
|
55a8a0d2d3 | ||
|
|
3093216534 | ||
|
|
99a1c36ffc | ||
|
|
f4e48f5fc1 | ||
|
|
c352dc647e | ||
|
|
34bb069b4a | ||
|
|
39ea9c112d | ||
|
|
e32d4648af | ||
|
|
20b7633ab5 | ||
|
|
6f017a7972 | ||
|
|
cbd0e2d287 | ||
|
|
ff739bf9be | ||
|
|
ff3541910b | ||
|
|
d043de7724 | ||
|
|
be1517facd | ||
|
|
19eced00f5 | ||
|
|
e2414b174a | ||
|
|
5e3c0cdea1 | ||
|
|
6b6d3d90e4 | ||
|
|
9b348adb6a | ||
|
|
feda420f0c | ||
|
|
4b818de0c8 | ||
|
|
614741d207 | ||
|
|
2550485c49 | ||
|
|
efc3e864bb | ||
|
|
1bf56fbe93 | ||
|
|
629629cb9e | ||
|
|
8c0751b186 | ||
|
|
0b46f61a23 | ||
|
|
0c92fbe11d | ||
|
|
59fa713549 | ||
|
|
63416d4f33 | ||
|
|
9e927af813 | ||
|
|
4b8bdeba74 | ||
|
|
4e4d67c8c0 | ||
|
|
ffda234b3c | ||
|
|
0bc44c36db | ||
|
|
f18bee5c3d | ||
|
|
73e16215bd | ||
|
|
75d7d2f172 | ||
|
|
269dca5b6d | ||
|
|
0fd6bed813 | ||
|
|
33bef6acd0 | ||
|
|
a6c4b31845 | ||
|
|
ea8ba8defc | ||
|
|
94cb78491a | ||
|
|
d12a5c5883 | ||
|
|
7a014761dc | ||
|
|
91ac952a72 | ||
|
|
225055fb0c | ||
|
|
d46e80c2ac | ||
|
|
fe364bd2da | ||
|
|
2756dff735 | ||
|
|
401f64b986 | ||
|
|
a02dcf485f | ||
|
|
f2ecd9818a | ||
|
|
bc81f659aa | ||
|
|
f8f038c5e9 | ||
|
|
fefb6acc57 | ||
|
|
461120ad84 | ||
|
|
bab72a5d2e | ||
|
|
e4011b4a23 | ||
|
|
899b59620d | ||
|
|
c75b5bec07 | ||
|
|
f0c10ba5ec | ||
|
|
7f4b95123f | ||
|
|
6238c625ae | ||
|
|
dbe387890f | ||
|
|
25517ad674 | ||
|
|
3047b5319b | ||
|
|
a5fa2ef8d0 | ||
|
|
59faffe0d5 | ||
|
|
b076a7dabc | ||
|
|
cbab279c3b | ||
|
|
61be5c9bc1 | ||
|
|
0a4f7da2df | ||
|
|
a87d82fd47 | ||
|
|
208d040f6f | ||
|
|
8ebcfbb8d1 | ||
|
|
20803451de | ||
|
|
2c5cb6abe4 | ||
|
|
522fd64f69 | ||
|
|
aaa6616777 | ||
|
|
7bc3591080 | ||
|
|
db059d84c3 | ||
|
|
e3a9e61046 | ||
|
|
03103e1efd | ||
|
|
08b72e2b90 | ||
|
|
ed8a14fdd7 | ||
|
|
0e50f6d9f8 | ||
|
|
833fe7c7b7 | ||
|
|
47bb3000f6 | ||
|
|
41dd7bbdde | ||
|
|
dc9a4f0dba | ||
|
|
891a656449 | ||
|
|
916ee66be2 | ||
|
|
9d7d838131 | ||
|
|
df29376b0e | ||
|
|
d72a1ee328 | ||
|
|
ce532e0ff6 | ||
|
|
f824490873 | ||
|
|
ebc70feef0 | ||
|
|
226ac889c7 | ||
|
|
4f942e1a13 | ||
|
|
38f389bce9 | ||
|
|
39c3ef0d94 | ||
|
|
0f92afb4bd | ||
|
|
6d0bcb709d | ||
|
|
ff3a9d03a4 | ||
|
|
303073822c | ||
|
|
0cfbf7159e | ||
|
|
150dc3acb9 | ||
|
|
778485b31d | ||
|
|
b4c6388ce0 | ||
|
|
f9af15175d | ||
|
|
84eda78c2c | ||
|
|
2faf24fb98 | ||
|
|
1456e2d4d5 | ||
|
|
a53ea33168 | ||
|
|
6c8c3de87c | ||
|
|
b4ab710b35 | ||
|
|
b5cf4e50a9 | ||
|
|
d8f6400add | ||
|
|
018b0e0543 | ||
|
|
a5993061f6 | ||
|
|
0b310c0ea8 | ||
|
|
da62984b5c | ||
|
|
16d73fab89 | ||
|
|
873a4e2000 | ||
|
|
d0d37ba34f | ||
|
|
76d476441f | ||
|
|
9857b46121 | ||
|
|
a35750734c | ||
|
|
5b7224e47e | ||
|
|
f3623096e4 | ||
|
|
88d438f227 | ||
|
|
8a8d284113 | ||
|
|
b4315b186a | ||
|
|
a90848edcb | ||
|
|
49b0f48be0 | ||
|
|
ceb8906c27 | ||
|
|
f9c578c87d | ||
|
|
c105793486 | ||
|
|
59b503eb7e | ||
|
|
b6a28b1950 | ||
|
|
b6ea551580 | ||
|
|
c5bf75385f | ||
|
|
1298b05e1a | ||
|
|
e0c27d8721 | ||
|
|
666428ba53 | ||
|
|
ead11ef009 | ||
|
|
e7543c10a9 | ||
|
|
bba52db02f | ||
|
|
a86d4ec89d | ||
|
|
d415944d5b | ||
|
|
98c78ba7e1 | ||
|
|
c554779490 | ||
|
|
53655a43eb | ||
|
|
38d1b92226 | ||
|
|
3e08c66888 | ||
|
|
a186b130cb | ||
|
|
bb052a24bc | ||
|
|
a1b6b7ad66 | ||
|
|
35181e4bfd | ||
|
|
c2e3638993 | ||
|
|
3ca7456885 | ||
|
|
07bfaec24e | ||
|
|
032435fbd4 | ||
|
|
caf1b4c5d7 | ||
|
|
20898e1505 | ||
|
|
0bb99fc667 | ||
|
|
e5b88bd0b0 | ||
|
|
4a81b59fc9 | ||
|
|
e1f47ef40e | ||
|
|
e568d70e65 | ||
|
|
41b6377f29 | ||
|
|
685be1663e | ||
|
|
85a1713897 | ||
|
|
c22d406d12 | ||
|
|
cf43d9dcfc | ||
|
|
30a23cedbc | ||
|
|
6e2a96d69c | ||
|
|
490e0a9bb4 | ||
|
|
31bc586ee8 | ||
|
+15 |
68bd877c5b | ||
|
|
b4f44e9a60 | ||
|
|
9c448e3b87 | ||
|
|
5deda33712 | ||
|
|
2335948099 | ||
|
|
8bbb38d2be | ||
|
|
a4035aef98 | ||
|
|
c57de8f43f | ||
|
|
2b1918f40a | ||
|
|
fb2c966e6c | ||
|
|
4f3cf4f948 | ||
|
|
4613ac34c9 | ||
|
|
d69ba825fe | ||
|
|
92a221197a | ||
|
|
b54f716fd7 | ||
|
|
57e712dd9e | ||
|
|
2021b641ce | ||
|
|
ef45cbfb3f | ||
|
|
ab17cb216f | ||
|
|
b04c870a3d | ||
|
|
09abb90c8b | ||
|
|
b8e3b549f8 | ||
|
|
403d08de41 | ||
|
|
d7c3132fa5 | ||
|
|
a2df323cf8 | ||
|
|
fe82d88adf | ||
|
|
01e2ba3fd9 | ||
|
|
c9b87bbf85 | ||
|
|
19e6dbb781 | ||
|
|
a3a49f9069 | ||
|
|
468da1f35a | ||
|
|
ec4e2f5996 | ||
|
|
8a413a97b4 | ||
|
|
c275981be6 | ||
|
|
ce13350d5d | ||
|
|
919066222b | ||
|
|
79b60adf9b | ||
|
|
1d882e6b3b | ||
|
|
7f7841fabc | ||
|
|
260064c836 | ||
|
|
8c8be1efa3 | ||
|
|
9084cc6677 | ||
|
|
fda3192d53 | ||
|
|
4acfb4b28c | ||
|
|
a0642598ab | ||
|
|
20282a16f7 | ||
|
|
69120095e4 | ||
|
|
bfc137c349 | ||
|
|
13caf6f2cd | ||
|
|
1d4eeab33e | ||
|
|
3bee2d705f | ||
|
|
9c621bf726 | ||
|
|
70461b2718 | ||
|
|
efb04d04ab | ||
|
|
538e3bf07c | ||
|
|
798707a553 | ||
|
|
5940f9bccd | ||
|
|
5e449670ea | ||
|
|
82d7a3db92 | ||
|
|
0b7e3510a5 | ||
|
|
e0c87cf664 | ||
|
|
0d653afd0c | ||
|
|
f414428dfe | ||
|
|
2a171e9284 | ||
|
|
faf974ca4c | ||
|
|
a78a7a1fa0 | ||
|
|
5943763726 | ||
|
|
2648e2770f | ||
|
|
4e7ab391ff | ||
|
|
578495de87 | ||
|
|
86b3a33ff6 | ||
|
|
94476565ad | ||
|
|
3b50fe075d | ||
|
|
96263b0cf7 | ||
|
|
3a3d8db489 | ||
|
|
de04b1a561 | ||
|
|
ca946a9822 | ||
|
|
a5c05bc550 | ||
|
|
fd02cc44fa | ||
|
|
5ee962948f | ||
|
|
c350f099f7 | ||
|
|
07860c238a | ||
|
|
2565d0915a | ||
|
|
3fdc41968f | ||
|
|
37f01db0da | ||
|
|
560ec244e8 | ||
|
|
21a0b67b5a | ||
|
|
824145b0e2 | ||
|
|
bc89fe00ea | ||
|
|
64850b54f0 | ||
|
|
405a4cc850 | ||
|
|
eccab37607 | ||
|
|
786c1c2ba3 | ||
|
|
50970dc249 | ||
|
|
45cded6b48 | ||
|
|
b139ec8994 | ||
|
|
d058d322bc | ||
|
|
72907e5e1b | ||
|
|
d9c3c184c4 | ||
|
|
b1fff71c91 | ||
|
|
56b1243e59 | ||
|
|
a3413216f6 | ||
|
|
fd507f1d12 | ||
|
|
c820a3937a | ||
|
|
5c24ed2ef7 | ||
|
|
8d9d7bbb25 | ||
|
|
837bf5d604 | ||
|
|
c9c634cceb | ||
|
|
8ba09ce04d | ||
|
|
65d4a4f40e | ||
|
|
581d8ff4ae | ||
|
|
c562f45904 | ||
|
|
b413f0a609 | ||
|
|
ffcf9b770d | ||
|
|
ab77531167 | ||
|
|
f59f4ca26c | ||
|
|
ca13eec4c9 | ||
|
|
bf1ff9bd71 | ||
|
|
caefa93e14 | ||
|
|
7154349e9e | ||
|
|
7beb9568ba | ||
|
|
2251d5a4be | ||
|
|
f1bdff9679 | ||
|
|
c433e0c74c | ||
|
|
fb940a1023 | ||
|
|
73366e4152 | ||
|
|
9d7da10744 | ||
|
|
c4f091debf | ||
|
|
29923fe304 | ||
|
|
e8998414fc | ||
|
|
2e58e73e95 | ||
|
|
b7168948ca | ||
|
|
c188a91d64 | ||
|
|
4724b1312c | ||
|
|
d349f6536a | ||
|
|
f35f506ddf | ||
|
|
46d0b67f81 | ||
|
|
7000cf7581 | ||
|
|
1c0faf4eb6 | ||
|
|
6d0f0d6bdd | ||
|
|
cb4a3dd2a6 | ||
|
|
fc623fc511 | ||
|
|
9db0834b50 | ||
|
|
7a3cc7d263 | ||
|
|
f1381d8a82 | ||
|
|
5c136df1bb | ||
|
|
e18f08249b | ||
|
|
5b8b165a3b | ||
|
|
ab39c8f581 | ||
|
|
abc077bfb6 | ||
|
|
bb167e7763 | ||
|
|
3b92ff607a | ||
|
|
129d00a69d | ||
|
|
c86e766aee | ||
|
|
a2873c740b | ||
|
|
6e3a1369f8 | ||
|
|
e44e24ca42 | ||
|
|
768a3132fb | ||
|
|
41812bb280 | ||
|
|
cb1d3487af | ||
|
|
c95917cceb | ||
|
|
9bc40a3eb6 | ||
|
|
bd21b2dfe1 | ||
|
|
d60fb081c9 | ||
|
|
197b435b6f | ||
|
|
add297b09a | ||
|
|
d65f0ee0e8 | ||
|
|
59c0a71db7 | ||
|
|
d50151fe08 | ||
|
|
d2b0dd8eab | ||
|
|
bb0574dff8 | ||
|
|
49364eee9a | ||
|
|
adb0530d69 | ||
|
+18 |
a2d1dbebba | ||
|
|
605e744a58 | ||
|
|
aab0d6b18a | ||
|
|
dfde17111c | ||
|
|
2591ad12df | ||
|
|
3a05ae6f74 | ||
|
|
efcfd7bb9b | ||
|
|
bce62829b7 | ||
|
|
f320e486a2 | ||
|
|
8c96e04b51 | ||
|
|
9eb3698ea5 | ||
|
|
8ff07807f9 | ||
|
|
9c7f655329 | ||
|
|
5c72fca816 | ||
|
|
aeafa3ef0a | ||
|
|
3d4a3c7a93 | ||
|
|
1c1625d3fc | ||
|
|
75f9f045b8 | ||
|
|
86af383f6c | ||
|
|
84408025ed | ||
|
|
9fba8c548b | ||
|
|
56f3647ec2 | ||
|
|
a5cb3ce573 | ||
|
|
7581f4d641 | ||
|
|
a1d67b256e | ||
|
|
75f9dad16e | ||
|
|
0a0d212373 | ||
|
|
b8794ba807 | ||
|
|
9b8e4ed385 | ||
|
|
997b5d5290 | ||
|
|
d7080b41cb | ||
|
|
32754bd120 | ||
|
|
13fd02d174 | ||
|
|
d6cbac54e0 | ||
|
|
489ffdf49a | ||
|
|
37b22977ee | ||
|
|
848a040162 | ||
|
|
350d84a047 | ||
|
|
9c3cbee363 | ||
|
|
7e2ad3cea9 | ||
|
|
4ef52aa2c6 | ||
|
|
3132c64205 | ||
|
|
f25f8e0909 | ||
|
|
d8961f8eb9 | ||
|
|
9ff7cf650b | ||
|
|
ba4e717b59 | ||
|
|
f326a9a620 | ||
|
|
95be16c358 | ||
|
|
5621507f7e | ||
|
|
bc6d58fd47 | ||
|
|
db29cc1251 | ||
|
|
69888c57b0 | ||
|
|
5c0da23b97 | ||
|
|
5324ec8051 | ||
|
|
b946f89ffb | ||
|
|
3e3581fabc | ||
|
|
05ac339233 | ||
|
|
ef4675ff44 | ||
|
|
47dc85ee1b | ||
|
|
50342e33ed | ||
|
|
eebcb636f1 | ||
|
|
ed2ced899a | ||
|
|
b7ae8c9594 | ||
|
|
fce98ebb13 | ||
|
|
b81b221bf5 | ||
|
|
f9eb335d0b | ||
|
|
d7d7bcd651 | ||
|
|
4c861b9e67 | ||
|
|
2fa704c39b | ||
|
|
9ccdf21d75 | ||
|
|
9d6148da42 | ||
|
|
bf437b7d6b | ||
|
|
3701fcd68b | ||
|
|
2f90dc7cb6 | ||
|
|
829f90b82f | ||
|
|
3539693729 | ||
|
|
868ba4396d | ||
|
|
d06e36a5cd | ||
|
|
f61c9d9bbf | ||
|
|
a2d521e001 | ||
|
|
bf9c881d16 | ||
|
|
6dd0f9ce29 | ||
|
|
d34db7aef6 | ||
|
|
b4b90f2d27 | ||
|
|
3f0380617a | ||
|
|
7143923ea4 | ||
|
|
dfccf4604f | ||
|
|
c12abd75a8 | ||
|
|
2e12f09f05 | ||
|
|
4258da1df5 | ||
|
|
41ef354c7b | ||
|
|
8891641953 | ||
|
|
0150041496 | ||
|
|
13f9e97015 | ||
|
|
3c5901edb8 | ||
|
|
d26bab5880 | ||
|
|
1c451a8d51 | ||
|
|
576261bf88 | ||
|
|
7d944af7f6 | ||
|
|
dfe7056c93 | ||
|
|
b27f01449a | ||
|
|
6dd77d0494 | ||
|
|
f73d38b632 | ||
|
|
a7d8f0c84e | ||
|
|
b0164990c0 | ||
|
|
bad72ba7bf | ||
|
|
1b317a98a4 | ||
|
|
6a22d75caa | ||
|
|
a030861423 | ||
|
|
bd9cd00c04 | ||
|
|
6fd34af6f6 | ||
|
|
1a85f336c5 | ||
|
|
cc5096a54a | ||
|
|
eea7059226 | ||
|
|
63f3247b1b | ||
|
|
7a769c38fb | ||
|
|
34c2565c70 | ||
|
|
a130804f18 | ||
|
|
446e851065 | ||
|
|
f06b4bfb9c | ||
|
|
1896efa3a0 | ||
|
|
62c8603863 | ||
|
|
7d8559b0b0 | ||
|
|
7a48bbe553 | ||
|
|
817e95c246 | ||
|
|
4ac6225a35 | ||
|
|
38d9b93dee | ||
|
|
376b38d4ed | ||
|
|
0e181f6d9f | ||
|
|
4dbb692156 | ||
|
|
0068718d24 | ||
|
|
ac817b3542 | ||
|
|
97f2cffd1f | ||
|
|
b6dea9c161 | ||
|
|
57eb4746ad | ||
|
|
eff9c87964 | ||
|
|
e06c07bfec | ||
|
|
332c00dac2 | ||
|
|
e3523db6bb | ||
|
|
9c0f046b2a | ||
|
|
6e1a717ecf | ||
|
|
b2e098c781 | ||
|
|
3010900358 | ||
|
|
9b043f44a0 | ||
|
|
814674fe40 | ||
|
|
acaa79b0b8 | ||
|
|
781a0f4a7c | ||
|
|
b2028870d0 | ||
|
|
8f70be8861 | ||
|
|
53c8f8c04a | ||
|
|
c4e5d168a5 | ||
|
|
500bfb4d82 | ||
|
|
024b92b9ce | ||
|
|
f23b63cc98 | ||
|
|
edc90e3f27 | ||
|
|
05631d093f | ||
|
|
6000ff4b48 | ||
|
|
cd7de98e3f | ||
|
|
3f51858d27 | ||
|
|
2c498ace5c | ||
|
|
595d781d51 | ||
|
|
3c508568da | ||
|
|
3204d0af34 | ||
|
|
25684f9040 | ||
|
|
bc9aeef710 | ||
|
|
393411a47a | ||
|
|
356117feaf | ||
|
|
1bf68d50e0 | ||
|
|
4a35e85756 | ||
|
|
a4f56bf680 | ||
|
|
a308075bc3 | ||
|
|
efa6d464a3 | ||
|
|
811262d7ee | ||
|
|
99a5dcf96a | ||
|
|
79f092673a | ||
|
|
b285f27d00 | ||
|
|
1fa87ae179 | ||
|
|
f6f4b67bf9 | ||
|
|
8c8550e6f8 | ||
|
|
c6246c9408 | ||
|
|
ccc814eb54 | ||
|
|
2ee0ec11d6 | ||
|
|
3223beb9ac | ||
|
|
c1bcfcfa3d | ||
|
|
83aea7d28c | ||
|
|
a810a538e9 | ||
|
|
1cd0d7b5ca | ||
|
|
4540c79580 | ||
|
|
00d01882d4 | ||
|
|
6ce4122528 | ||
|
|
7ddbcfcdcf | ||
|
|
b1b01ed383 | ||
|
|
6f5d9dc41f | ||
|
|
ac190b89dd | ||
|
|
990441295c | ||
|
|
1e4c20073c | ||
|
|
80353d67a0 | ||
|
|
d55b4b051a | ||
|
|
25d90e3e12 | ||
|
|
1727eb5625 | ||
|
|
58f4224c66 | ||
|
|
5d491e0a12 | ||
|
|
1eef5a56c3 | ||
|
|
d6740503be | ||
|
|
9f647ead46 | ||
|
|
f480afcace | ||
|
|
8d73d15b8a | ||
|
|
e8370ef950 | ||
|
|
e859c8dc58 | ||
|
|
b4b38b9bc3 | ||
|
|
d32719ad8b | ||
|
|
99d0816d9f | ||
|
|
95f4b89fe8 | ||
|
|
3e63c2b717 | ||
|
|
42cd56578d | ||
|
|
31d4cb4eb7 | ||
|
|
fde3e8bea9 | ||
|
|
874ad29676 | ||
|
|
5503d9d432 | ||
|
|
43b3d68447 | ||
|
|
e3c747a867 | ||
|
|
e020a2c9a4 | ||
|
|
e6e6f09547 | ||
|
|
68ec946c59 |
@@ -125,4 +125,5 @@ SALESFORCE_CONSUMER_SECRET=""
|
|||||||
ZOHOCRM_CLIENT_ID=""
|
ZOHOCRM_CLIENT_ID=""
|
||||||
ZOHOCRM_CLIENT_SECRET=""
|
ZOHOCRM_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
|
||||||
# *********************************************************************************************************
|
# *********************************************************************************************************
|
||||||
|
|||||||
+40
-2
@@ -87,7 +87,7 @@ CRON_ENABLE_APP_SYNC=false
|
|||||||
|
|
||||||
# Application Key for symmetric encryption and decryption
|
# Application Key for symmetric encryption and decryption
|
||||||
# must be 32 bytes for AES256 encryption algorithm
|
# must be 32 bytes for AES256 encryption algorithm
|
||||||
# You can use: `openssl rand -base64 24` to generate one
|
# You can use: `openssl rand -base64 32` to generate one
|
||||||
CALENDSO_ENCRYPTION_KEY=
|
CALENDSO_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# Intercom Config
|
# Intercom Config
|
||||||
@@ -115,6 +115,10 @@ SENDGRID_API_KEY=
|
|||||||
SENDGRID_EMAIL=
|
SENDGRID_EMAIL=
|
||||||
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
|
||||||
|
|
||||||
|
# Sentry
|
||||||
|
# Used for capturing exceptions and logging messages
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
|
||||||
# Twilio
|
# Twilio
|
||||||
# Used to send SMS reminders in workflows
|
# Used to send SMS reminders in workflows
|
||||||
TWILIO_SID=
|
TWILIO_SID=
|
||||||
@@ -126,7 +130,7 @@ TWILIO_WHATSAPP_PHONE_NUMBER=
|
|||||||
NEXT_PUBLIC_SENDER_ID=
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
TWILIO_VERIFY_SID=
|
TWILIO_VERIFY_SID=
|
||||||
|
|
||||||
# Set it to "1" if you need to run E2E tests locally.
|
# Set it to "1" if you need to run E2E tests locally.
|
||||||
NEXT_PUBLIC_IS_E2E=
|
NEXT_PUBLIC_IS_E2E=
|
||||||
|
|
||||||
# Used for internal billing system
|
# Used for internal billing system
|
||||||
@@ -203,6 +207,8 @@ CSP_POLICY=
|
|||||||
EDGE_CONFIG=
|
EDGE_CONFIG=
|
||||||
|
|
||||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
||||||
|
# Control time intervals on a user's Schedule availability
|
||||||
|
NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL=
|
||||||
|
|
||||||
# - ORGANIZATIONS *******************************************************************************************
|
# - ORGANIZATIONS *******************************************************************************************
|
||||||
# Enable Organizations non-prod domain setup, works in combination with organizations feature flag
|
# Enable Organizations non-prod domain setup, works in combination with organizations feature flag
|
||||||
@@ -213,6 +219,10 @@ NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
|||||||
# use organizations
|
# use organizations
|
||||||
ORGANIZATIONS_ENABLED=
|
ORGANIZATIONS_ENABLED=
|
||||||
|
|
||||||
|
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
|
||||||
|
# existing organizations based on email domain address
|
||||||
|
ORGANIZATIONS_AUTOLINK=
|
||||||
|
|
||||||
# Vercel Config to create subdomains for organizations
|
# Vercel Config to create subdomains for organizations
|
||||||
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
|
# Get it from https://vercel.com/<TEAM_OR_USER_NAME>/<PROJECT_SLUG>/settings
|
||||||
PROJECT_ID_VERCEL=
|
PROJECT_ID_VERCEL=
|
||||||
@@ -225,3 +235,31 @@ AUTH_BEARER_TOKEN_VERCEL=
|
|||||||
# Used for E2E tests on Apple Calendar
|
# Used for E2E tests on Apple Calendar
|
||||||
E2E_TEST_APPLE_CALENDAR_EMAIL=""
|
E2E_TEST_APPLE_CALENDAR_EMAIL=""
|
||||||
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
|
E2E_TEST_APPLE_CALENDAR_PASSWORD=""
|
||||||
|
|
||||||
|
# - APP CREDENTIAL SYNC ***********************************************************************************
|
||||||
|
# Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations
|
||||||
|
# Under settings/admin/apps ensure that all app secrets are set the same as the parent application
|
||||||
|
# You can use: `openssl rand -base64 32` to generate one
|
||||||
|
CALCOM_WEBHOOK_SECRET=""
|
||||||
|
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
|
||||||
|
CALCOM_WEBHOOK_HEADER_NAME="calcom-webhook-secret"
|
||||||
|
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
|
||||||
|
# Key should match on Cal.com and your application
|
||||||
|
# must be 32 bytes for AES256 encryption algorithm
|
||||||
|
# You can use: `openssl rand -base64 24` to generate one
|
||||||
|
CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY=""
|
||||||
|
|
||||||
|
# - OIDC E2E TEST *******************************************************************************************
|
||||||
|
|
||||||
|
# Ensure this ADMIN EMAIL is present in the SAML_ADMINS list
|
||||||
|
E2E_TEST_SAML_ADMIN_EMAIL=
|
||||||
|
E2E_TEST_SAML_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
E2E_TEST_OIDC_CLIENT_ID=
|
||||||
|
E2E_TEST_OIDC_CLIENT_SECRET=
|
||||||
|
E2E_TEST_OIDC_PROVIDER_DOMAIN=
|
||||||
|
|
||||||
|
E2E_TEST_OIDC_USER_EMAIL=
|
||||||
|
E2E_TEST_OIDC_USER_PASSWORD=
|
||||||
|
|
||||||
|
# ***********************************************************************************************************
|
||||||
|
|||||||
@@ -47,3 +47,9 @@ assignees: ""
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
(Share it here.)
|
(Share it here.)
|
||||||
|
|
||||||
|
---
|
||||||
|
##### 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.
|
||||||
|
- 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.
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Fixes # (issue)
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
<!-- Please remove all the irrelevant bullets to your PR -->
|
<!-- Remove bullet points below that don't apply to you -->
|
||||||
|
|
||||||
- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||||
- My code doesn't follow the style guidelines of this project
|
- My code doesn't follow the style guidelines of this project
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
name: Add PRs to project Reviewing PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
add-PR-to-project:
|
|
||||||
name: Add PRs to project Reviewing PRs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/add-to-project@v0.1.0
|
|
||||||
with:
|
|
||||||
project-url: https://github.com/orgs/calcom/projects/11
|
|
||||||
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Cron - monthlyDigestEmail
|
||||||
|
|
||||||
|
on:
|
||||||
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
|
schedule:
|
||||||
|
# Runs on the 28th, 29th, 30th and 31st of every month (see https://crontab.guru)
|
||||||
|
- cron: "59 23 28-31 * *"
|
||||||
|
jobs:
|
||||||
|
cron-monthlyDigestEmail:
|
||||||
|
env:
|
||||||
|
APP_URL: ${{ secrets.APP_URL }}
|
||||||
|
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check if today is the last day of the month
|
||||||
|
id: check-last-day
|
||||||
|
run: |
|
||||||
|
LAST_DAY=$(date -d tomorrow +%d)
|
||||||
|
if [ "$LAST_DAY" == "01" ]; then
|
||||||
|
echo "::set-output name=is_last_day::true"
|
||||||
|
else
|
||||||
|
echo "::set-output name=is_last_day::false"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: cURL request
|
||||||
|
if: ${{ env.APP_URL && env.CRON_API_KEY && steps.check-last-day.outputs.is_last_day == 'true' }}
|
||||||
|
run: |
|
||||||
|
curl ${{ secrets.APP_URL }}/api/cron/monthlyDigestEmail \
|
||||||
|
-X POST \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
|
--fail
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Cron - webhookTriggers
|
||||||
|
|
||||||
|
on:
|
||||||
|
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||||
|
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||||
|
schedule:
|
||||||
|
# Runs “every 5 minutes” (see https://crontab.guru)
|
||||||
|
- cron: "*/5 * * * *"
|
||||||
|
jobs:
|
||||||
|
cron-webhookTriggers:
|
||||||
|
env:
|
||||||
|
APP_URL: ${{ secrets.APP_URL }}
|
||||||
|
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: cURL request
|
||||||
|
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||||
|
run: |
|
||||||
|
curl ${{ secrets.APP_URL }}/api/cron/webhookTriggers \
|
||||||
|
-X POST \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||||
|
--fail
|
||||||
@@ -19,12 +19,20 @@ jobs:
|
|||||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@1.5.1
|
uses: crowdin/github-action@v1.13.0
|
||||||
with:
|
with:
|
||||||
|
# upload sources
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
|
|
||||||
|
# upload translations (& auto-approve 'em)
|
||||||
|
upload_translations_args: '--auto-approve-imported'
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
download_translations: true
|
|
||||||
push_translations: true
|
push_translations: true
|
||||||
|
|
||||||
|
# download translations
|
||||||
|
download_translations: true
|
||||||
|
|
||||||
|
# GH config
|
||||||
commit_message: "New Crowdin translations by Github Action"
|
commit_message: "New Crowdin translations by Github Action"
|
||||||
localization_branch_name: main
|
localization_branch_name: main
|
||||||
create_pull_request: false
|
create_pull_request: false
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
repo-token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
organization-name: calcom
|
organization-name: calcom
|
||||||
ignore-labels: "app-store, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
ignore-labels: "app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||||
|
|||||||
@@ -44,4 +44,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
header: pr-title-lint-error
|
header: pr-title-lint-error
|
||||||
message: |
|
message: |
|
||||||
Thank you for following the naming conventions! 🙏
|
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link to [collect XP and win prizes!](https://cal.com/blog/community-incentives)
|
||||||
|
|
||||||
|
|||||||
@@ -15,3 +15,5 @@ jobs:
|
|||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
|
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
|
||||||
- run: yarn test
|
- run: yarn test
|
||||||
|
# We could add different timezones here that we need to run our tests in
|
||||||
|
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
name: "Welcome new contributors"
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: opened
|
|
||||||
pull_request:
|
|
||||||
types: opened
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
welcome-message:
|
|
||||||
name: Welcoming New Users
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
if: github.event.action == 'opened'
|
|
||||||
steps:
|
|
||||||
- uses: actions/first-interaction@v1
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
pr-message: |-
|
|
||||||
Thank you for making your first Pull Request and taking the time to improve Cal.com ! ❤️🎉
|
|
||||||
Feel free to join the conversation at [discord](https://go.cal.com/discord)
|
|
||||||
issue-message: |
|
|
||||||
Thank you for opening your first issue, one of our team members will review it as soon as it possible. ❤️🎉
|
|
||||||
+3
-1
@@ -5,7 +5,9 @@ tasks:
|
|||||||
next_auth_secret=$(openssl rand -base64 32) &&
|
next_auth_secret=$(openssl rand -base64 32) &&
|
||||||
calendso_encryption_key=$(openssl rand -base64 24) &&
|
calendso_encryption_key=$(openssl rand -base64 24) &&
|
||||||
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
|
sed -i -e "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$next_auth_secret|" \
|
||||||
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" .env
|
-e "s|^CALENDSO_ENCRYPTION_KEY=.*|CALENDSO_ENCRYPTION_KEY=$calendso_encryption_key|" \
|
||||||
|
-e "s|http://localhost:3000|https://localhost:3000|" \
|
||||||
|
-e "s|localhost:3000|3000-$GITPOD_WORKSPACE_ID.$GITPOD_WORKSPACE_CLUSTER_HOST|" .env
|
||||||
command: yarn dx
|
command: yarn dx
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
_
|
|
||||||
@@ -7,7 +7,6 @@ public
|
|||||||
|
|
||||||
*.lock
|
*.lock
|
||||||
*.log
|
*.log
|
||||||
*.test.ts
|
|
||||||
|
|
||||||
.gitignore
|
.gitignore
|
||||||
.npmignore
|
.npmignore
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
diff --git a/index.cjs b/index.cjs
|
||||||
|
index b645707a3549fc298508726e404243499bbed499..f34b0891e99b275a9218e253f303f43d31ef3f73 100644
|
||||||
|
--- a/index.cjs
|
||||||
|
+++ b/index.cjs
|
||||||
|
@@ -13,8 +13,8 @@ function withMetadataArgument(func, _arguments) {
|
||||||
|
// https://github.com/babel/babel/issues/2212#issuecomment-131827986
|
||||||
|
// An alternative approach:
|
||||||
|
// https://www.npmjs.com/package/babel-plugin-add-module-exports
|
||||||
|
-exports = module.exports = min.parsePhoneNumberFromString
|
||||||
|
-exports['default'] = min.parsePhoneNumberFromString
|
||||||
|
+// exports = module.exports = min.parsePhoneNumberFromString
|
||||||
|
+// exports['default'] = min.parsePhoneNumberFromString
|
||||||
|
|
||||||
|
// `parsePhoneNumberFromString()` named export is now considered legacy:
|
||||||
|
// it has been promoted to a default export due to being too verbose.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
diff --git a/dist/commonjs/serverSideTranslations.js b/dist/commonjs/serverSideTranslations.js
|
||||||
|
index bcad3d02fbdfab8dacb1d85efd79e98623a0c257..fff668f598154a13c4030d1b4a90d5d9c18214ad 100644
|
||||||
|
--- a/dist/commonjs/serverSideTranslations.js
|
||||||
|
+++ b/dist/commonjs/serverSideTranslations.js
|
||||||
|
@@ -36,7 +36,6 @@ var _fs = _interopRequireDefault(require("fs"));
|
||||||
|
var _path = _interopRequireDefault(require("path"));
|
||||||
|
var _createConfig = require("./config/createConfig");
|
||||||
|
var _node = _interopRequireDefault(require("./createClient/node"));
|
||||||
|
-var _appWithTranslation = require("./appWithTranslation");
|
||||||
|
var _utils = require("./utils");
|
||||||
|
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
|
||||||
|
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { (0, _defineProperty2["default"])(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
|
||||||
|
@@ -110,12 +109,8 @@ var serverSideTranslations = /*#__PURE__*/function () {
|
||||||
|
lng: initialLocale
|
||||||
|
}));
|
||||||
|
localeExtension = config.localeExtension, localePath = config.localePath, fallbackLng = config.fallbackLng, reloadOnPrerender = config.reloadOnPrerender;
|
||||||
|
- if (!reloadOnPrerender) {
|
||||||
|
- _context.next = 18;
|
||||||
|
- break;
|
||||||
|
- }
|
||||||
|
_context.next = 18;
|
||||||
|
- return _appWithTranslation.globalI18n === null || _appWithTranslation.globalI18n === void 0 ? void 0 : _appWithTranslation.globalI18n.reloadResources();
|
||||||
|
+ return void 0;
|
||||||
|
case 18:
|
||||||
|
_createClient = (0, _node["default"])(_objectSpread(_objectSpread({}, config), {}, {
|
||||||
|
lng: initialLocale
|
||||||
+88
-7
@@ -1,8 +1,19 @@
|
|||||||
# Contributing to Cal.com
|
# Contributing to Cal.com
|
||||||
|
|
||||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||||
|
|
||||||
|
## House rules
|
||||||
|
|
||||||
|
- Before submitting a new issue or PR, check if it already exists in [issues](https://github.com/calcom/cal.com/issues) or [PRs](https://github.com/calcom/cal.com/pulls).
|
||||||
|
- GitHub issues: take note of the `🚨 needs approval` label.
|
||||||
|
- **For Contributors**:
|
||||||
|
- Feature Requests: Wait for a core member to approve and remove the `🚨 needs approval` label before you start coding or submit a PR.
|
||||||
|
- Bugs, Security, Performance, Documentation, etc.: You can start coding immediately, even if the `🚨 needs approval` label is present. This label mainly concerns feature requests.
|
||||||
|
- **Our Process**:
|
||||||
|
- Issues from non-core members automatically receive the `🚨 needs approval` label.
|
||||||
|
- We greatly value new feature ideas. To ensure consistency in the product's direction, they undergo review and approval.
|
||||||
|
|
||||||
|
|
||||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
|
||||||
|
|
||||||
## Priorities
|
## Priorities
|
||||||
|
|
||||||
@@ -37,7 +48,7 @@ Contributions are what make the open source community such an amazing place to l
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
Core Features (Booking page, availabilty, timezone calculation)
|
Core Features (Booking page, availability, timezone calculation)
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
||||||
@@ -92,7 +103,22 @@ To develop locally:
|
|||||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||||
|
|
||||||
6. Start developing and watch for code changes:
|
6. Setup Node
|
||||||
|
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
You first might need to install the specific version and then use it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm install && nvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
||||||
|
|
||||||
|
7. Start developing and watch for code changes:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
yarn dev
|
yarn dev
|
||||||
@@ -120,6 +146,16 @@ This will run and test all flows in multiple Chromium windows to verify that no
|
|||||||
yarn test-e2e
|
yarn test-e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Resolving issues
|
||||||
|
|
||||||
|
##### E2E test browsers not installed
|
||||||
|
|
||||||
|
Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium
|
||||||
|
```
|
||||||
|
|
||||||
## Linting
|
## Linting
|
||||||
|
|
||||||
To check the formatting of your code:
|
To check the formatting of your code:
|
||||||
@@ -132,7 +168,52 @@ If you get errors, be sure to fix them before committing.
|
|||||||
|
|
||||||
## Making a Pull Request
|
## Making a Pull Request
|
||||||
|
|
||||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR.
|
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue
|
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||||
](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
|
||||||
- Be sure to fill the PR Template accordingly.
|
- Be sure to fill the PR Template accordingly.
|
||||||
|
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
||||||
|
|
||||||
|
## Guidelines for committing yarn lockfile
|
||||||
|
|
||||||
|
Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo:
|
||||||
|
|
||||||
|
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
|
||||||
|
```bash
|
||||||
|
git checkout HEAD~1 yarn.lock
|
||||||
|
git commit -m "Revert yarn.lock changes"
|
||||||
|
```
|
||||||
|
If you've pushed the commit with the `yarn.lock`:
|
||||||
|
1. Correct the commit locally using the above method.
|
||||||
|
2. Carefully force push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
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:
|
||||||
|
```bash
|
||||||
|
git log yarn.lock
|
||||||
|
```
|
||||||
|
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
|
||||||
|
```bash
|
||||||
|
git checkout <commit_hash> yarn.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Commit the Reverted Version**:
|
||||||
|
- After checking out the previous version of the `yarn.lock`, commit this change:
|
||||||
|
```bash
|
||||||
|
git commit -m "Revert yarn.lock to its state before unintended changes"
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
```bash
|
||||||
|
git pull origin <your-branch-name>
|
||||||
|
```
|
||||||
|
- Then push the updated branch:
|
||||||
|
```bash
|
||||||
|
git push origin <your-branch-name>
|
||||||
|
```
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ Here is what you need to be able to run Cal.com.
|
|||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Clone the repo into a public GitHub repository (or fork https://github.com/calcom/cal.com/fork). If you plan to distribute the code, keep the source code public to comply with [AGPLv3](https://github.com/calcom/cal.com/blob/main/LICENSE). To clone in a private repository, [acquire a commercial license](https://cal.com/sales))
|
1. Clone the repo into a public GitHub repository (or fork https://github.com/calcom/cal.com/fork). If you plan to distribute the code, keep the source code public to comply with [AGPLv3](https://github.com/calcom/cal.com/blob/main/LICENSE). To clone in a private repository, [acquire a commercial license](https://cal.com/sales)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/calcom/cal.com.git
|
git clone https://github.com/calcom/cal.com.git
|
||||||
@@ -131,23 +131,39 @@ Here is what you need to be able to run Cal.com.
|
|||||||
> If you are on Windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
|
> If you are on Windows, run the following command on `gitbash` with admin privileges: <br> > `git clone -c core.symlinks=true https://github.com/calcom/cal.com.git` <br>
|
||||||
> See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details.
|
> See [docs](https://cal.com/docs/how-to-guides/how-to-troubleshoot-symbolic-link-issues-on-windows#enable-symbolic-links) for more details.
|
||||||
|
|
||||||
1. Go to the project folder
|
2. Go to the project folder
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd cal.com
|
cd cal.com
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Install packages with yarn
|
3. Install packages with yarn
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
yarn
|
yarn
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Set up your `.env` file
|
4. Set up your `.env` file
|
||||||
|
|
||||||
- Duplicate `.env.example` to `.env`
|
- Duplicate `.env.example` to `.env`
|
||||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||||
|
|
||||||
|
5. Setup Node
|
||||||
|
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
You first might need to install the specific version and then use it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm install && nvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
||||||
|
|
||||||
#### Quick start with `yarn dx`
|
#### Quick start with `yarn dx`
|
||||||
|
|
||||||
> - **Requires Docker and Docker Compose to be installed**
|
> - **Requires Docker and Docker Compose to be installed**
|
||||||
@@ -205,7 +221,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
|
|
||||||
1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`.
|
1. Copy and paste your `DATABASE_URL` from `.env` to `.env.appStore`.
|
||||||
|
|
||||||
1. Set a 32 character random string in your `.env` file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
|
1. Set a 24 character random string in your `.env` file for the `CALENDSO_ENCRYPTION_KEY` (You can use a command like `openssl rand -base64 24` to generate one).
|
||||||
1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
|
1. Set up the database using the Prisma schema (found in `packages/prisma/schema.prisma`)
|
||||||
|
|
||||||
In a development environment, run:
|
In a development environment, run:
|
||||||
@@ -221,6 +237,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
```
|
```
|
||||||
|
|
||||||
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
|
1. Run [mailhog](https://github.com/mailhog/MailHog) to view emails sent during development
|
||||||
|
|
||||||
> **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
|
> **_NOTE:_** Required when `E2E_TEST_MAILHOG_ENABLED` is "1"
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -236,6 +253,8 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
|
|
||||||
#### Setting up your first user
|
#### Setting up your first user
|
||||||
|
|
||||||
|
##### Approach 1
|
||||||
|
|
||||||
1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
|
1. Open [Prisma Studio](https://prisma.io/studio) to look at or modify the database content:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -247,6 +266,17 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
|
|||||||
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file.
|
> New users are set on a `TRIAL` plan by default. You might want to adjust this behavior to your needs in the `packages/prisma/schema.prisma` file.
|
||||||
1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
|
1. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
|
||||||
|
|
||||||
|
##### Approach 2
|
||||||
|
|
||||||
|
Seed the local db by running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd packages/prisma
|
||||||
|
yarn db-seed
|
||||||
|
```
|
||||||
|
|
||||||
|
The above command will populate the local db with dummy users.
|
||||||
|
|
||||||
### E2E-Testing
|
### E2E-Testing
|
||||||
|
|
||||||
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
|
Be sure to set the environment variable `NEXTAUTH_URL` to the correct value. If you are running locally, as the documentation within `.env.example` mentions, the value should be `http://localhost:3000`.
|
||||||
@@ -259,6 +289,16 @@ yarn test-e2e
|
|||||||
yarn playwright show-report test-results/reports/playwright-html-report
|
yarn playwright show-report test-results/reports/playwright-html-report
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Resolving issues
|
||||||
|
|
||||||
|
##### E2E test browsers not installed
|
||||||
|
|
||||||
|
Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Executable doesn't exist at /Users/alice/Library/Caches/ms-playwright/chromium-1048/chrome-mac/Chromium.app/Contents/MacOS/Chromium
|
||||||
|
```
|
||||||
|
|
||||||
### Upgrading from earlier versions
|
### Upgrading from earlier versions
|
||||||
|
|
||||||
1. Pull the current version:
|
1. Pull the current version:
|
||||||
@@ -341,6 +381,10 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
|
|||||||
|
|
||||||
[](https://render.com/deploy?repo=https://github.com/calcom/docker)
|
[](https://render.com/deploy?repo=https://github.com/calcom/docker)
|
||||||
|
|
||||||
|
### Elestio
|
||||||
|
|
||||||
|
[](https://elest.io/open-source/cal.com)
|
||||||
|
|
||||||
<!-- ROADMAP -->
|
<!-- ROADMAP -->
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -423,7 +467,7 @@ yarn seed-app-store
|
|||||||
```
|
```
|
||||||
|
|
||||||
You will need to complete a few more steps to activate Google Calendar App.
|
You will need to complete a few more steps to activate Google Calendar App.
|
||||||
Make sure to complete section "Obtaining the Google API Credentials". After the do the
|
Make sure to complete section "Obtaining the Google API Credentials". After that do the
|
||||||
following
|
following
|
||||||
|
|
||||||
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
|
1. Add extra redirect URL `<Cal.com URL>/api/auth/callback/google`
|
||||||
@@ -449,8 +493,8 @@ following
|
|||||||
7. Click "Create".
|
7. Click "Create".
|
||||||
8. 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.
|
||||||
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. 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.
|
||||||
10. Also add the redirect URL given above as a 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.
|
||||||
11. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
|
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`.
|
||||||
12. Click "Done".
|
12. Click "Done".
|
||||||
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
|
||||||
|
|
||||||
@@ -470,9 +514,8 @@ following
|
|||||||
4. Select Basecamp 4 as the product to integrate with.
|
4. Select Basecamp 4 as the product to integrate with.
|
||||||
5. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs.
|
5. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||||
6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields.
|
6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields.
|
||||||
7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`.
|
7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`.
|
||||||
For example, `Cal.com (support@cal.com)`.
|
For example, `Cal.com (support@cal.com)`.
|
||||||
|
|
||||||
|
|
||||||
### Obtaining HubSpot Client ID and Secret
|
### Obtaining HubSpot Client ID and Secret
|
||||||
|
|
||||||
@@ -504,6 +547,10 @@ For example, `Cal.com (support@cal.com)`.
|
|||||||
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
9. Click the "Save"/ "UPDATE" button at the bottom footer.
|
||||||
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
|
10. You're good to go. Now you can easily add your ZohoCRM integration in the Cal.com settings.
|
||||||
|
|
||||||
|
### Obtaining Zoho Calendar Client ID and Secret
|
||||||
|
|
||||||
|
[Follow these steps](./packages/app-store/zohocalendar/)
|
||||||
|
|
||||||
### Obtaining Zoho Bigin Client ID and Secret
|
### Obtaining Zoho Bigin Client ID and Secret
|
||||||
|
|
||||||
[Follow these steps](./packages/app-store/zoho-bigin/)
|
[Follow these steps](./packages/app-store/zoho-bigin/)
|
||||||
@@ -526,7 +573,7 @@ For example, `Cal.com (support@cal.com)`.
|
|||||||
3. Copy Account SID to your `.env` file into the `TWILIO_SID` field
|
3. Copy Account SID to your `.env` file into the `TWILIO_SID` field
|
||||||
4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field
|
4. Copy Auth Token to your `.env` file into the `TWILIO_TOKEN` field
|
||||||
5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field
|
5. Copy your Twilio phone number to your `.env` file into the `TWILIO_PHONE_NUMBER` field
|
||||||
6. Add your own sender id to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
6. Add your own sender ID to the `.env` file into the `NEXT_PUBLIC_SENDER_ID` field (fallback is Cal.com)
|
||||||
7. Create a messaging service (Develop -> Messaging -> Services)
|
7. Create a messaging service (Develop -> Messaging -> Services)
|
||||||
8. Choose any name for the messaging service
|
8. Choose any name for the messaging service
|
||||||
9. Click 'Add Senders'
|
9. Click 'Add Senders'
|
||||||
@@ -550,8 +597,6 @@ Distributed under the [AGPLv3 License](https://github.com/calcom/cal.com/blob/ma
|
|||||||
|
|
||||||
Special thanks to these amazing projects which help power Cal.com:
|
Special thanks to these amazing projects which help power Cal.com:
|
||||||
|
|
||||||
[<img src="https://cal.com/powered-by-vercel.svg">](https://vercel.com/?utm_source=calend-so&utm_campaign=oss)
|
|
||||||
|
|
||||||
- [Vercel](https://vercel.com/?utm_source=calend-so&utm_campaign=oss)
|
- [Vercel](https://vercel.com/?utm_source=calend-so&utm_campaign=oss)
|
||||||
- [Next.js](https://nextjs.org/)
|
- [Next.js](https://nextjs.org/)
|
||||||
- [Day.js](https://day.js.org/)
|
- [Day.js](https://day.js.org/)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Checkly Tests
|
||||||
|
|
||||||
|
Run as `yarn checkly test`
|
||||||
|
Deploy the tests as `yarn checkly deploy`
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Org", () => {
|
||||||
|
// Because these pages involve next.config.js rewrites, it's better to test them on production
|
||||||
|
test.describe("Embeds - i.cal.com", () => {
|
||||||
|
test("Org Profile Page should be embeddable", async ({ page }) => {
|
||||||
|
const response = await page.goto("https://i.cal.com/embed");
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await page.screenshot({ path: "screenshot.jpg" });
|
||||||
|
await expectPageToBeServerSideRendered(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Org User(Peer) Page should be embeddable", async ({ page }) => {
|
||||||
|
const response = await page.goto("https://i.cal.com/peer/embed");
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(page.locator("text=Peer Richelsen")).toBeVisible();
|
||||||
|
await expectPageToBeServerSideRendered(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => {
|
||||||
|
const response = await page.goto("https://i.cal.com/peer/meet/embed");
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||||
|
await expectPageToBeServerSideRendered(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Org Team Profile(/sales) page should be embeddable", async ({ page }) => {
|
||||||
|
const response = await page.goto("https://i.cal.com/sales/embed");
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(page.locator("text=Cal.com Sales")).toBeVisible();
|
||||||
|
await expectPageToBeServerSideRendered(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Org Team Event page(/sales/hippa) should be embeddable", async ({ page }) => {
|
||||||
|
const response = await page.goto("https://i.cal.com/sales/hipaa/embed");
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||||
|
await expectPageToBeServerSideRendered(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// This ensures that the route is actually mapped to a page that is using withEmbedSsr
|
||||||
|
async function expectPageToBeServerSideRendered(page: Page) {
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
return window.__NEXT_DATA__.props.pageProps.isEmbed;
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
@@ -83,6 +83,10 @@
|
|||||||
"NEXT_PUBLIC_TEAM_IMPERSONATION": {
|
"NEXT_PUBLIC_TEAM_IMPERSONATION": {
|
||||||
"description": "Set the following value to true if you wish to enable Team Impersonation",
|
"description": "Set the following value to true if you wish to enable Team Impersonation",
|
||||||
"value": "false"
|
"value": "false"
|
||||||
|
},
|
||||||
|
"NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL": {
|
||||||
|
"description": "Control time intervals on a user's Schedule availability",
|
||||||
|
"value": "15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
BACKEND_URL=http://localhost:3002/api
|
||||||
|
# BACKEND_URL=https://api.cal.com/v1
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
# FRONTEND_URL=https://cal.com
|
||||||
|
|
||||||
|
APP_ID=cal-ai
|
||||||
|
APP_URL=http://localhost:3000/apps/cal-ai
|
||||||
|
|
||||||
|
# This is for the onboard route. Which domain should we send emails from?
|
||||||
|
SENDER_DOMAIN=cal.ai
|
||||||
|
|
||||||
|
# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
|
||||||
|
PARSE_KEY=
|
||||||
|
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# Optionally trace completions at https://smith.langchain.com
|
||||||
|
# LANGCHAIN_TRACING_V2=true
|
||||||
|
# LANGCHAIN_ENDPOINT=
|
||||||
|
# LANGCHAIN_API_KEY=
|
||||||
|
# LANGCHAIN_PROJECT=
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Cal.ai
|
||||||
|
|
||||||
|
Welcome to [Cal.ai](https://cal.ai)!
|
||||||
|
|
||||||
|
This app lets you chat with your calendar via email:
|
||||||
|
|
||||||
|
- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
|
||||||
|
- List and rearrange your bookings eg. "clear my afternoon"
|
||||||
|
- Answer basic questions about your busiest times eg. "how does my Tuesday look?"
|
||||||
|
|
||||||
|
The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
|
||||||
|
|
||||||
|
_The AI agent can only choose from a set of tools, without ever seeing your API key._
|
||||||
|
|
||||||
|
Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
|
||||||
|
|
||||||
|
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
|
||||||
|
|
||||||
|
## Recognition
|
||||||
|
|
||||||
|
<a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-cal-ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=419860&theme=light&period=daily" alt="Cal.ai - World's first open source AI scheduling assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cal-ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal.ai - World's first open source AI scheduling assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
If you haven't yet, please run the [root setup](/README.md) steps.
|
||||||
|
|
||||||
|
Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. Run `cp .env.example .env` in this folder to get started. You'll need:
|
||||||
|
|
||||||
|
- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
|
||||||
|
- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
|
||||||
|
- A default sender email (for example, `me@dev.example.com`)
|
||||||
|
- The Cal.ai app's ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
|
||||||
|
- A unique value for `PARSE_KEY` with `openssl rand -hex 32`
|
||||||
|
|
||||||
|
To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
|
||||||
|
|
||||||
|
### Agent Architecture
|
||||||
|
|
||||||
|
The scheduling agent in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts) calls an LLM (in this case, GPT-4) in a loop to accomplish a multi-step task. We use an [OpenAI Functions agent](https://js.langchain.com/docs/modules/agents/agent_types/openai_functions_agent), which is fine-tuned to output text suited for passing to tools.
|
||||||
|
|
||||||
|
Tools (eg. [`createBooking`](/apps/ai/src/tools/createBooking.ts)) are simply JavaScript methods wrapped by Zod schemas, telling the agent what format to output.
|
||||||
|
|
||||||
|
Here is the full architecture:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Email Router
|
||||||
|
|
||||||
|
To expose the AI app, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||||
|
|
||||||
|
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||||
|
|
||||||
|
1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/)
|
||||||
|
2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication).
|
||||||
|
3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`, both with priority `10` if prompted.
|
||||||
|
4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain.
|
||||||
|
5. In the Destination URL field, use the nGrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
|
||||||
|
6. Activate "POST the raw, full MIME message".
|
||||||
|
7. Send an email to `[anyUsername]@example.com`. You should see a ping on the nGrok listener and server.
|
||||||
|
8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
|
||||||
|
|
||||||
|
Please feel free to improve any part of this architecture!
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const withBundleAnalyzer = require("@next/bundle-analyzer");
|
||||||
|
|
||||||
|
const plugins = [];
|
||||||
|
plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
|
||||||
|
|
||||||
|
/** @type {import("next").NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/",
|
||||||
|
destination: "https://cal.com/ai",
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: "en",
|
||||||
|
locales: ["en"],
|
||||||
|
},
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@calcom/ai",
|
||||||
|
"version": "1.2.1",
|
||||||
|
"private": true,
|
||||||
|
"author": "Cal.com Inc.",
|
||||||
|
"dependencies": {
|
||||||
|
"@calcom/prisma": "*",
|
||||||
|
"@t3-oss/env-nextjs": "^0.6.1",
|
||||||
|
"langchain": "^0.0.131",
|
||||||
|
"mailparser": "^3.6.5",
|
||||||
|
"next": "^13.5.4",
|
||||||
|
"supports-color": "8.1.1",
|
||||||
|
"zod": "^3.22.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/mailparser": "^3.4.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"dev": "next dev -p 3005",
|
||||||
|
"format": "npx prettier . --write",
|
||||||
|
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||||
|
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||||
|
"start": "next start"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import agent from "../../../utils/agent";
|
||||||
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
|
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||||
|
|
||||||
|
// Allow agent loop to run for up to 5 minutes
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launches a LangChain agent to process an incoming email,
|
||||||
|
* then sends the response to the user.
|
||||||
|
*/
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
const verified = verifyParseKey(request.url);
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await request.json();
|
||||||
|
|
||||||
|
const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json;
|
||||||
|
|
||||||
|
if ((!message && !subject) || !user) {
|
||||||
|
return new NextResponse("Missing fields", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail);
|
||||||
|
|
||||||
|
// Send response to user
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
|
||||||
|
to: user.email,
|
||||||
|
from: agentEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse("ok");
|
||||||
|
} catch (error) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
|
||||||
|
to: user.email,
|
||||||
|
from: agentEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(
|
||||||
|
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import { env } from "../../../env.mjs";
|
||||||
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
|
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
const { userId } = await request.json();
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response("User not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
subject: "Welcome to Cal AI",
|
||||||
|
to: user.email,
|
||||||
|
from: `${user.username}@${env.SENDER_DOMAIN}`,
|
||||||
|
text: `Hi ${
|
||||||
|
user.name || `@${user.username}`
|
||||||
|
},\n\nI'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.\n\nHere are some things you can ask me:\n\n- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)\n- "What meetings do I have today?" (I'll show you your schedule)\n- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)\n\nI'm still learning, so if you have any feedback, please tweet it to @calcom!\n\nRemember, you can always reach me here, at ${
|
||||||
|
user.username
|
||||||
|
}@${
|
||||||
|
env.SENDER_DOMAIN
|
||||||
|
}.\n\nLooking forward to working together (:\n\n- Cal AI, Your personal booking assistant`,
|
||||||
|
html: `Hi ${
|
||||||
|
user.name || `@${user.username}`
|
||||||
|
},<br><br>I'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.<br><br>Here are some things you can ask me:<br><br>- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)<br>- "What meetings do I have today?" (I'll show you your schedule)<br>- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)<br><br>I'm still learning, so if you have any feedback, please send it to <a href="https://twitter.com/calcom">@calcom</a> on X!<br><br>Remember, you can always reach me here, at ${
|
||||||
|
user.username
|
||||||
|
}@${env.SENDER_DOMAIN}.<br><br>Looking forward to working together (:<br><br>- Cal AI`,
|
||||||
|
});
|
||||||
|
return new Response("OK", { status: 200 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { ParsedMail, Source } from "mailparser";
|
||||||
|
import { simpleParser } from "mailparser";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import { env } from "../../../env.mjs";
|
||||||
|
import { fetchAvailability } from "../../../tools/getAvailability";
|
||||||
|
import { fetchEventTypes } from "../../../tools/getEventTypes";
|
||||||
|
import { extractUsers } from "../../../utils/extractUsers";
|
||||||
|
import getHostFromHeaders from "../../../utils/host";
|
||||||
|
import now from "../../../utils/now";
|
||||||
|
import sendEmail from "../../../utils/sendEmail";
|
||||||
|
import { verifyParseKey } from "../../../utils/verifyParseKey";
|
||||||
|
|
||||||
|
// Allow receive loop to run for up to 30 seconds
|
||||||
|
// Why so long? the rate determining API call (getAvailability, getEventTypes) can take up to 15 seconds at peak times so we give it a little extra time to complete.
|
||||||
|
export const maxDuration = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies email signature and app authorization,
|
||||||
|
* then hands off to booking agent.
|
||||||
|
*/
|
||||||
|
export const POST = async (request: NextRequest) => {
|
||||||
|
const verified = verifyParseKey(request.url);
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const body = Object.fromEntries(formData);
|
||||||
|
const envelope = JSON.parse(body.envelope as string);
|
||||||
|
|
||||||
|
const aiEmail = envelope.to[0];
|
||||||
|
const subject = body.subject || "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await checkRateLimitAndThrowError({
|
||||||
|
identifier: `ai:email:${envelope.from}`,
|
||||||
|
rateLimitingType: "ai",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
|
||||||
|
to: envelope.from,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse email from mixed MIME type
|
||||||
|
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||||
|
|
||||||
|
if (!parsed.text && !parsed.subject) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
|
||||||
|
to: envelope.from,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
timeZone: true,
|
||||||
|
credentials: {
|
||||||
|
select: {
|
||||||
|
appId: true,
|
||||||
|
key: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: { email: envelope.from },
|
||||||
|
});
|
||||||
|
|
||||||
|
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||||
|
const signature = (body.dkim as string).includes(" : pass");
|
||||||
|
|
||||||
|
// User is not a cal.com user or is using an unverified email.
|
||||||
|
if (!signature || !user) {
|
||||||
|
await sendEmail({
|
||||||
|
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address and then install Cal.ai here: <a href="https://go.cal.com/ai" target="_blank">go.cal.com/ai</a>.`,
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||||
|
to: envelope.from,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
|
||||||
|
|
||||||
|
// User has not installed the app from the app store. Direct them to install it.
|
||||||
|
if (!(credential as { apiKey: string })?.apiKey) {
|
||||||
|
const url = env.APP_URL;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
|
||||||
|
to: envelope.from,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey } = credential as { apiKey: string };
|
||||||
|
|
||||||
|
// Pre-fetch data relevant to most bookings.
|
||||||
|
const [eventTypes, availability, users] = await Promise.all([
|
||||||
|
fetchEventTypes({
|
||||||
|
apiKey,
|
||||||
|
}),
|
||||||
|
fetchAvailability({
|
||||||
|
apiKey,
|
||||||
|
userId: user.id,
|
||||||
|
dateFrom: now(user.timeZone),
|
||||||
|
dateTo: now(user.timeZone),
|
||||||
|
}),
|
||||||
|
extractUsers(`${parsed.text} ${parsed.subject}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ("error" in availability) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||||
|
to: user.email,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
console.error(availability.error);
|
||||||
|
return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("error" in eventTypes) {
|
||||||
|
await sendEmail({
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||||
|
to: user.email,
|
||||||
|
from: aiEmail,
|
||||||
|
});
|
||||||
|
console.error(eventTypes.error);
|
||||||
|
return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workingHours } = availability;
|
||||||
|
|
||||||
|
const appHost = getHostFromHeaders(request.headers);
|
||||||
|
|
||||||
|
// Hand off to long-running agent endpoint to handle the email. (don't await)
|
||||||
|
fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
apiKey,
|
||||||
|
userId: user.id,
|
||||||
|
message: parsed.text || "",
|
||||||
|
subject: parsed.subject || "",
|
||||||
|
replyTo: aiEmail,
|
||||||
|
user: {
|
||||||
|
email: user.email,
|
||||||
|
eventTypes,
|
||||||
|
username: user.username,
|
||||||
|
timeZone: user.timeZone,
|
||||||
|
workingHours,
|
||||||
|
},
|
||||||
|
users,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
|
return new NextResponse("ok");
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createEnv } from "@t3-oss/env-nextjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const env = createEnv({
|
||||||
|
/**
|
||||||
|
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||||
|
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||||
|
* `NEXT_PUBLIC_`.
|
||||||
|
*/
|
||||||
|
client: {
|
||||||
|
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||||
|
* middlewares) or client-side so we need to destruct manually.
|
||||||
|
*/
|
||||||
|
runtimeEnv: {
|
||||||
|
BACKEND_URL: process.env.BACKEND_URL,
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL,
|
||||||
|
APP_ID: process.env.APP_ID,
|
||||||
|
APP_URL: process.env.APP_URL,
|
||||||
|
SENDER_DOMAIN: process.env.SENDER_DOMAIN,
|
||||||
|
PARSE_KEY: process.env.PARSE_KEY,
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||||
|
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||||
|
* isn't built with invalid env vars.
|
||||||
|
*/
|
||||||
|
server: {
|
||||||
|
BACKEND_URL: z.string().url(),
|
||||||
|
FRONTEND_URL: z.string().url(),
|
||||||
|
APP_ID: z.string().min(1),
|
||||||
|
APP_URL: z.string().url(),
|
||||||
|
SENDER_DOMAIN: z.string().min(1),
|
||||||
|
PARSE_KEY: z.string().min(1),
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||||
|
OPENAI_API_KEY: z.string().min(1),
|
||||||
|
SENDGRID_API_KEY: z.string().min(1),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
},
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,121 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { UserList } from "~/src/types/user";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a booking for a user by event type, times, and timezone.
|
||||||
|
*/
|
||||||
|
const createBooking = async ({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
users,
|
||||||
|
eventTypeId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
timeZone,
|
||||||
|
language,
|
||||||
|
invite,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
userId: number;
|
||||||
|
users: UserList;
|
||||||
|
eventTypeId: number;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
timeZone: string;
|
||||||
|
language: string;
|
||||||
|
invite: number;
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<string | Error | { error: string }> => {
|
||||||
|
const params = {
|
||||||
|
apiKey,
|
||||||
|
userId: userId.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const user = users.find((u) => u.id === invite);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { error: `User with id ${invite} not found to invite` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = {
|
||||||
|
id: invite.toString(),
|
||||||
|
name: user.username,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
end,
|
||||||
|
eventTypeId,
|
||||||
|
language,
|
||||||
|
metadata: {},
|
||||||
|
responses,
|
||||||
|
start,
|
||||||
|
timeZone,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let GPT handle this. This will happen when wrong event type id is used.
|
||||||
|
// if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return {
|
||||||
|
error: data.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Booking created";
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Creates a booking on the primary user's calendar.",
|
||||||
|
func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await createBooking({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
users,
|
||||||
|
end,
|
||||||
|
eventTypeId,
|
||||||
|
language,
|
||||||
|
invite,
|
||||||
|
start,
|
||||||
|
status,
|
||||||
|
timeZone,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "createBooking",
|
||||||
|
schema: z.object({
|
||||||
|
end: z
|
||||||
|
.string()
|
||||||
|
.describe("This should correspond to the event type's length, unless otherwise specified."),
|
||||||
|
eventTypeId: z.number(),
|
||||||
|
language: z.string(),
|
||||||
|
invite: z.number().describe("External user id to invite."),
|
||||||
|
start: z.string(),
|
||||||
|
status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
|
||||||
|
timeZone: z.string(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createBookingTool;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels a booking for a user by ID with reason.
|
||||||
|
*/
|
||||||
|
const cancelBooking = async ({
|
||||||
|
apiKey,
|
||||||
|
id,
|
||||||
|
reason,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
id: string;
|
||||||
|
reason: string;
|
||||||
|
}): Promise<string | { error: string }> => {
|
||||||
|
const params = {
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||||
|
// if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Booking cancelled";
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelBookingTool = (apiKey: string) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Cancel a booking",
|
||||||
|
func: async ({ id, reason }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await cancelBooking({
|
||||||
|
apiKey,
|
||||||
|
id,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "cancelBooking",
|
||||||
|
schema: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
reason: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default cancelBookingTool;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
import type { Availability } from "../types/availability";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches availability for a user by date range and event type.
|
||||||
|
*/
|
||||||
|
export const fetchAvailability = async ({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
userId: number;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
}): Promise<Partial<Availability> | { error: string }> => {
|
||||||
|
const params: { [k: string]: string } = {
|
||||||
|
apiKey,
|
||||||
|
userId: userId.toString(),
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy: data.busy,
|
||||||
|
dateRanges: data.dateRanges,
|
||||||
|
timeZone: data.timeZone,
|
||||||
|
workingHours: data.workingHours,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailabilityTool = (apiKey: string) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Get availability of users within range.",
|
||||||
|
func: async ({ userIds, dateFrom, dateTo }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await Promise.all(
|
||||||
|
userIds.map(
|
||||||
|
async (userId) =>
|
||||||
|
await fetchAvailability({
|
||||||
|
userId: userId,
|
||||||
|
apiKey,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "getAvailability",
|
||||||
|
schema: z.object({
|
||||||
|
userIds: z.array(z.number()).describe("The users to fetch availability for."),
|
||||||
|
dateFrom: z.string(),
|
||||||
|
dateTo: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getAvailabilityTool;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
import type { Booking } from "../types/booking";
|
||||||
|
import { BOOKING_STATUS } from "../types/booking";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches bookings for a user by date range.
|
||||||
|
*/
|
||||||
|
const fetchBookings = async ({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
userId: number;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}): Promise<Booking[] | { error: string }> => {
|
||||||
|
const params = {
|
||||||
|
apiKey,
|
||||||
|
userId: userId.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookings: Booking[] = data.bookings
|
||||||
|
.filter((booking: Booking) => {
|
||||||
|
const afterFrom = new Date(booking.startTime).getTime() > new Date(from).getTime();
|
||||||
|
const beforeTo = new Date(booking.endTime).getTime() < new Date(to).getTime();
|
||||||
|
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
|
||||||
|
|
||||||
|
return afterFrom && beforeTo && notCancelled;
|
||||||
|
})
|
||||||
|
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
|
||||||
|
endTime,
|
||||||
|
eventTypeId,
|
||||||
|
id,
|
||||||
|
startTime,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return bookings;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBookingsTool = (apiKey: string, userId: number) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Get bookings for the primary user between two dates.",
|
||||||
|
func: async ({ from, to }) => {
|
||||||
|
return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
|
||||||
|
},
|
||||||
|
name: "getBookings",
|
||||||
|
schema: z.object({
|
||||||
|
from: z.string().describe("ISO 8601 datetime string"),
|
||||||
|
to: z.string().describe("ISO 8601 datetime string"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getBookingsTool;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
import type { EventType } from "../types/eventType";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches event types by user ID.
|
||||||
|
*/
|
||||||
|
export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
params["userId"] = userId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.event_types.map((eventType: EventType) => ({
|
||||||
|
id: eventType.id,
|
||||||
|
slug: eventType.slug,
|
||||||
|
length: eventType.length,
|
||||||
|
title: eventType.title,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventTypesTool = (apiKey: string) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Get a user's event type IDs. Usually necessary to book a meeting.",
|
||||||
|
func: async ({ userId }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await fetchEventTypes({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "getEventTypes",
|
||||||
|
schema: z.object({
|
||||||
|
userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getEventTypesTool;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "~/src/env.mjs";
|
||||||
|
import type { User, UserList } from "~/src/types/user";
|
||||||
|
import sendEmail from "~/src/utils/sendEmail";
|
||||||
|
|
||||||
|
export const sendBookingEmail = async ({
|
||||||
|
user,
|
||||||
|
agentEmail,
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
eventTypeSlug,
|
||||||
|
slots,
|
||||||
|
date,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
user: User;
|
||||||
|
users: UserList;
|
||||||
|
agentEmail: string;
|
||||||
|
subject: string;
|
||||||
|
to: string;
|
||||||
|
message: string;
|
||||||
|
eventTypeSlug: string;
|
||||||
|
slots?: {
|
||||||
|
time: string;
|
||||||
|
text: string;
|
||||||
|
}[];
|
||||||
|
date: {
|
||||||
|
date: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
// const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
|
||||||
|
const timeUrls = slots?.map(({ time, text }) => {
|
||||||
|
return {
|
||||||
|
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?slot=${time}`,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateUrl = {
|
||||||
|
url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date.date}`,
|
||||||
|
text: date.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
cc: user.email,
|
||||||
|
from: agentEmail,
|
||||||
|
text: message
|
||||||
|
.split("[[[Slots]]]")
|
||||||
|
.join(timeUrls?.map(({ url, text }) => `${text}: ${url}`).join("\n"))
|
||||||
|
.split("[[[Link]]]")
|
||||||
|
.join(`${dateUrl.text}: ${dateUrl.url}`),
|
||||||
|
html: message
|
||||||
|
.split("\n")
|
||||||
|
.join("<br>")
|
||||||
|
.split("[[[Slots]]]")
|
||||||
|
.join(timeUrls?.map(({ url, text }) => `<a href="${url}">${text}</a>`).join("<br>"))
|
||||||
|
.split("[[[Link]]]")
|
||||||
|
.join(`<a href="${dateUrl.url}">${dateUrl.text}</a>`),
|
||||||
|
});
|
||||||
|
|
||||||
|
return "Booking link sent";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendBookingEmailTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description:
|
||||||
|
"Send a booking link via email. Useful for scheduling with non cal users. Be confident, suggesting a good date/time with a fallback to a link to select a date/time.",
|
||||||
|
func: async ({ message, subject, to, eventTypeSlug, slots, date }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await sendBookingEmail({
|
||||||
|
apiKey,
|
||||||
|
user,
|
||||||
|
users,
|
||||||
|
agentEmail,
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
eventTypeSlug,
|
||||||
|
slots,
|
||||||
|
date,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "sendBookingEmail",
|
||||||
|
|
||||||
|
schema: z.object({
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"A polite and professional email with an intro and signature at the end. Specify you are the AI booking assistant of the primary user. Use [[[Slots]]] and a fallback [[[Link]]] to inject good times and 'see all times' into messages"
|
||||||
|
),
|
||||||
|
subject: z.string(),
|
||||||
|
to: z
|
||||||
|
.string()
|
||||||
|
.describe("email address to send the booking link to. Primary user is automatically CC'd"),
|
||||||
|
eventTypeSlug: z.string().describe("the slug of the event type to book"),
|
||||||
|
slots: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
time: z.string().describe("YYYY-MM-DDTHH:mm in UTC"),
|
||||||
|
text: z.string().describe("minimum readable label. Ex. 4pm."),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.describe("Time slots the external user can click"),
|
||||||
|
date: z
|
||||||
|
.object({
|
||||||
|
date: z.string().describe("YYYY-MM-DD"),
|
||||||
|
text: z.string().describe('"See all times" or similar'),
|
||||||
|
})
|
||||||
|
.describe(
|
||||||
|
"A booking link that allows the external user to select a date / time. Should be a fallback to time slots"
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sendBookingEmailTool;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { DynamicStructuredTool } from "langchain/tools";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits a booking for a user by booking ID with new times, title, description, or status.
|
||||||
|
*/
|
||||||
|
const editBooking = async ({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
|
||||||
|
endTime, // Same here: it says end but it's endTime.
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
apiKey: string;
|
||||||
|
userId: number;
|
||||||
|
id: string;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<string | { error: string }> => {
|
||||||
|
const params = {
|
||||||
|
apiKey,
|
||||||
|
userId: userId.toString(),
|
||||||
|
};
|
||||||
|
const urlParams = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify({ description, endTime, startTime, status, title }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PATCH",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let GPT handle this. This will happen when wrong booking id is used.
|
||||||
|
// if (response.status === 401) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return { error: data.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Booking edited";
|
||||||
|
};
|
||||||
|
|
||||||
|
const editBookingTool = (apiKey: string, userId: number) => {
|
||||||
|
return new DynamicStructuredTool({
|
||||||
|
description: "Edit a booking",
|
||||||
|
func: async ({ description, endTime, id, startTime, status, title }) => {
|
||||||
|
return JSON.stringify(
|
||||||
|
await editBooking({
|
||||||
|
apiKey,
|
||||||
|
userId,
|
||||||
|
description,
|
||||||
|
endTime,
|
||||||
|
id,
|
||||||
|
startTime,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
name: "editBooking",
|
||||||
|
schema: z.object({
|
||||||
|
description: z.string().optional(),
|
||||||
|
endTime: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
startTime: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default editBookingTool;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type Availability = {
|
||||||
|
busy: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
title?: string;
|
||||||
|
}[];
|
||||||
|
timeZone: string;
|
||||||
|
dateRanges: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}[];
|
||||||
|
workingHours: {
|
||||||
|
days: number[];
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
userId: number;
|
||||||
|
}[];
|
||||||
|
dateOverrides: {
|
||||||
|
date: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
currentSeats: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export enum BOOKING_STATUS {
|
||||||
|
ACCEPTED = "ACCEPTED",
|
||||||
|
PENDING = "PENDING",
|
||||||
|
CANCELLED = "CANCELLED",
|
||||||
|
REJECTED = "REJECTED",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Booking = {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
description: string | null;
|
||||||
|
eventTypeId: number;
|
||||||
|
uid: string;
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||||
|
user: { email: string; name: string; timeZone: string; locale: string }[] | null;
|
||||||
|
payment: { id: number; success: boolean; paymentOption: string }[];
|
||||||
|
metadata: object | null;
|
||||||
|
status: BOOKING_STATUS;
|
||||||
|
responses: { email: string; name: string; location: string } | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type EventType = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
length: number;
|
||||||
|
metadata: object;
|
||||||
|
slug: string;
|
||||||
|
hosts: {
|
||||||
|
userId: number;
|
||||||
|
isFixed: boolean;
|
||||||
|
}[];
|
||||||
|
hidden: boolean;
|
||||||
|
// ...
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { EventType } from "./eventType";
|
||||||
|
import type { WorkingHours } from "./workingHours";
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
timeZone: string;
|
||||||
|
eventTypes: EventType[];
|
||||||
|
workingHours: WorkingHours[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserList = {
|
||||||
|
id?: number;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
type: "fromUsername" | "fromEmail";
|
||||||
|
}[];
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type WorkingHours = {
|
||||||
|
days: number[];
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { initializeAgentExecutorWithOptions } from "langchain/agents";
|
||||||
|
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
import createBookingIfAvailable from "../tools/createBooking";
|
||||||
|
import deleteBooking from "../tools/deleteBooking";
|
||||||
|
import getAvailability from "../tools/getAvailability";
|
||||||
|
import getBookings from "../tools/getBookings";
|
||||||
|
import sendBookingEmail from "../tools/sendBookingEmail";
|
||||||
|
import updateBooking from "../tools/updateBooking";
|
||||||
|
import type { EventType } from "../types/eventType";
|
||||||
|
import type { User, UserList } from "../types/user";
|
||||||
|
import type { WorkingHours } from "../types/workingHours";
|
||||||
|
import now from "./now";
|
||||||
|
|
||||||
|
const gptModel = "gpt-4";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
|
||||||
|
* Uses a toolchain to book meetings, list available slots, etc.
|
||||||
|
* Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
|
||||||
|
*/
|
||||||
|
const agent = async (
|
||||||
|
input: string,
|
||||||
|
user: User,
|
||||||
|
users: UserList,
|
||||||
|
apiKey: string,
|
||||||
|
userId: number,
|
||||||
|
agentEmail: string
|
||||||
|
) => {
|
||||||
|
const tools = [
|
||||||
|
// getEventTypes(apiKey),
|
||||||
|
getAvailability(apiKey),
|
||||||
|
getBookings(apiKey, userId),
|
||||||
|
createBookingIfAvailable(apiKey, userId, users),
|
||||||
|
updateBooking(apiKey, userId),
|
||||||
|
deleteBooking(apiKey),
|
||||||
|
sendBookingEmail(apiKey, user, users, agentEmail),
|
||||||
|
];
|
||||||
|
|
||||||
|
const model = new ChatOpenAI({
|
||||||
|
modelName: gptModel,
|
||||||
|
openAIApiKey: env.OPENAI_API_KEY,
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the agent executor with arguments.
|
||||||
|
*/
|
||||||
|
const executor = await initializeAgentExecutorWithOptions(tools, model, {
|
||||||
|
agentArgs: {
|
||||||
|
prefix: `You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
|
||||||
|
Make sure your final answers are definitive, complete and well formatted.
|
||||||
|
Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
|
||||||
|
Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
|
||||||
|
In responses to users, always summarize necessary context and open the door to follow ups. For example "I have booked your chat with @username for 3pm on Wednesday, December 20th, 2023 EST. Please let me know if you need to reschedule."
|
||||||
|
If you can't find a referenced user, ask the user for their email or @username. Make sure to specify that usernames require the @username format. Users don't know other users' userIds.
|
||||||
|
|
||||||
|
The primary user's id is: ${userId}
|
||||||
|
The primary user's username is: ${user.username}
|
||||||
|
The current time in the primary user's timezone is: ${now(user.timeZone)}
|
||||||
|
The primary user's time zone is: ${user.timeZone}
|
||||||
|
The primary user's event types are: ${user.eventTypes
|
||||||
|
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
|
||||||
|
.join("\n")}
|
||||||
|
The primary user's working hours are: ${user.workingHours
|
||||||
|
.map(
|
||||||
|
(w: WorkingHours) =>
|
||||||
|
`Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
|
||||||
|
w.startTime
|
||||||
|
}, End Time (minutes in UTC): ${w.endTime};`
|
||||||
|
)
|
||||||
|
.join("\n")}
|
||||||
|
${
|
||||||
|
users.length
|
||||||
|
? `The email references the following @usernames and emails: ${users
|
||||||
|
.map(
|
||||||
|
(u) =>
|
||||||
|
`${
|
||||||
|
(u.id ? `, id: ${u.id}` : "id: (non user)") +
|
||||||
|
(u.username
|
||||||
|
? u.type === "fromUsername"
|
||||||
|
? `, username: @${u.username}`
|
||||||
|
: ", username: REDACTED"
|
||||||
|
: ", (no username)") +
|
||||||
|
(u.email
|
||||||
|
? u.type === "fromEmail"
|
||||||
|
? `, email: ${u.email}`
|
||||||
|
: ", email: REDACTED"
|
||||||
|
: ", (no email)")
|
||||||
|
};`
|
||||||
|
)
|
||||||
|
.join("\n")}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
agentType: "openai-functions",
|
||||||
|
returnIntermediateSteps: env.NODE_ENV === "development",
|
||||||
|
verbose: env.NODE_ENV === "development",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executor.call({ input });
|
||||||
|
const { output } = result;
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default agent;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const context = { apiKey: "", userId: "" };
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import type { UserList } from "../types/user";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Extracts usernames (@Example) and emails (hi@example.com) from a string
|
||||||
|
*/
|
||||||
|
export const extractUsers = async (text: string) => {
|
||||||
|
const usernames = text
|
||||||
|
.match(/(?<![a-zA-Z0-9_.])@[a-zA-Z0-9_]+/g)
|
||||||
|
?.map((username) => username.slice(1).toLowerCase());
|
||||||
|
const emails = text
|
||||||
|
.match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g)
|
||||||
|
?.map((email) => email.toLowerCase());
|
||||||
|
|
||||||
|
const dbUsersFromUsernames = usernames
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
username: {
|
||||||
|
in: usernames,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const usersFromUsernames = usernames
|
||||||
|
? usernames.map((username) => {
|
||||||
|
const user = dbUsersFromUsernames.find((u) => u.username === username);
|
||||||
|
return user
|
||||||
|
? {
|
||||||
|
username,
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
type: "fromUsername",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
username,
|
||||||
|
id: null,
|
||||||
|
email: null,
|
||||||
|
type: "fromUsername",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const dbUsersFromEmails = emails
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
in: emails,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const usersFromEmails = emails
|
||||||
|
? emails.map((email) => {
|
||||||
|
const user = dbUsersFromEmails.find((u) => u.email === email);
|
||||||
|
return user
|
||||||
|
? {
|
||||||
|
email,
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
type: "fromEmail",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
email,
|
||||||
|
id: null,
|
||||||
|
username: null,
|
||||||
|
type: "fromEmail",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...usersFromUsernames, ...usersFromEmails] as UserList;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
|
||||||
|
return `https://${headers.get("host")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getHostFromHeaders;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default function now(timeZone: string) {
|
||||||
|
return new Date().toLocaleString("en-US", {
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import mail from "@sendgrid/mail";
|
||||||
|
|
||||||
|
const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simply send an email by address, subject, and body.
|
||||||
|
*/
|
||||||
|
const send = async ({
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
from,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
}: {
|
||||||
|
subject: string;
|
||||||
|
to: string | string[];
|
||||||
|
cc?: string | string[];
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
html?: string;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
mail.setApiKey(sendgridAPIKey);
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
from: {
|
||||||
|
email: from,
|
||||||
|
name: "Cal.ai",
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await mail.send(msg);
|
||||||
|
const success = !!res;
|
||||||
|
|
||||||
|
return success;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default send;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { env } from "../env.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the request contains the correct parse key.
|
||||||
|
* env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
|
||||||
|
*/
|
||||||
|
export const verifyParseKey = (url: NextRequest["url"]) => {
|
||||||
|
const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
|
||||||
|
|
||||||
|
return verified;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@calcom/tsconfig/nextjs.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["*"]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import type { NextMiddleware } from "next-api-middleware";
|
import type { NextMiddleware } from "next-api-middleware";
|
||||||
|
|
||||||
import { CONSOLE_URL } from "@calcom/lib/constants";
|
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||||
|
import prisma, { customPrisma } from "@calcom/prisma";
|
||||||
|
|
||||||
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
|
const LOCAL_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL || CONSOLE_URL;
|
||||||
|
|
||||||
@@ -12,32 +12,41 @@ export const customPrismaClient: NextMiddleware = async (req, res, next) => {
|
|||||||
} = req;
|
} = req;
|
||||||
// If no custom api Id is provided, attach to request the regular cal.com prisma client.
|
// If no custom api Id is provided, attach to request the regular cal.com prisma client.
|
||||||
if (!key) {
|
if (!key) {
|
||||||
req.prisma = new PrismaClient();
|
req.prisma = prisma;
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a key, we check if the deployment matching the key, has a databaseUrl value set.
|
try {
|
||||||
const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`)
|
// If we have a key, we check if the deployment matching the key, has a databaseUrl value set.
|
||||||
.then((res) => res.json())
|
const databaseUrl = await fetch(`${LOCAL_CONSOLE_URL}/api/deployments/database?key=${key}`)
|
||||||
.then((res) => res.databaseUrl);
|
.then((res) => res.json())
|
||||||
|
.then((res) => res.databaseUrl);
|
||||||
|
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
|
res.status(400).json({ error: "no databaseUrl set up at your instance yet" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
req.prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
|
req.prisma = customPrisma({ datasources: { db: { url: databaseUrl } } });
|
||||||
/* @note:
|
/* @note:
|
||||||
In order to skip verifyApiKey for customPrisma requests,
|
In order to skip verifyApiKey for customPrisma requests,
|
||||||
we pass isAdmin true, and userId 0, if we detect them later,
|
we pass isAdmin true, and userId 0, if we detect them later,
|
||||||
we skip verifyApiKey logic and pass onto next middleware instead.
|
we skip verifyApiKey logic and pass onto next middleware instead.
|
||||||
*/
|
*/
|
||||||
req.isAdmin = true;
|
req.isAdmin = true;
|
||||||
req.isCustomPrisma = true;
|
req.isCustomPrisma = true;
|
||||||
// We don't need the key from here and on. Prevents unrecognized key errors.
|
// We don't need the key from here and on. Prevents unrecognized key errors.
|
||||||
delete req.query.key;
|
delete req.query.key;
|
||||||
await next();
|
await next();
|
||||||
await req.prisma.$disconnect();
|
await req.prisma.$disconnect();
|
||||||
// @ts-expect-error testing
|
// @ts-expect-error testing
|
||||||
delete req.prisma;
|
delete req.prisma;
|
||||||
|
} catch (err) {
|
||||||
|
if (req.prisma) {
|
||||||
|
await req.prisma.$disconnect();
|
||||||
|
// @ts-expect-error testing
|
||||||
|
delete req.prisma;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { NextMiddleware } from "next-api-middleware";
|
||||||
|
|
||||||
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
|
|
||||||
|
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
|
||||||
|
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||||
|
|
||||||
|
// TODO: Add a way to add trusted api keys
|
||||||
|
await checkRateLimitAndThrowError({
|
||||||
|
identifier: req.query.apiKey as string,
|
||||||
|
rateLimitingType: "api",
|
||||||
|
});
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
|||||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
|
||||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
import { isAdminGuard } from "../utils/isAdmin";
|
||||||
|
|
||||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||||
export const dateNotInPast = function (date: Date) {
|
export const dateNotInPast = function (date: Date) {
|
||||||
|
|||||||
@@ -12,24 +12,29 @@ import {
|
|||||||
HTTP_GET_OR_POST,
|
HTTP_GET_OR_POST,
|
||||||
HTTP_GET_DELETE_PATCH,
|
HTTP_GET_DELETE_PATCH,
|
||||||
} from "./httpMethods";
|
} from "./httpMethods";
|
||||||
|
import { rateLimitApiKey } from "./rateLimitApiKey";
|
||||||
import { verifyApiKey } from "./verifyApiKey";
|
import { verifyApiKey } from "./verifyApiKey";
|
||||||
import { withPagination } from "./withPagination";
|
import { withPagination } from "./withPagination";
|
||||||
|
|
||||||
const withMiddleware = label(
|
const middleware = {
|
||||||
{
|
HTTP_GET_OR_POST,
|
||||||
HTTP_GET_OR_POST,
|
HTTP_GET_DELETE_PATCH,
|
||||||
HTTP_GET_DELETE_PATCH,
|
HTTP_GET,
|
||||||
HTTP_GET,
|
HTTP_PATCH,
|
||||||
HTTP_PATCH,
|
HTTP_POST,
|
||||||
HTTP_POST,
|
HTTP_DELETE,
|
||||||
HTTP_DELETE,
|
addRequestId,
|
||||||
addRequestId,
|
verifyApiKey,
|
||||||
verifyApiKey,
|
rateLimitApiKey,
|
||||||
customPrismaClient,
|
customPrismaClient,
|
||||||
extendRequest,
|
extendRequest,
|
||||||
pagination: withPagination,
|
pagination: withPagination,
|
||||||
captureErrors,
|
captureErrors,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
type Middleware = keyof typeof middleware;
|
||||||
|
|
||||||
|
const middlewareOrder =
|
||||||
// The order here, determines the order of execution
|
// The order here, determines the order of execution
|
||||||
[
|
[
|
||||||
"extendRequest",
|
"extendRequest",
|
||||||
@@ -37,8 +42,10 @@ const withMiddleware = label(
|
|||||||
// - Put customPrismaClient before verifyApiKey always.
|
// - Put customPrismaClient before verifyApiKey always.
|
||||||
"customPrismaClient",
|
"customPrismaClient",
|
||||||
"verifyApiKey",
|
"verifyApiKey",
|
||||||
|
"rateLimitApiKey",
|
||||||
"addRequestId",
|
"addRequestId",
|
||||||
] // <-- Provide a list of middleware to call automatically
|
] as Middleware[]; // <-- Provide a list of middleware to call automatically
|
||||||
);
|
|
||||||
|
|
||||||
export { withMiddleware };
|
const withMiddleware = label(middleware, middlewareOrder);
|
||||||
|
|
||||||
|
export { withMiddleware, middleware, middlewareOrder };
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||||
|
|
||||||
|
export function extractUserIdsFromQuery({ isAdmin, query }: NextApiRequest) {
|
||||||
|
/** Guard: Only admins can query other users */
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new HttpError({ statusCode: 401, message: "ADMIN required" });
|
||||||
|
}
|
||||||
|
const { userId: userIdOrUserIds } = schemaQuerySingleOrMultipleUserIds.parse(query);
|
||||||
|
return Array.isArray(userIdOrUserIds) ? userIdOrUserIds : [userIdOrUserIds];
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function isValidBase64Image(input: string): boolean {
|
||||||
|
const regex = /^data:image\/[^;]+;base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||||
|
return regex.test(input);
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ export const schemaBookingReadPublic = Booking.extend({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
responses: z.record(z.any()).nullable(),
|
||||||
}).pick({
|
}).pick({
|
||||||
id: true,
|
id: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ const schemaDestinationCalendarCreateParams = z
|
|||||||
.object({
|
.object({
|
||||||
integration: z.string(),
|
integration: z.string(),
|
||||||
externalId: z.string(),
|
externalId: z.string(),
|
||||||
eventTypeId: z.number(),
|
eventTypeId: z.number().optional(),
|
||||||
bookingId: z.number(),
|
bookingId: z.number().optional(),
|
||||||
userId: z.number(),
|
userId: z.number().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ const hostSchema = _HostModel.pick({
|
|||||||
userId: true,
|
userId: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const childrenSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
userId: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
export const schemaEventTypeBaseBodyParams = EventType.pick({
|
export const schemaEventTypeBaseBodyParams = EventType.pick({
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
@@ -33,16 +38,19 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
|||||||
position: true,
|
position: true,
|
||||||
eventName: true,
|
eventName: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
|
schedulingType: true,
|
||||||
|
// START Limit future bookings
|
||||||
periodType: true,
|
periodType: true,
|
||||||
periodStartDate: true,
|
periodStartDate: true,
|
||||||
schedulingType: true,
|
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodDays: true,
|
periodDays: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
|
// END Limit future bookings
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
hideCalendarNotes: true,
|
hideCalendarNotes: true,
|
||||||
minimumBookingNotice: true,
|
minimumBookingNotice: true,
|
||||||
|
parentId: true,
|
||||||
beforeEventBuffer: true,
|
beforeEventBuffer: true,
|
||||||
afterEventBuffer: true,
|
afterEventBuffer: true,
|
||||||
teamId: true,
|
teamId: true,
|
||||||
@@ -51,8 +59,15 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
|
|||||||
slotInterval: true,
|
slotInterval: true,
|
||||||
successRedirectUrl: true,
|
successRedirectUrl: true,
|
||||||
locations: true,
|
locations: true,
|
||||||
|
bookingLimits: true,
|
||||||
|
durationLimits: true,
|
||||||
})
|
})
|
||||||
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
|
.merge(
|
||||||
|
z.object({
|
||||||
|
children: z.array(childrenSchema).optional().default([]),
|
||||||
|
hosts: z.array(hostSchema).optional().default([]),
|
||||||
|
})
|
||||||
|
)
|
||||||
.partial()
|
.partial()
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -66,8 +81,10 @@ const schemaEventTypeCreateParams = z
|
|||||||
recurringEvent: recurringEventInputSchema.optional(),
|
recurringEvent: recurringEventInputSchema.optional(),
|
||||||
seatsPerTimeSlot: z.number().optional(),
|
seatsPerTimeSlot: z.number().optional(),
|
||||||
seatsShowAttendees: z.boolean().optional(),
|
seatsShowAttendees: z.boolean().optional(),
|
||||||
|
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||||
bookingFields: eventTypeBookingFields.optional(),
|
bookingFields: eventTypeBookingFields.optional(),
|
||||||
scheduleId: z.number().optional(),
|
scheduleId: z.number().optional(),
|
||||||
|
parentId: z.number().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -85,6 +102,7 @@ const schemaEventTypeEditParams = z
|
|||||||
length: z.number().int().optional(),
|
length: z.number().int().optional(),
|
||||||
seatsPerTimeSlot: z.number().optional(),
|
seatsPerTimeSlot: z.number().optional(),
|
||||||
seatsShowAttendees: z.boolean().optional(),
|
seatsShowAttendees: z.boolean().optional(),
|
||||||
|
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||||
bookingFields: eventTypeBookingFields.optional(),
|
bookingFields: eventTypeBookingFields.optional(),
|
||||||
scheduleId: z.number().optional(),
|
scheduleId: z.number().optional(),
|
||||||
})
|
})
|
||||||
@@ -119,15 +137,21 @@ export const schemaEventTypeReadPublic = EventType.pick({
|
|||||||
price: true,
|
price: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
slotInterval: true,
|
slotInterval: true,
|
||||||
|
parentId: true,
|
||||||
successRedirectUrl: true,
|
successRedirectUrl: true,
|
||||||
description: true,
|
description: true,
|
||||||
locations: true,
|
locations: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
seatsPerTimeSlot: true,
|
seatsPerTimeSlot: true,
|
||||||
seatsShowAttendees: true,
|
seatsShowAttendees: true,
|
||||||
|
seatsShowAvailabilityCount: true,
|
||||||
bookingFields: true,
|
bookingFields: true,
|
||||||
|
bookingLimits: true,
|
||||||
|
durationLimits: true,
|
||||||
}).merge(
|
}).merge(
|
||||||
z.object({
|
z.object({
|
||||||
|
children: z.array(childrenSchema).optional().default([]),
|
||||||
|
hosts: z.array(hostSchema).optional().default([]),
|
||||||
locations: z
|
locations: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
|
|||||||
teamId: z.number(),
|
teamId: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const membershipCreateBodySchema = Membership.partial({
|
export const membershipCreateBodySchema = Membership.omit({ id: true })
|
||||||
accepted: true,
|
.partial({
|
||||||
role: true,
|
accepted: true,
|
||||||
disableImpersonation: true,
|
role: true,
|
||||||
}).transform((v) => ({
|
disableImpersonation: true,
|
||||||
accepted: false,
|
})
|
||||||
role: MembershipRole.MEMBER,
|
.transform((v) => ({
|
||||||
disableImpersonation: false,
|
accepted: false,
|
||||||
...v,
|
role: MembershipRole.MEMBER,
|
||||||
}));
|
disableImpersonation: false,
|
||||||
|
...v,
|
||||||
|
}));
|
||||||
|
|
||||||
export const membershipEditBodySchema = Membership.omit({
|
export const membershipEditBodySchema = Membership.omit({
|
||||||
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { withValidation } from "next-validations";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { baseApiParams } from "./baseApiParams";
|
||||||
|
|
||||||
|
// Extracted out as utility function so can be reused
|
||||||
|
// at different endpoints that require this validation.
|
||||||
|
export const schemaQueryUserEmail = baseApiParams.extend({
|
||||||
|
email: z.string().email(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const schemaQuerySingleOrMultipleUserEmails = z.object({
|
||||||
|
email: z.union([z.string().email(), z.array(z.string().email())]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const withValidQueryUserEmail = withValidation({
|
||||||
|
schema: schemaQueryUserEmail,
|
||||||
|
type: "Zod",
|
||||||
|
mode: "query",
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ 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";
|
||||||
|
|
||||||
|
import { isValidBase64Image } from "~/lib/utils/isValidBase64Image";
|
||||||
import { timeZone } from "~/lib/validations/shared/timeZone";
|
import { timeZone } from "~/lib/validations/shared/timeZone";
|
||||||
|
|
||||||
// @note: These are the ONLY values allowed as weekStart. So user don't introduce bad data.
|
// @note: These are the ONLY values allowed as weekStart. So user don't introduce bad data.
|
||||||
@@ -75,6 +76,7 @@ export const schemaUserBaseBodyParams = User.pick({
|
|||||||
theme: true,
|
theme: true,
|
||||||
defaultScheduleId: true,
|
defaultScheduleId: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
|
hideBranding: true,
|
||||||
timeFormat: true,
|
timeFormat: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
@@ -95,6 +97,7 @@ const schemaUserEditParams = z.object({
|
|||||||
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(),
|
||||||
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||||
|
hideBranding: z.boolean().optional(),
|
||||||
timeZone: timeZone.optional(),
|
timeZone: timeZone.optional(),
|
||||||
theme: z.nativeEnum(theme).optional().nullable(),
|
theme: z.nativeEnum(theme).optional().nullable(),
|
||||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||||
@@ -104,6 +107,7 @@ const schemaUserEditParams = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
locale: z.nativeEnum(locales).optional().nullable(),
|
locale: z.nativeEnum(locales).optional().nullable(),
|
||||||
|
avatar: z.string().refine(isValidBase64Image).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// @note: These are the values that are editable via PATCH method on the user Model,
|
// @note: These are the values that are editable via PATCH method on the user Model,
|
||||||
@@ -115,6 +119,7 @@ const schemaUserCreateParams = z.object({
|
|||||||
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(),
|
||||||
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||||
|
hideBranding: z.boolean().optional(),
|
||||||
timeZone: timeZone.optional(),
|
timeZone: timeZone.optional(),
|
||||||
theme: z.nativeEnum(theme).optional().nullable(),
|
theme: z.nativeEnum(theme).optional().nullable(),
|
||||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||||
@@ -125,6 +130,7 @@ const schemaUserCreateParams = z.object({
|
|||||||
.nullable(),
|
.nullable(),
|
||||||
locale: z.nativeEnum(locales).optional(),
|
locale: z.nativeEnum(locales).optional(),
|
||||||
createdDate: iso8601.optional(),
|
createdDate: iso8601.optional(),
|
||||||
|
avatar: z.string().refine(isValidBase64Image).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// @note: These are the values that are editable via PATCH method on the user Model,
|
// @note: These are the values that are editable via PATCH method on the user Model,
|
||||||
@@ -157,6 +163,7 @@ export const schemaUserReadPublic = User.pick({
|
|||||||
defaultScheduleId: true,
|
defaultScheduleId: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
timeFormat: true,
|
timeFormat: true,
|
||||||
|
hideBranding: true,
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
allowDynamicBooking: true,
|
allowDynamicBooking: true,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const schemaWebhookCreateParams = z
|
|||||||
payloadTemplate: z.string().optional().nullable(),
|
payloadTemplate: z.string().optional().nullable(),
|
||||||
eventTypeId: z.number().optional(),
|
eventTypeId: z.number().optional(),
|
||||||
userId: z.number().optional(),
|
userId: z.number().optional(),
|
||||||
|
secret: z.string().optional().nullable(),
|
||||||
// API shouldn't mess with Apps webhooks yet (ie. Zapier)
|
// API shouldn't mess with Apps webhooks yet (ie. Zapier)
|
||||||
// appId: z.string().optional().nullable(),
|
// appId: z.string().optional().nullable(),
|
||||||
})
|
})
|
||||||
@@ -31,6 +32,7 @@ export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams
|
|||||||
.merge(
|
.merge(
|
||||||
z.object({
|
z.object({
|
||||||
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
|
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
|
||||||
|
secret: z.string().optional().nullable(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.partial()
|
.partial()
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import type { NextApiRequest as BaseNextApiRequest } from "next/types";
|
import type { NextApiRequest as BaseNextApiRequest } from "next/types";
|
||||||
|
|
||||||
import type { PrismaClient } from "@calcom/prisma/client";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
export type * from "next/types";
|
export type * from "next/types";
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"lint": "eslint . --ignore-path .gitignore",
|
"lint": "eslint . --ignore-path .gitignore",
|
||||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||||
"start": "PORT=3002 next start",
|
"start": "PORT=3002 next start",
|
||||||
|
"docker-start-api": "PORT=80 next start",
|
||||||
"type-check": "tsc --pretty --noEmit"
|
"type-check": "tsc --pretty --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
"@calcom/lib": "*",
|
"@calcom/lib": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "*",
|
||||||
"@sentry/nextjs": "^7.20.0",
|
"@sentry/nextjs": "^7.73.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "^13.4.6",
|
"next": "^13.4.6",
|
||||||
@@ -40,6 +41,6 @@
|
|||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"tzdata": "^1.0.30",
|
"tzdata": "^1.0.30",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.22.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /availability:
|
* /teams/{teamId}/availability:
|
||||||
* get:
|
* get:
|
||||||
* summary: Find user or team availability
|
* summary: Find team availability
|
||||||
* parameters:
|
* parameters:
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: apiKey
|
* name: apiKey
|
||||||
@@ -21,25 +21,14 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
* type: string
|
* type: string
|
||||||
* example: "1234abcd5678efgh"
|
* example: "1234abcd5678efgh"
|
||||||
* description: Your API key
|
* description: Your API key
|
||||||
* - in: query
|
* - in: path
|
||||||
* name: userId
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* example: 101
|
|
||||||
* description: ID of the user to fetch the availability for
|
|
||||||
* - in: query
|
|
||||||
* name: teamId
|
* name: teamId
|
||||||
|
* required: true
|
||||||
* schema:
|
* schema:
|
||||||
* type: integer
|
* type: integer
|
||||||
* example: 123
|
* example: 123
|
||||||
* description: ID of the team to fetch the availability for
|
* description: ID of the team to fetch the availability for
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: username
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* example: "alice"
|
|
||||||
* description: username of the user to fetch the availability for
|
|
||||||
* - in: query
|
|
||||||
* name: dateFrom
|
* name: dateFrom
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
@@ -59,7 +48,7 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
* type: integer
|
* type: integer
|
||||||
* example: 123
|
* example: 123
|
||||||
* description: Event Type ID of the event type to fetch the availability for
|
* description: Event Type ID of the event type to fetch the availability for
|
||||||
* operationId: availability
|
* operationId: team-availability
|
||||||
* tags:
|
* tags:
|
||||||
* - availability
|
* - availability
|
||||||
* responses:
|
* responses:
|
||||||
@@ -96,7 +85,89 @@ import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
|||||||
* 401:
|
* 401:
|
||||||
* description: Authorization information is missing or invalid.
|
* description: Authorization information is missing or invalid.
|
||||||
* 404:
|
* 404:
|
||||||
* description: User not found | Team not found | Team has no members
|
* description: Team not found | Team has no members
|
||||||
|
*
|
||||||
|
* /availability:
|
||||||
|
* get:
|
||||||
|
* summary: Find user availability
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* example: "1234abcd5678efgh"
|
||||||
|
* description: Your API key
|
||||||
|
* - in: query
|
||||||
|
* name: userId
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* example: 101
|
||||||
|
* description: ID of the user to fetch the availability for
|
||||||
|
* - in: query
|
||||||
|
* name: username
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* example: "alice"
|
||||||
|
* description: username of the user to fetch the availability for
|
||||||
|
* - in: query
|
||||||
|
* name: dateFrom
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: date
|
||||||
|
* example: "2023-05-14 00:00:00"
|
||||||
|
* description: Start Date of the availability query
|
||||||
|
* - in: query
|
||||||
|
* name: dateTo
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: date
|
||||||
|
* example: "2023-05-20 00:00:00"
|
||||||
|
* description: End Date of the availability query
|
||||||
|
* - in: query
|
||||||
|
* name: eventTypeId
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* example: 123
|
||||||
|
* description: Event Type ID of the event type to fetch the availability for
|
||||||
|
* operationId: user-availability
|
||||||
|
* tags:
|
||||||
|
* - availability
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* example:
|
||||||
|
* busy:
|
||||||
|
* - start: "2023-05-14T10:00:00.000Z"
|
||||||
|
* end: "2023-05-14T11:00:00.000Z"
|
||||||
|
* title: "Team meeting between Alice and Bob"
|
||||||
|
* - start: "2023-05-15T14:00:00.000Z"
|
||||||
|
* end: "2023-05-15T15:00:00.000Z"
|
||||||
|
* title: "Project review between Carol and Dave"
|
||||||
|
* - start: "2023-05-16T09:00:00.000Z"
|
||||||
|
* end: "2023-05-16T10:00:00.000Z"
|
||||||
|
* - start: "2023-05-17T13:00:00.000Z"
|
||||||
|
* end: "2023-05-17T14:00:00.000Z"
|
||||||
|
* timeZone: "America/New_York"
|
||||||
|
* workingHours:
|
||||||
|
* - days: [1, 2, 3, 4, 5]
|
||||||
|
* startTime: 540
|
||||||
|
* endTime: 1020
|
||||||
|
* userId: 101
|
||||||
|
* dateOverrides:
|
||||||
|
* - date: "2023-05-15"
|
||||||
|
* startTime: 600
|
||||||
|
* endTime: 960
|
||||||
|
* userId: 101
|
||||||
|
* currentSeats: 4
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: User not found
|
||||||
*/
|
*/
|
||||||
interface MemberRoles {
|
interface MemberRoles {
|
||||||
[userId: number | string]: MembershipRole;
|
[userId: number | string]: MembershipRole;
|
||||||
|
|||||||
@@ -56,10 +56,6 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
|||||||
* <td>The provided id didn't correspond to any existing booking.</td>
|
* <td>The provided id didn't correspond to any existing booking.</td>
|
||||||
* </tr>
|
* </tr>
|
||||||
* <tr>
|
* <tr>
|
||||||
* <td>Cannot cancel past events</td>
|
|
||||||
* <td>The provided id matched an existing booking with a past startDate.</td>
|
|
||||||
* </tr>
|
|
||||||
* <tr>
|
|
||||||
* <td>User not found</td>
|
* <td>User not found</td>
|
||||||
* <td>The userId did not matched an existing user.</td>
|
* <td>The userId did not matched an existing user.</td>
|
||||||
* </tr>
|
* </tr>
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ import { defaultResponder } from "@calcom/lib/server";
|
|||||||
* seatsShowAttendees:
|
* seatsShowAttendees:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: 'Share Attendee information in seats'
|
* description: 'Share Attendee information in seats'
|
||||||
|
* seatsShowAvailabilityCount:
|
||||||
|
* type: boolean
|
||||||
|
* description: 'Show the number of available seats'
|
||||||
* smsReminderNumber:
|
* smsReminderNumber:
|
||||||
* type: number
|
* type: number
|
||||||
* description: 'SMS reminder number'
|
* description: 'SMS reminder number'
|
||||||
@@ -202,8 +205,8 @@ import { defaultResponder } from "@calcom/lib/server";
|
|||||||
async function handler(req: NextApiRequest) {
|
async function handler(req: NextApiRequest) {
|
||||||
const { userId, isAdmin } = req;
|
const { userId, isAdmin } = req;
|
||||||
if (isAdmin) req.userId = req.body.userId || userId;
|
if (isAdmin) req.userId = req.body.userId || userId;
|
||||||
const booking = await handleNewBooking(req);
|
|
||||||
return booking;
|
return await handleNewBooking(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultResponder(handler);
|
export default defaultResponder(handler);
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
|
||||||
import type { DestinationCalendarResponse } from "~/lib/types";
|
|
||||||
import {
|
|
||||||
schemaDestinationCalendarEditBodyParams,
|
|
||||||
schemaDestinationCalendarReadPublic,
|
|
||||||
} from "~/lib/validations/destination-calendar";
|
|
||||||
import {
|
|
||||||
schemaQueryIdParseInt,
|
|
||||||
withValidQueryIdTransformParseInt,
|
|
||||||
} from "~/lib/validations/shared/queryIdTransformParseInt";
|
|
||||||
|
|
||||||
export async function destionationCalendarById(
|
|
||||||
{ method, query, body, userId, prisma }: NextApiRequest,
|
|
||||||
res: NextApiResponse<DestinationCalendarResponse>
|
|
||||||
) {
|
|
||||||
const safeQuery = schemaQueryIdParseInt.safeParse(query);
|
|
||||||
const safeBody = schemaDestinationCalendarEditBodyParams.safeParse(body);
|
|
||||||
if (!safeQuery.success) {
|
|
||||||
res.status(400).json({ message: "Your query was invalid" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await prisma.destinationCalendar.findMany({ where: { userId } });
|
|
||||||
const userDestinationCalendars = data.map((destinationCalendar) => destinationCalendar.id);
|
|
||||||
// FIXME: Should we also check ownership of bokingId and eventTypeId to avoid users cross-pollinating other users calendars.
|
|
||||||
// On a related note, moving from sequential integer IDs to UUIDs would be a good idea. and maybe help avoid having this problem.
|
|
||||||
if (userDestinationCalendars.includes(safeQuery.data.id)) res.status(401).json({ message: "Unauthorized" });
|
|
||||||
else {
|
|
||||||
switch (method) {
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /destination-calendars/{id}:
|
|
||||||
* get:
|
|
||||||
* summary: Find a destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to get
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: OK
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
* 404:
|
|
||||||
* description: DestinationCalendar was not found
|
|
||||||
* patch:
|
|
||||||
* summary: Edit an existing destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to edit
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* requestBody:
|
|
||||||
* description: Create a new booking related to one of your event-types
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* integration:
|
|
||||||
* type: string
|
|
||||||
* description: 'The integration'
|
|
||||||
* externalId:
|
|
||||||
* type: string
|
|
||||||
* description: 'The external ID of the integration'
|
|
||||||
* eventTypeId:
|
|
||||||
* type: integer
|
|
||||||
* description: 'The ID of the eventType it is associated with'
|
|
||||||
* bookingId:
|
|
||||||
* type: integer
|
|
||||||
* description: 'The booking ID it is associated with'
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destinationCalendar edited successfuly
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar body is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
* delete:
|
|
||||||
* summary: Remove an existing destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to delete
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destinationCalendar removed successfuly
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar id is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
*/
|
|
||||||
case "GET":
|
|
||||||
await prisma.destinationCalendar
|
|
||||||
.findUnique({ where: { id: safeQuery.data.id } })
|
|
||||||
.then((data) => schemaDestinationCalendarReadPublic.parse(data))
|
|
||||||
.then((destination_calendar) => res.status(200).json({ destination_calendar }))
|
|
||||||
.catch((error: Error) =>
|
|
||||||
res.status(404).json({
|
|
||||||
message: `DestinationCalendar with id: ${safeQuery.data.id} not found`,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /destination-calendars/{id}:
|
|
||||||
* patch:
|
|
||||||
* summary: Edit an existing destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to edit
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destinationCalendar edited successfuly
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar body is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
*/
|
|
||||||
case "PATCH":
|
|
||||||
if (!safeBody.success) {
|
|
||||||
{
|
|
||||||
res.status(400).json({ message: "Invalid request body" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await prisma.destinationCalendar
|
|
||||||
.update({ where: { id: safeQuery.data.id }, data: safeBody.data })
|
|
||||||
.then((data) => schemaDestinationCalendarReadPublic.parse(data))
|
|
||||||
.then((destination_calendar) => res.status(200).json({ destination_calendar }))
|
|
||||||
.catch((error: Error) =>
|
|
||||||
res.status(404).json({
|
|
||||||
message: `DestinationCalendar with id: ${safeQuery.data.id} not found`,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /destination-calendars/{id}:
|
|
||||||
* delete:
|
|
||||||
* summary: Remove an existing destination calendar
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* required: true
|
|
||||||
* description: ID of the destination calendar to delete
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destinationCalendar removed successfuly
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar id is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
*/
|
|
||||||
case "DELETE":
|
|
||||||
await prisma.destinationCalendar
|
|
||||||
.delete({
|
|
||||||
where: { id: safeQuery.data.id },
|
|
||||||
})
|
|
||||||
.then(() =>
|
|
||||||
res.status(200).json({
|
|
||||||
message: `DestinationCalendar with id: ${safeQuery.data.id} deleted`,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch((error: Error) =>
|
|
||||||
res.status(404).json({
|
|
||||||
message: `DestinationCalendar with id: ${safeQuery.data.id} not found`,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
res.status(405).json({ message: "Method not allowed" });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withMiddleware("HTTP_GET_DELETE_PATCH")(
|
|
||||||
withValidQueryIdTransformParseInt(destionationCalendarById)
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
async function authMiddleware(req: NextApiRequest) {
|
||||||
|
const { userId, isAdmin, prisma } = req;
|
||||||
|
const { id } = schemaQueryIdParseInt.parse(req.query);
|
||||||
|
if (isAdmin) return;
|
||||||
|
const userEventTypes = await prisma.eventType.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const userEventTypeIds = userEventTypes.map((eventType) => eventType.id);
|
||||||
|
|
||||||
|
const destinationCalendar = await prisma.destinationCalendar.findFirst({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
OR: [{ userId }, { eventTypeId: { in: userEventTypeIds } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!destinationCalendar)
|
||||||
|
throw new HttpError({ statusCode: 404, message: "Destination calendar not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default authMiddleware;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars/{id}:
|
||||||
|
* delete:
|
||||||
|
* summary: Remove an existing destination calendar
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* required: true
|
||||||
|
* description: ID of the destination calendar to delete
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK, destinationCalendar removed successfully
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: Destination calendar not found
|
||||||
|
*/
|
||||||
|
export async function deleteHandler(req: NextApiRequest) {
|
||||||
|
const { prisma, query } = req;
|
||||||
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
|
await prisma.destinationCalendar.delete({ where: { id } });
|
||||||
|
return { message: `OK, Destination Calendar removed successfully` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(deleteHandler);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar";
|
||||||
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Find a destination calendar
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* required: true
|
||||||
|
* description: ID of the destination calendar to get
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: Destination calendar not found
|
||||||
|
*/
|
||||||
|
export async function getHandler(req: NextApiRequest) {
|
||||||
|
const { prisma, query } = req;
|
||||||
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
|
|
||||||
|
const destinationCalendar = await prisma.destinationCalendar.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse({ ...destinationCalendar }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(getHandler);
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||||
|
|
||||||
|
import {
|
||||||
|
schemaDestinationCalendarEditBodyParams,
|
||||||
|
schemaDestinationCalendarReadPublic,
|
||||||
|
} from "~/lib/validations/destination-calendar";
|
||||||
|
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars/{id}:
|
||||||
|
* patch:
|
||||||
|
* summary: Edit an existing destination calendar
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* required: true
|
||||||
|
* description: ID of the destination calendar to edit
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* requestBody:
|
||||||
|
* description: Create a new booking related to one of your event-types
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* integration:
|
||||||
|
* type: string
|
||||||
|
* description: 'The integration'
|
||||||
|
* externalId:
|
||||||
|
* type: string
|
||||||
|
* description: 'The external ID of the integration'
|
||||||
|
* eventTypeId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The ID of the eventType it is associated with'
|
||||||
|
* bookingId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The booking ID it is associated with'
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: Destination calendar not found
|
||||||
|
*/
|
||||||
|
type DestinationCalendarType = {
|
||||||
|
userId?: number | null;
|
||||||
|
eventTypeId?: number | null;
|
||||||
|
credentialId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserCredentialType = {
|
||||||
|
id: number;
|
||||||
|
appId: string | null;
|
||||||
|
type: string;
|
||||||
|
userId: number | null;
|
||||||
|
user: {
|
||||||
|
email: string;
|
||||||
|
} | null;
|
||||||
|
teamId: number | null;
|
||||||
|
key: Prisma.JsonValue;
|
||||||
|
invalid: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function patchHandler(req: NextApiRequest) {
|
||||||
|
const { userId, isAdmin, prisma, query, body } = req;
|
||||||
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
|
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
|
||||||
|
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
|
||||||
|
|
||||||
|
validateIntegrationInput(parsedBody);
|
||||||
|
const destinationCalendarObject: DestinationCalendarType = await getDestinationCalendar(id, prisma);
|
||||||
|
await validateRequestAndOwnership({ destinationCalendarObject, parsedBody, assignedUserId, prisma });
|
||||||
|
|
||||||
|
const userCredentials = await getUserCredentials({
|
||||||
|
credentialId: destinationCalendarObject.credentialId,
|
||||||
|
userId: assignedUserId,
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
const credentialId = await verifyCredentialsAndGetId({
|
||||||
|
parsedBody,
|
||||||
|
userCredentials,
|
||||||
|
currentCredentialId: destinationCalendarObject.credentialId,
|
||||||
|
});
|
||||||
|
// If the user has passed eventTypeId, we need to remove userId from the update data to make sure we don't link it to user as well
|
||||||
|
if (parsedBody.eventTypeId) parsedBody.userId = undefined;
|
||||||
|
const destinationCalendar = await prisma.destinationCalendar.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ...parsedBody, credentialId },
|
||||||
|
});
|
||||||
|
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves user credentials associated with a given credential ID and user ID and validates if the credentials belong to this user
|
||||||
|
*
|
||||||
|
* @param credentialId - The ID of the credential to fetch. If not provided, an error is thrown.
|
||||||
|
* @param userId - The user ID against which the credentials need to be verified.
|
||||||
|
* @param prisma - An instance of PrismaClient for database operations.
|
||||||
|
*
|
||||||
|
* @returns - An array containing the matching user credentials.
|
||||||
|
*
|
||||||
|
* @throws HttpError - If `credentialId` is not provided or no associated credentials are found in the database.
|
||||||
|
*/
|
||||||
|
async function getUserCredentials({
|
||||||
|
credentialId,
|
||||||
|
userId,
|
||||||
|
prisma,
|
||||||
|
}: {
|
||||||
|
credentialId: number | null;
|
||||||
|
userId: number;
|
||||||
|
prisma: PrismaClient;
|
||||||
|
}) {
|
||||||
|
if (!credentialId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: `Destination calendar missing credential id`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const userCredentials = await prisma.credential.findMany({
|
||||||
|
where: { id: credentialId, userId },
|
||||||
|
select: credentialForCalendarServiceSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userCredentials || userCredentials.length === 0) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `Bad request, no associated credentials found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return userCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the provided credentials and retrieves the associated credential ID.
|
||||||
|
*
|
||||||
|
* This function checks if the `integration` and `externalId` properties from the parsed body are present.
|
||||||
|
* If both properties exist, it fetches the connected calendar credentials using the provided user credentials
|
||||||
|
* and checks for a matching external ID and integration from the list of connected calendars.
|
||||||
|
*
|
||||||
|
* If a match is found, it updates the `credentialId` with the one from the connected calendar.
|
||||||
|
* Otherwise, it throws an HTTP error with a 400 status indicating an invalid credential ID.
|
||||||
|
*
|
||||||
|
* If the parsed body does not contain the necessary properties, the function
|
||||||
|
* returns the `credentialId` from the destination calendar object.
|
||||||
|
*
|
||||||
|
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
|
||||||
|
* Checked if it contain properties like `integration` and `externalId`.
|
||||||
|
* @param userCredentials - An array of user credentials used to fetch the connected calendar credentials.
|
||||||
|
* @param destinationCalendarObject - An object representing the destination calendar. Primarily used
|
||||||
|
* to fetch the default `credentialId`.
|
||||||
|
*
|
||||||
|
* @returns - The verified `credentialId` either from the matched connected calendar in case of updating the destination calendar,
|
||||||
|
* or the provided destination calendar object in other cases.
|
||||||
|
*
|
||||||
|
* @throws HttpError - If no matching connected calendar is found for the given `integration` and `externalId`.
|
||||||
|
*/
|
||||||
|
async function verifyCredentialsAndGetId({
|
||||||
|
parsedBody,
|
||||||
|
userCredentials,
|
||||||
|
currentCredentialId,
|
||||||
|
}: {
|
||||||
|
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
|
||||||
|
userCredentials: UserCredentialType[];
|
||||||
|
currentCredentialId: number | null;
|
||||||
|
}) {
|
||||||
|
if (parsedBody.integration && parsedBody.externalId) {
|
||||||
|
const calendarCredentials = getCalendarCredentials(userCredentials);
|
||||||
|
|
||||||
|
const { connectedCalendars } = await getConnectedCalendars(
|
||||||
|
calendarCredentials,
|
||||||
|
[],
|
||||||
|
parsedBody.externalId
|
||||||
|
);
|
||||||
|
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
|
||||||
|
const calendar = eligibleCalendars?.find(
|
||||||
|
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!calendar?.credentialId)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Bad request, credential id invalid",
|
||||||
|
});
|
||||||
|
return calendar?.credentialId;
|
||||||
|
}
|
||||||
|
return currentCredentialId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the request for updating a destination calendar.
|
||||||
|
*
|
||||||
|
* This function checks the validity of the provided eventTypeId against the existing destination calendar object
|
||||||
|
* in the sense that if the destination calendar is not linked to an event type, the eventTypeId can not be provided.
|
||||||
|
*
|
||||||
|
* It also ensures that the eventTypeId, if provided, belongs to the assigned user.
|
||||||
|
*
|
||||||
|
* @param destinationCalendarObject - An object representing the destination calendar.
|
||||||
|
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
|
||||||
|
* @param assignedUserId - The user ID assigned for the operation, which might be an admin or a regular user.
|
||||||
|
* @param prisma - An instance of PrismaClient for database operations.
|
||||||
|
*
|
||||||
|
* @throws HttpError - If the validation fails or inconsistencies are detected in the request data.
|
||||||
|
*/
|
||||||
|
async function validateRequestAndOwnership({
|
||||||
|
destinationCalendarObject,
|
||||||
|
parsedBody,
|
||||||
|
assignedUserId,
|
||||||
|
prisma,
|
||||||
|
}: {
|
||||||
|
destinationCalendarObject: DestinationCalendarType;
|
||||||
|
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
|
||||||
|
assignedUserId: number;
|
||||||
|
prisma: PrismaClient;
|
||||||
|
}) {
|
||||||
|
if (parsedBody.eventTypeId) {
|
||||||
|
if (!destinationCalendarObject.eventTypeId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `The provided destination calendar can not be linked to an event type`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEventType = await prisma.eventType.findFirst({
|
||||||
|
where: { id: parsedBody.eventTypeId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userEventType || userEventType.userId !== assignedUserId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: `Event type with ID ${parsedBody.eventTypeId} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedBody.eventTypeId) {
|
||||||
|
if (destinationCalendarObject.eventTypeId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `The provided destination calendar can only be linked to an event type`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (destinationCalendarObject.userId !== assignedUserId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: `Forbidden`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the destination calendar based on the provided ID as the path parameter, specifically `credentialId` and `eventTypeId`.
|
||||||
|
*
|
||||||
|
* If no matching destination calendar is found for the provided ID, an HTTP error with a 404 status
|
||||||
|
* indicating that the desired destination calendar was not found is thrown.
|
||||||
|
*
|
||||||
|
* @param id - The ID of the destination calendar to be retrieved.
|
||||||
|
* @param prisma - An instance of PrismaClient for database operations.
|
||||||
|
*
|
||||||
|
* @returns - An object containing details of the matching destination calendar, specifically `credentialId` and `eventTypeId`.
|
||||||
|
*
|
||||||
|
* @throws HttpError - If no destination calendar matches the provided ID.
|
||||||
|
*/
|
||||||
|
async function getDestinationCalendar(id: number, prisma: PrismaClient) {
|
||||||
|
const destinationCalendarObject = await prisma.destinationCalendar.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
select: { userId: true, eventTypeId: true, credentialId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!destinationCalendarObject) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: `Destination calendar with ID ${id} not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return destinationCalendarObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateIntegrationInput(parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>) {
|
||||||
|
if (parsedBody.integration && !parsedBody.externalId) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: "External Id is required with integration value" });
|
||||||
|
}
|
||||||
|
if (!parsedBody.integration && parsedBody.externalId) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: "Integration value is required with external ID" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(patchHandler);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
|
|
||||||
|
import authMiddleware from "./_auth-middleware";
|
||||||
|
|
||||||
|
export default withMiddleware()(
|
||||||
|
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
await authMiddleware(req);
|
||||||
|
return defaultHandler({
|
||||||
|
GET: import("./_get"),
|
||||||
|
PATCH: import("./_patch"),
|
||||||
|
DELETE: import("./_delete"),
|
||||||
|
})(req, res);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
|
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
|
||||||
|
import { schemaDestinationCalendarReadPublic } from "~/lib/validations/destination-calendar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars:
|
||||||
|
* get:
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* summary: Find all destination calendars
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: No destination calendars were found
|
||||||
|
*/
|
||||||
|
async function getHandler(req: NextApiRequest) {
|
||||||
|
const { userId, prisma } = req;
|
||||||
|
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
|
||||||
|
|
||||||
|
const userEventTypes = await prisma.eventType.findMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const userEventTypeIds = userEventTypes.map((eventType) => eventType.id);
|
||||||
|
|
||||||
|
const allDestinationCalendars = await prisma.destinationCalendar.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ userId: { in: userIds } }, { eventTypeId: { in: userEventTypeIds } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allDestinationCalendars.length === 0)
|
||||||
|
new HttpError({ statusCode: 404, message: "No destination calendars were found" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinationCalendars: allDestinationCalendars.map((destinationCalendar) =>
|
||||||
|
schemaDestinationCalendarReadPublic.parse(destinationCalendar)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(getHandler);
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||||
|
|
||||||
|
import {
|
||||||
|
schemaDestinationCalendarReadPublic,
|
||||||
|
schemaDestinationCalendarCreateBodyParams,
|
||||||
|
} from "~/lib/validations/destination-calendar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /destination-calendars:
|
||||||
|
* post:
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* summary: Creates a new destination calendar
|
||||||
|
* requestBody:
|
||||||
|
* description: Create a new destination calendar for your events
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - integration
|
||||||
|
* - externalId
|
||||||
|
* - credentialId
|
||||||
|
* properties:
|
||||||
|
* integration:
|
||||||
|
* type: string
|
||||||
|
* description: 'The integration'
|
||||||
|
* externalId:
|
||||||
|
* type: string
|
||||||
|
* description: 'The external ID of the integration'
|
||||||
|
* eventTypeId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The ID of the eventType it is associated with'
|
||||||
|
* bookingId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The booking ID it is associated with'
|
||||||
|
* userId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The user it is associated with'
|
||||||
|
* tags:
|
||||||
|
* - destination-calendars
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: OK, destination calendar created
|
||||||
|
* 400:
|
||||||
|
* description: Bad request. DestinationCalendar body is invalid.
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
*/
|
||||||
|
async function postHandler(req: NextApiRequest) {
|
||||||
|
const { userId, isAdmin, prisma, body } = req;
|
||||||
|
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
|
||||||
|
await checkPermissions(req, userId);
|
||||||
|
|
||||||
|
const assignedUserId = isAdmin && parsedBody.userId ? parsedBody.userId : userId;
|
||||||
|
|
||||||
|
/* Check if credentialId data matches the ownership and integration passed in */
|
||||||
|
const userCredentials = await prisma.credential.findMany({
|
||||||
|
where: {
|
||||||
|
type: parsedBody.integration,
|
||||||
|
userId: assignedUserId,
|
||||||
|
},
|
||||||
|
select: credentialForCalendarServiceSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userCredentials.length === 0)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Bad request, credential id invalid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendarCredentials = getCalendarCredentials(userCredentials);
|
||||||
|
|
||||||
|
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, [], parsedBody.externalId);
|
||||||
|
|
||||||
|
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
|
||||||
|
const calendar = eligibleCalendars?.find(
|
||||||
|
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
|
||||||
|
);
|
||||||
|
if (!calendar?.credentialId)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Bad request, credential id invalid",
|
||||||
|
});
|
||||||
|
const credentialId = calendar.credentialId;
|
||||||
|
|
||||||
|
if (parsedBody.eventTypeId) {
|
||||||
|
const eventType = await prisma.eventType.findFirst({
|
||||||
|
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
|
||||||
|
});
|
||||||
|
if (!eventType)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Bad request, eventTypeId invalid",
|
||||||
|
});
|
||||||
|
parsedBody.userId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination_calendar = await prisma.destinationCalendar.create({
|
||||||
|
data: { ...parsedBody, credentialId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
|
||||||
|
message: "Destination calendar created successfully",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPermissions(req: NextApiRequest, userId: number) {
|
||||||
|
const { isAdmin } = req;
|
||||||
|
const body = schemaDestinationCalendarCreateBodyParams.parse(req.body);
|
||||||
|
|
||||||
|
/* Non-admin users can only create destination calendars for themselves */
|
||||||
|
if (!isAdmin && body.userId)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: "ADMIN required for `userId`",
|
||||||
|
});
|
||||||
|
/* Admin users are required to pass in a userId */
|
||||||
|
if (isAdmin && !body.userId) throw new HttpError({ statusCode: 400, message: "`userId` required" });
|
||||||
|
/* User should only be able to create for their own destination calendars*/
|
||||||
|
if (!isAdmin && body.eventTypeId) {
|
||||||
|
const ownsEventType = await req.prisma.eventType.findFirst({ where: { id: body.eventTypeId, userId } });
|
||||||
|
if (!ownsEventType) throw new HttpError({ statusCode: 401, message: "Unauthorized" });
|
||||||
|
}
|
||||||
|
// TODO:: Add support for team event types with validation
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultResponder(postHandler);
|
||||||
@@ -1,114 +1,10 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import { defaultHandler } from "@calcom/lib/server";
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
import type { DestinationCalendarResponse, DestinationCalendarsResponse } from "~/lib/types";
|
|
||||||
import {
|
|
||||||
schemaDestinationCalendarCreateBodyParams,
|
|
||||||
schemaDestinationCalendarReadPublic,
|
|
||||||
} from "~/lib/validations/destination-calendar";
|
|
||||||
|
|
||||||
async function createOrlistAllDestinationCalendars(
|
export default withMiddleware()(
|
||||||
{ method, body, userId, prisma }: NextApiRequest,
|
defaultHandler({
|
||||||
res: NextApiResponse<DestinationCalendarsResponse | DestinationCalendarResponse>
|
GET: import("./_get"),
|
||||||
) {
|
POST: import("./_post"),
|
||||||
if (method === "GET") {
|
})
|
||||||
/**
|
);
|
||||||
* @swagger
|
|
||||||
* /destination-calendars:
|
|
||||||
* get:
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* summary: Find all destination calendars
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: OK
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
* 404:
|
|
||||||
* description: No destination calendars were found
|
|
||||||
*/
|
|
||||||
const data = await prisma.destinationCalendar.findMany({ where: { userId } });
|
|
||||||
const destination_calendars = data.map((destinationCalendar) =>
|
|
||||||
schemaDestinationCalendarReadPublic.parse(destinationCalendar)
|
|
||||||
);
|
|
||||||
if (data) res.status(200).json({ destination_calendars });
|
|
||||||
else
|
|
||||||
(error: Error) =>
|
|
||||||
res.status(404).json({
|
|
||||||
message: "No DestinationCalendars were found",
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
} else if (method === "POST") {
|
|
||||||
/**
|
|
||||||
* @swagger
|
|
||||||
* /destination-calendars:
|
|
||||||
* post:
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: apiKey
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Your API key
|
|
||||||
* summary: Creates a new destination calendar
|
|
||||||
* requestBody:
|
|
||||||
* description: Create a new destination calendar for your events
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* required:
|
|
||||||
* - integration
|
|
||||||
* - externalId
|
|
||||||
* properties:
|
|
||||||
* integration:
|
|
||||||
* type: string
|
|
||||||
* description: 'The integration'
|
|
||||||
* externalId:
|
|
||||||
* type: string
|
|
||||||
* description: 'The external ID of the integration'
|
|
||||||
* eventTypeId:
|
|
||||||
* type: integer
|
|
||||||
* description: 'The ID of the eventType it is associated with'
|
|
||||||
* bookingId:
|
|
||||||
* type: integer
|
|
||||||
* description: 'The booking ID it is associated with'
|
|
||||||
* tags:
|
|
||||||
* - destination-calendars
|
|
||||||
* responses:
|
|
||||||
* 201:
|
|
||||||
* description: OK, destination calendar created
|
|
||||||
* 400:
|
|
||||||
* description: Bad request. DestinationCalendar body is invalid.
|
|
||||||
* 401:
|
|
||||||
* description: Authorization information is missing or invalid.
|
|
||||||
*/
|
|
||||||
const safe = schemaDestinationCalendarCreateBodyParams.safeParse(body);
|
|
||||||
if (!safe.success) {
|
|
||||||
res.status(400).json({ message: "Invalid request body" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await prisma.destinationCalendar.create({ data: { ...safe.data, userId } });
|
|
||||||
const destination_calendar = schemaDestinationCalendarReadPublic.parse(data);
|
|
||||||
|
|
||||||
if (destination_calendar)
|
|
||||||
res.status(201).json({ destination_calendar, message: "DestinationCalendar created successfully" });
|
|
||||||
else
|
|
||||||
(error: Error) =>
|
|
||||||
res.status(400).json({
|
|
||||||
message: "Could not create new destinationCalendar",
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
} else res.status(405).json({ message: `Method ${method} not allowed` });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withMiddleware("HTTP_GET_OR_POST")(createOrlistAllDestinationCalendars);
|
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
customInputs: true,
|
customInputs: true,
|
||||||
team: { select: { slug: true } },
|
team: { select: { slug: true } },
|
||||||
users: true,
|
users: true,
|
||||||
|
hosts: { select: { userId: true, isFixed: true } },
|
||||||
owner: { select: { username: true, id: true } },
|
owner: { select: { username: true, id: true } },
|
||||||
|
children: { select: { id: true, userId: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await checkPermissions(req, eventType);
|
await checkPermissions(req, eventType);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
@@ -146,6 +146,9 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
|||||||
* seatsShowAttendees:
|
* seatsShowAttendees:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* description: 'Share Attendee information in seats'
|
* description: 'Share Attendee information in seats'
|
||||||
|
* seatsShowAvailabilityCount:
|
||||||
|
* type: boolean
|
||||||
|
* description: 'Show the number of available seats'
|
||||||
* locations:
|
* locations:
|
||||||
* type: array
|
* type: array
|
||||||
* description: A list of all available locations for the event type
|
* description: A list of all available locations for the event type
|
||||||
@@ -202,10 +205,19 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
|
|||||||
export async function patchHandler(req: NextApiRequest) {
|
export async function patchHandler(req: NextApiRequest) {
|
||||||
const { prisma, query, body } = req;
|
const { prisma, query, body } = req;
|
||||||
const { id } = schemaQueryIdParseInt.parse(query);
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
const { hosts = [], ...parsedBody } = schemaEventTypeEditBodyParams.parse(body);
|
const {
|
||||||
|
hosts = [],
|
||||||
|
bookingLimits,
|
||||||
|
durationLimits,
|
||||||
|
/** FIXME: Updating event-type children from API not supported for now */
|
||||||
|
children: _,
|
||||||
|
...parsedBody
|
||||||
|
} = schemaEventTypeEditBodyParams.parse(body);
|
||||||
|
|
||||||
const data: Prisma.EventTypeUpdateArgs["data"] = {
|
const data: Prisma.EventTypeUpdateArgs["data"] = {
|
||||||
...parsedBody,
|
...parsedBody,
|
||||||
|
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||||
|
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hosts) {
|
if (hosts) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
|
|||||||
|
|
||||||
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";
|
||||||
import type { PrismaClient } from "@calcom/prisma/client";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||||
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||||
@@ -45,7 +45,9 @@ async function getHandler(req: NextApiRequest) {
|
|||||||
customInputs: true,
|
customInputs: true,
|
||||||
team: { select: { slug: true } },
|
team: { select: { slug: true } },
|
||||||
users: true,
|
users: true,
|
||||||
|
hosts: { select: { userId: true, isFixed: true } },
|
||||||
owner: { select: { username: true, id: true } },
|
owner: { select: { username: true, id: true } },
|
||||||
|
children: { select: { id: true, userId: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// this really should return [], but backwards compatibility..
|
// this really should return [], but backwards compatibility..
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
@@ -6,7 +6,9 @@ import { defaultResponder } from "@calcom/lib/server";
|
|||||||
|
|
||||||
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||||
|
|
||||||
|
import checkParentEventOwnership from "./_utils/checkParentEventOwnership";
|
||||||
import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission";
|
import checkTeamEventEditPermission from "./_utils/checkTeamEventEditPermission";
|
||||||
|
import checkUserMembership from "./_utils/checkUserMembership";
|
||||||
import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,10 +120,13 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
|||||||
* schedulingType:
|
* schedulingType:
|
||||||
* type: string
|
* type: string
|
||||||
* description: The type of scheduling if a Team event. Required for team events only
|
* description: The type of scheduling if a Team event. Required for team events only
|
||||||
* enum: [ROUND_ROBIN, COLLECTIVE]
|
* enum: [ROUND_ROBIN, COLLECTIVE, MANAGED]
|
||||||
* price:
|
* price:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: Price of the event type booking
|
* description: Price of the event type booking
|
||||||
|
* parentId:
|
||||||
|
* type: integer
|
||||||
|
* description: EventTypeId of the parent managed event
|
||||||
* currency:
|
* currency:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Currency acronym. Eg- usd, eur, gbp, etc.
|
* description: Currency acronym. Eg- usd, eur, gbp, etc.
|
||||||
@@ -259,16 +264,30 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
|
|||||||
async function postHandler(req: NextApiRequest) {
|
async function postHandler(req: NextApiRequest) {
|
||||||
const { userId, isAdmin, prisma, body } = req;
|
const { userId, isAdmin, prisma, body } = req;
|
||||||
|
|
||||||
const { hosts = [], ...parsedBody } = schemaEventTypeCreateBodyParams.parse(body || {});
|
const {
|
||||||
|
hosts = [],
|
||||||
|
bookingLimits,
|
||||||
|
durationLimits,
|
||||||
|
/** FIXME: Adding event-type children from API not supported for now */
|
||||||
|
children: _,
|
||||||
|
...parsedBody
|
||||||
|
} = schemaEventTypeCreateBodyParams.parse(body || {});
|
||||||
|
|
||||||
let data: Prisma.EventTypeCreateArgs["data"] = {
|
let data: Prisma.EventTypeCreateArgs["data"] = {
|
||||||
...parsedBody,
|
...parsedBody,
|
||||||
userId,
|
userId,
|
||||||
users: { connect: { id: userId } },
|
users: { connect: { id: userId } },
|
||||||
|
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
|
||||||
|
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
|
||||||
};
|
};
|
||||||
|
|
||||||
await checkPermissions(req);
|
await checkPermissions(req);
|
||||||
|
|
||||||
|
if (parsedBody.parentId) {
|
||||||
|
await checkParentEventOwnership(req);
|
||||||
|
await checkUserMembership(req);
|
||||||
|
}
|
||||||
|
|
||||||
if (isAdmin && parsedBody.userId) {
|
if (isAdmin && parsedBody.userId) {
|
||||||
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
|
data = { ...parsedBody, users: { connect: { id: parsedBody.userId } } };
|
||||||
}
|
}
|
||||||
@@ -297,8 +316,9 @@ async function checkPermissions(req: NextApiRequest) {
|
|||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
message: "ADMIN required for `userId`",
|
message: "ADMIN required for `userId`",
|
||||||
});
|
});
|
||||||
/* Admin users are required to pass in a userId */
|
/* Admin users are required to pass in a userId or teamId */
|
||||||
if (isAdmin && !body.userId) throw new HttpError({ statusCode: 400, message: "`userId` required" });
|
if (isAdmin && (!body.userId || !body.teamId))
|
||||||
|
throw new HttpError({ statusCode: 400, message: "`userId` or `teamId` required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultResponder(postHandler);
|
export default defaultResponder(postHandler);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user, identified by the provided userId, has ownership (or admin rights) over
|
||||||
|
* the team associated with the event type identified by the parentId.
|
||||||
|
*
|
||||||
|
* @param req - The current request
|
||||||
|
*
|
||||||
|
* @throws {HttpError} If the parent event type is not found,
|
||||||
|
* if the parent event type doesn't belong to any team,
|
||||||
|
* or if the user doesn't have ownership or admin rights to the associated team.
|
||||||
|
*/
|
||||||
|
export default async function checkParentEventOwnership(req: NextApiRequest) {
|
||||||
|
const { userId, prisma, body } = req;
|
||||||
|
/** These are already parsed upstream, we can assume they're good here. */
|
||||||
|
const parentId = Number(body.parentId);
|
||||||
|
const parentEventType = await prisma.eventType.findUnique({
|
||||||
|
where: {
|
||||||
|
id: parentId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
teamId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parentEventType) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: "Parent event type not found.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentEventType.teamId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "This event type is not capable of having children",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamMember = await prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
teamId: parentEventType.teamId,
|
||||||
|
userId: userId,
|
||||||
|
OR: [{ role: "OWNER" }, { role: "ADMIN" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!teamMember) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: "User is not authorized to access the team to which the parent event type belongs.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user, identified by the provided userId, is a member of the team associated
|
||||||
|
* with the event type identified by the parentId.
|
||||||
|
*
|
||||||
|
* @param req - The current request
|
||||||
|
*
|
||||||
|
* @throws {HttpError} If the event type is not found,
|
||||||
|
* if the event type doesn't belong to any team,
|
||||||
|
* or if the user isn't a member of the associated team.
|
||||||
|
*/
|
||||||
|
export default async function checkUserMembership(req: NextApiRequest) {
|
||||||
|
const { prisma, body } = req;
|
||||||
|
/** These are already parsed upstream, we can assume they're good here. */
|
||||||
|
const parentId = Number(body.parentId);
|
||||||
|
const userId = Number(body.userId);
|
||||||
|
const parentEventType = await prisma.eventType.findUnique({
|
||||||
|
where: {
|
||||||
|
id: parentId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
teamId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parentEventType) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: "Event type not found.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentEventType.teamId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "This event type is not capable of having children.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamMember = await prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
teamId: parentEventType.teamId,
|
||||||
|
userId: userId,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!teamMember) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "User is not a team member.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
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";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { viewerRouter } from "@calcom/trpc/server/routers/viewer/_router";
|
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||||
|
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
/** @see https://trpc.io/docs/server-side-calls */
|
|
||||||
const ctx = await createContext({ req, res });
|
|
||||||
const caller = viewerRouter.createCaller(ctx);
|
|
||||||
try {
|
try {
|
||||||
|
const input = getScheduleSchema.parse(req.query);
|
||||||
|
return await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return await caller.slots.getSchedule(req.query as any /* Let tRPC handle this */);
|
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause instanceof TRPCError) {
|
if (cause instanceof TRPCError) {
|
||||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
|
|||||||
* timeZone:
|
* timeZone:
|
||||||
* description: The user's time zone
|
* description: The user's time zone
|
||||||
* type: string
|
* type: string
|
||||||
|
* hideBranding:
|
||||||
|
* description: Remove branding from the user's calendar page
|
||||||
|
* type: boolean
|
||||||
* theme:
|
* theme:
|
||||||
* description: Default theme for the user. Acceptable values are one of [DARK, LIGHT]
|
* description: Default theme for the user. Acceptable values are one of [DARK, LIGHT]
|
||||||
* type: string
|
* type: string
|
||||||
@@ -62,6 +65,9 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
|
|||||||
* locale:
|
* locale:
|
||||||
* description: The user's locale. Acceptable values are one of [EN, FR, IT, RU, ES, DE, PT, RO, NL, PT_BR, ES_419, KO, JA, PL, AR, IW, ZH_CH, ZH_TW, CS, SR, SV, VI]
|
* description: The user's locale. Acceptable values are one of [EN, FR, IT, RU, ES, DE, PT, RO, NL, PT_BR, ES_419, KO, JA, PL, AR, IW, ZH_CH, ZH_TW, CS, SR, SV, VI]
|
||||||
* type: string
|
* type: string
|
||||||
|
* avatar:
|
||||||
|
* description: The user's avatar, in base64 format
|
||||||
|
* type: string
|
||||||
* examples:
|
* examples:
|
||||||
* user:
|
* user:
|
||||||
* summary: An example of USER
|
* summary: An example of USER
|
||||||
@@ -79,7 +85,7 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
|
|||||||
* - users
|
* - users
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: OK, user edited successfuly
|
* description: OK, user edited successfully
|
||||||
* 400:
|
* 400:
|
||||||
* description: Bad request. User body is invalid.
|
* description: Bad request. User body is invalid.
|
||||||
* 401:
|
* 401:
|
||||||
@@ -94,9 +100,10 @@ export async function patchHandler(req: NextApiRequest) {
|
|||||||
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||||
|
|
||||||
const body = await schemaUserEditBodyParams.parseAsync(req.body);
|
const body = await schemaUserEditBodyParams.parseAsync(req.body);
|
||||||
// disable role changes unless admin.
|
// disable role or branding changes unless admin.
|
||||||
if (!isAdmin && body.role) {
|
if (!isAdmin) {
|
||||||
body.role = undefined;
|
if (body.role) body.role = undefined;
|
||||||
|
if (body.hideBranding) body.hideBranding = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSchedules = await prisma.schedule.findMany({
|
const userSchedules = await prisma.schedule.findMany({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { NextApiRequest } from "next";
|
|||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
import { withMiddleware } from "~/lib/helpers/withMiddleware";
|
||||||
|
import { schemaQuerySingleOrMultipleUserEmails } from "~/lib/validations/shared/queryUserEmail";
|
||||||
import { schemaUsersReadPublic } from "~/lib/validations/user";
|
import { schemaUsersReadPublic } from "~/lib/validations/user";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,17 @@ import { schemaUsersReadPublic } from "~/lib/validations/user";
|
|||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Your API key
|
* description: Your API key
|
||||||
|
* - in: query
|
||||||
|
* name: email
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* format: email
|
||||||
|
* style: form
|
||||||
|
* explode: true
|
||||||
|
* description: The email address or an array of email addresses to filter by
|
||||||
* tags:
|
* tags:
|
||||||
* - users
|
* - users
|
||||||
* responses:
|
* responses:
|
||||||
@@ -39,6 +51,14 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
const where: Prisma.UserWhereInput = {};
|
const where: Prisma.UserWhereInput = {};
|
||||||
// If user is not ADMIN, return only his data.
|
// If user is not ADMIN, return only his data.
|
||||||
if (!isAdmin) where.id = userId;
|
if (!isAdmin) where.id = userId;
|
||||||
|
|
||||||
|
if (req.query.email) {
|
||||||
|
const validationResult = schemaQuerySingleOrMultipleUserEmails.parse(req.query);
|
||||||
|
where.email = {
|
||||||
|
in: Array.isArray(validationResult.email) ? validationResult.email : [validationResult.email],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [total, data] = await prisma.$transaction([
|
const [total, data] = await prisma.$transaction([
|
||||||
prisma.user.count({ where }),
|
prisma.user.count({ where }),
|
||||||
prisma.user.findMany({ where, take, skip }),
|
prisma.user.findMany({ where, take, skip }),
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user";
|
|||||||
* darkBrandColor:
|
* darkBrandColor:
|
||||||
* description: The new user's brand color for dark mode
|
* description: The new user's brand color for dark mode
|
||||||
* type: string
|
* type: string
|
||||||
|
* hideBranding:
|
||||||
|
* description: Remove branding from the user's calendar page
|
||||||
|
* type: boolean
|
||||||
* weekStart:
|
* weekStart:
|
||||||
* description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]
|
* description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]
|
||||||
* type: string
|
* type: string
|
||||||
@@ -57,6 +60,9 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user";
|
|||||||
* locale:
|
* locale:
|
||||||
* description: The new user's locale. Acceptable values are one of [EN, FR, IT, RU, ES, DE, PT, RO, NL, PT_BR, ES_419, KO, JA, PL, AR, IW, ZH_CH, ZH_TW, CS, SR, SV, VI]
|
* description: The new user's locale. Acceptable values are one of [EN, FR, IT, RU, ES, DE, PT, RO, NL, PT_BR, ES_419, KO, JA, PL, AR, IW, ZH_CH, ZH_TW, CS, SR, SV, VI]
|
||||||
* type: string
|
* type: string
|
||||||
|
* avatar:
|
||||||
|
* description: The user's avatar, in base64 format
|
||||||
|
* type: string
|
||||||
* examples:
|
* examples:
|
||||||
* user:
|
* user:
|
||||||
* summary: An example of USER
|
* summary: An example of USER
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali
|
|||||||
* eventTypeId:
|
* eventTypeId:
|
||||||
* type: number
|
* type: number
|
||||||
* description: The event type ID if this webhook should be associated with only that event type
|
* description: The event type ID if this webhook should be associated with only that event type
|
||||||
|
* secret:
|
||||||
|
* type: string
|
||||||
|
* description: The secret to verify the authenticity of the received payload
|
||||||
* tags:
|
* tags:
|
||||||
* - webhooks
|
* - webhooks
|
||||||
* externalDocs:
|
* externalDocs:
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va
|
|||||||
* eventTypeId:
|
* eventTypeId:
|
||||||
* type: number
|
* type: number
|
||||||
* description: The event type ID if this webhook should be associated with only that event type
|
* description: The event type ID if this webhook should be associated with only that event type
|
||||||
|
* secret:
|
||||||
|
* type: string
|
||||||
|
* description: The secret to verify the authenticity of the received payload
|
||||||
* tags:
|
* tags:
|
||||||
* - webhooks
|
* - webhooks
|
||||||
* externalDocs:
|
* externalDocs:
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// TODO: Fix tests (These test were never running due to the vitest workspace config)
|
||||||
|
import prismaMock 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";
|
||||||
@@ -8,7 +11,6 @@ import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
|||||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
|
||||||
import handler from "../../../pages/api/bookings/_post";
|
import handler from "../../../pages/api/bookings/_post";
|
||||||
|
|
||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
@@ -20,7 +22,7 @@ vi.mock("@calcom/lib/server/i18n", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/bookings", () => {
|
describe.skipIf(true)("POST /api/bookings", () => {
|
||||||
describe("Errors", () => {
|
describe("Errors", () => {
|
||||||
test("Missing required data", async () => {
|
test("Missing required data", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
@@ -30,7 +32,7 @@ describe("POST /api/bookings", () => {
|
|||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { addRequestId } from "../../../lib/helpers/addRequestid";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Adds a request ID", () => {
|
||||||
|
it("Should attach a request ID to the request", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: addRequestId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.getHeader("Calcom-Response-ID")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { httpMethod } from "../../../lib/helpers/httpMethods";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTTP Methods function only allows the correct HTTP Methods", () => {
|
||||||
|
it("Should allow the passed in Method", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: httpMethod("POST"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
it("Should allow the passed in Method", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: httpMethod("GET"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(405);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||||
|
|
||||||
|
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||||
|
|
||||||
|
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/ee/common/server/checkLicense", () => {
|
||||||
|
return {
|
||||||
|
default: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("~/lib/utils/isAdmin", () => {
|
||||||
|
return {
|
||||||
|
isAdminGuard: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Verify API key", () => {
|
||||||
|
it("It should throw an error if the api key is not valid", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: verifyApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(checkLicense).mockResolvedValue(false);
|
||||||
|
vi.mocked(isAdminGuard).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
it("It should thow an error if no api key is provided", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = {
|
||||||
|
fn: verifyApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(checkLicense).mockResolvedValue(true);
|
||||||
|
vi.mocked(isAdminGuard).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
|
|
||||||
|
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||||
|
|
||||||
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { middlewareOrder } from "../../../lib/helpers/withMiddleware";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not sure if there is much point testing this order is actually applied via an integration test:
|
||||||
|
// It is tested internally https://github.com/htunnicliff/next-api-middleware/blob/368b12aa30e79f4bd7cfe7aacc18da263cc3de2f/lib/label.spec.ts#L62
|
||||||
|
describe("API - withMiddleware test", () => {
|
||||||
|
it("Custom prisma should be before verifyApiKey", async () => {
|
||||||
|
const customPrismaClientIndex = middlewareOrder.indexOf("customPrismaClient");
|
||||||
|
const verifyApiKeyIndex = middlewareOrder.indexOf("verifyApiKey");
|
||||||
|
expect(customPrismaClientIndex).toBeLessThan(verifyApiKeyIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello World
|
||||||
@@ -1,9 +1,29 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
|
const ns = ["common"];
|
||||||
|
const supportedLngs = ["en", "fr"];
|
||||||
|
const resources = ns.reduce((acc, n) => {
|
||||||
|
supportedLngs.forEach((lng) => {
|
||||||
|
if (!acc[lng]) acc[lng] = {};
|
||||||
|
acc[lng] = {
|
||||||
|
...acc[lng],
|
||||||
|
[n]: require(`../../web/public/static/locales/${lng}/${n}.json`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources: [],
|
|
||||||
debug: true,
|
debug: true,
|
||||||
|
fallbackLng: "en",
|
||||||
|
defaultNS: "common",
|
||||||
|
ns,
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
react: { useSuspense: true },
|
||||||
|
resources,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export const parameters = {
|
|||||||
push() {},
|
push() {},
|
||||||
Provider: AppRouterContext.Provider,
|
Provider: AppRouterContext.Provider,
|
||||||
},
|
},
|
||||||
|
globals: {
|
||||||
|
locale: "en",
|
||||||
|
locales: {
|
||||||
|
en: "English",
|
||||||
|
fr: "Français",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
i18n,
|
||||||
};
|
};
|
||||||
|
|
||||||
addDecorator((storyFn) => (
|
addDecorator((storyFn) => (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function VariantsTable({
|
|||||||
const columns = React.Children.toArray(children) as ReactElement<RowProps>[];
|
const columns = React.Children.toArray(children) as ReactElement<RowProps>[];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id="light-variant"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
isDark &&
|
isDark &&
|
||||||
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
|
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
|
||||||
@@ -43,7 +44,7 @@ export function VariantsTable({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{!isDark && (
|
{!isDark && (
|
||||||
<div data-mode="dark" className="dark">
|
<div id="dark-variant" data-mode="dark" className="dark">
|
||||||
<VariantsTable titles={titles} isDark columnMinWidth={columnMinWidth}>
|
<VariantsTable titles={titles} isDark columnMinWidth={columnMinWidth}>
|
||||||
{children}
|
{children}
|
||||||
</VariantsTable>
|
</VariantsTable>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user