Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89c08868f6 | ||
|
|
ebab3b701d | ||
|
|
9ffa4b8477 | ||
|
|
c656063b06 | ||
|
|
5b7f2a6ffc | ||
|
|
63c7e4b55a | ||
|
|
24e5226e3e | ||
|
|
9f43c5f1e7 | ||
|
|
b48e3c7bb4 | ||
|
|
a561b805fd | ||
|
|
d21c91c519 | ||
|
|
41e565fcf8 | ||
|
|
2f946fc5da | ||
|
|
06bb7055ac | ||
|
|
1a8492b9c2 | ||
|
|
b8ba95dbb0 | ||
|
|
40df4cf511 | ||
|
|
58cabe974c | ||
|
|
199d95b35f | ||
|
|
c6febad364 | ||
|
|
0ddcc068ce | ||
|
|
3ff3a97c78 | ||
|
|
baaa1fa71e | ||
|
|
27d331ad2d | ||
|
|
9a1a86ed3a | ||
|
|
7902c6157d | ||
|
|
92d0fc6eb4 | ||
|
|
4d0b417925 | ||
|
|
9947b5ec85 | ||
|
|
fa06f38d47 | ||
|
|
99a6b1c7eb | ||
|
|
e3861ad22a | ||
|
|
37caa57798 | ||
|
|
6e66f7f8f8 | ||
|
|
62dd5ea95b | ||
|
|
365dfe9178 | ||
|
|
4d89da99a7 | ||
|
|
6954ad1ea5 | ||
|
|
825573da8e | ||
|
|
2c517f4878 | ||
|
|
8508a6f1a0 | ||
|
|
db03e9db20 | ||
|
|
e6a16f5b5c | ||
|
|
b94aa603c5 | ||
|
|
31e6b88951 | ||
|
|
fbff4db2a6 | ||
|
|
ba61731689 | ||
|
|
e16662e655 | ||
|
|
53c37a9ddf | ||
|
|
64ef2703a5 | ||
|
|
a997edd8db | ||
|
|
bd73eba528 | ||
|
|
0d53c2069e | ||
|
|
3b559525a8 | ||
|
|
33e1abf4a7 | ||
|
|
059c6c37ba | ||
|
|
14bc489d70 | ||
|
|
e8b36d5b0b | ||
|
|
5983c960a6 | ||
|
|
40305b9c5a | ||
|
|
dcc62c6db0 | ||
|
|
07f14e4e14 | ||
|
|
f940f6bd87 | ||
|
|
ca5dcfd1e8 | ||
|
|
324728d4ec | ||
|
|
2f7cc05cad | ||
|
|
84be4dba8d | ||
|
|
17655cd1c8 | ||
|
|
010f5349f6 | ||
|
|
01dead9a1d | ||
|
|
900ea37b43 | ||
|
|
c209c22b98 | ||
|
|
b8610dc2d1 | ||
|
|
28f84d3570 | ||
|
|
b46a902a3d | ||
|
|
de2f51e104 | ||
|
|
35110892c1 | ||
|
|
c870df7027 | ||
|
|
a4b0fdba7b | ||
|
|
b530d31b35 | ||
|
|
a69f9c5fa3 | ||
|
|
73b8569a16 | ||
|
|
5becc95fc4 | ||
|
|
a74522af3d | ||
|
|
8b23e41954 | ||
|
|
94873ea3cb | ||
|
|
0c0cac28fd | ||
|
|
769ca9002e | ||
|
|
03366d2337 | ||
|
|
159fe3a87d | ||
|
|
5b97d3bf52 | ||
|
|
7cdecf57e0 | ||
|
|
1c8d0eda45 | ||
|
|
7ee3f9409c | ||
|
|
0d1dd036eb | ||
|
|
2af27170cd | ||
|
|
218f6d6eaf | ||
|
|
f0464e72c7 | ||
|
|
2ad3592eb3 | ||
|
|
22fc47f69f | ||
|
|
af3d2e6879 | ||
|
|
58e61cb18e | ||
|
|
9e3f8a2dc0 | ||
|
|
e960b8ad10 | ||
|
|
b7bfd77f95 | ||
|
|
2707adbb7b | ||
|
|
5ef1b22c87 | ||
|
|
7e29db7a1b | ||
|
|
41cde8e384 | ||
|
|
9fec77e345 | ||
|
|
7046f6f80a | ||
|
|
3f4a0eff7f | ||
|
|
d2768c4dc5 | ||
|
|
470e0f53d8 | ||
|
|
003af9a69d | ||
|
|
283ca9fc74 | ||
|
|
13ec930a61 | ||
|
|
cada4a58ac | ||
|
|
77519a077c | ||
|
|
1a5a539106 | ||
|
|
539a2ea301 | ||
|
|
c8b577fb7a | ||
|
|
7f97d27c20 | ||
|
|
9ff10877e7 | ||
|
|
eb0768917c | ||
|
|
ec65cd6333 | ||
|
|
563141b295 | ||
|
|
7b24972900 | ||
|
|
6d2ec71df3 | ||
|
|
492304fe1d | ||
|
|
c7c5cdf133 | ||
|
|
0dd3b25624 | ||
|
|
a633e35c66 | ||
|
|
c618e69d8e | ||
|
|
844dd7e3af | ||
|
|
b6c512e5d7 | ||
|
|
6e0035689a | ||
|
|
58540af9ce | ||
|
|
21f30fc47a | ||
|
|
92845d3adc | ||
|
|
8ee27d3997 | ||
|
|
1263519973 | ||
|
|
caf7943c91 | ||
|
|
fed3987028 | ||
|
|
ad61d48706 | ||
|
|
aa20b9248a | ||
|
|
fa40f1ead5 | ||
|
|
86676eb8af | ||
|
|
3f9d1c1abf | ||
|
|
5f8d090d3c | ||
|
|
4a2a2d165b | ||
|
|
263c0ac7ef | ||
|
|
f1b4d7d9e0 | ||
|
|
7d8cb7d6bb | ||
|
|
5ff7e65d64 | ||
|
|
5bbc025494 | ||
|
|
59bffa075f | ||
|
|
c4104af8a2 | ||
|
|
dca2922ef8 | ||
|
|
8b5ef68652 | ||
|
|
5b2688dda0 | ||
|
|
884ccc9b48 | ||
|
|
7d06d9f865 | ||
|
|
785ab8b74a | ||
|
|
557498b80d | ||
|
|
c3a532cff0 | ||
|
|
ab5d4f1d03 | ||
|
|
ae79154b83 | ||
|
|
e33c676ec5 | ||
|
|
8cd9285cc6 | ||
|
|
4f76d4468e | ||
|
|
92bf1ee3c7 | ||
|
|
e9aec23fbe | ||
|
|
a6a60969df | ||
|
|
e9d828639a | ||
|
|
726c938b6d | ||
|
|
b43aaaccc2 | ||
|
|
0b5f828307 | ||
|
|
3807e339ce | ||
|
|
ececef96e5 | ||
|
|
50338dcd61 | ||
|
|
285d1ec4c9 | ||
|
|
c458b297ae | ||
|
|
a52f7efe88 | ||
|
|
3e10e2db36 | ||
|
|
dfbcf4742f | ||
|
|
3a2a70d83a | ||
|
|
3948b8d203 | ||
|
|
6207afdf03 | ||
|
|
52c7a89785 | ||
|
|
dda4ce5338 | ||
|
|
a02747fc52 | ||
|
|
03311def07 | ||
|
|
5e382ffd21 | ||
|
|
0e0dfcdd88 | ||
|
|
e260809b08 | ||
|
|
835443a0d1 | ||
|
|
48842e72a6 | ||
|
|
ff8931f4a0 | ||
|
|
6670bbc1d7 | ||
|
|
750676f91a | ||
|
|
db60f8faed | ||
|
|
2b3cb20fdf | ||
|
|
7f1d45bb5d | ||
|
|
6632c26063 | ||
|
|
97a3189c12 | ||
|
|
d25595b546 | ||
|
|
a28eb63d61 | ||
|
|
4f944ec12d | ||
|
|
e33930e5c0 | ||
|
|
9613b9da6d | ||
|
|
ec755b142c | ||
|
|
d431607125 | ||
|
|
72253021ef | ||
|
|
be0cc31d63 | ||
|
|
38e67ccc79 | ||
|
|
3fe26f19ff | ||
|
|
f38f98a0b6 | ||
|
|
b4d5f511b5 | ||
|
|
86fc5161b6 | ||
|
|
dbf4be27ea | ||
|
|
7b69a6ab9c | ||
|
|
f897c680a4 | ||
|
|
ac05693694 | ||
|
|
3174b55e21 | ||
|
|
08f4a48774 | ||
|
|
928dd49965 | ||
|
|
47a8ba1eaa | ||
|
|
c4e78c213b | ||
|
|
a31cf9963d | ||
|
|
7ff255d1e7 | ||
|
|
4a88051531 | ||
|
|
765b0b44f8 | ||
|
|
10f8b40348 | ||
|
|
c9a9f70801 | ||
|
|
c2d7054ec4 | ||
|
|
89ac9a3d31 | ||
|
|
7d9c7a8c22 | ||
|
|
48e7efccb2 | ||
|
|
0072aba55d | ||
|
|
c7a96e1b43 | ||
|
|
0288ea931f | ||
|
|
c958b6017a | ||
|
|
ffe03be018 | ||
|
|
f760a8ac26 | ||
|
|
d0a7eb12a0 | ||
|
|
9a473d5b55 | ||
|
|
c5a83f4df2 | ||
|
|
3e175dbca3 | ||
|
|
762b995172 | ||
|
|
d2d7453962 | ||
|
|
87d2ec9e75 | ||
|
|
226686d4dd | ||
|
|
518ab82e3b | ||
|
|
532b78a9b3 | ||
|
|
1251091300 | ||
|
|
ce1459011a | ||
|
|
e1bdac077a | ||
|
|
d666f2926c | ||
|
|
745f0b0872 | ||
|
|
65f50e9f25 | ||
|
|
54162f8568 | ||
|
|
0f4b9b2a6e | ||
|
|
70b4e3a9c6 | ||
|
|
ab44d5b92c | ||
|
|
bed08c1e4f | ||
|
|
1e0f7f433f | ||
|
|
0cc7a9fc22 | ||
|
|
b03b878199 | ||
|
|
7a560cf09b | ||
|
|
58ad441223 | ||
|
|
d8de919acb | ||
|
|
d699b33dcb | ||
|
|
c071df4cd6 | ||
|
|
219b13d053 | ||
|
|
5e09281974 | ||
|
|
b66d36a98e | ||
|
|
68ce9524eb | ||
|
|
357a42b512 | ||
|
|
33d7da88bf | ||
|
|
cd888be61e | ||
|
|
06231ec299 | ||
|
|
e38705c3e8 | ||
|
|
5c95268d1a | ||
|
|
b310eb1a7e | ||
|
|
051510fa36 | ||
|
|
7d1226b823 | ||
|
|
7732a22c49 | ||
|
|
2a41cd0770 | ||
|
|
a574e5e60f | ||
|
|
0b6508379b | ||
|
|
f6930df87d | ||
|
|
7d4e8e2210 | ||
|
|
50eeb712e3 | ||
|
|
75edc4539c | ||
|
|
def87ea84a | ||
|
|
4153e5b2d5 | ||
|
|
8c2f6ae145 | ||
|
|
78ea4d2f68 | ||
|
|
e7c638c1e4 | ||
|
|
c28fab4fe2 | ||
|
|
e8a02554b6 | ||
|
|
4017c0e28f | ||
|
|
d72f7f8a42 | ||
|
|
3a4ebadbf3 | ||
|
|
765d601ff9 | ||
|
|
3a22fdb428 | ||
|
|
93580ac4bc | ||
|
|
b15ad6f233 | ||
|
|
56e59ab3e1 | ||
|
|
ee796933ee | ||
|
|
d2f76edfd6 | ||
|
|
822f0204a6 | ||
|
|
9770aff153 | ||
|
|
723d0ddec2 | ||
|
|
60b61eb625 | ||
|
|
5d2d99006b | ||
|
|
5d01eb2f38 | ||
|
|
ac3fb0a1cd | ||
|
|
1730ef7d5d | ||
|
|
9e371ded90 | ||
|
|
15098bdd1e | ||
|
|
625a7ec180 | ||
|
|
5602f7bd95 | ||
|
|
3e2caa9e1e | ||
|
|
b905de435d | ||
|
|
23bb06cc36 | ||
|
|
dd24100035 | ||
|
|
7434d91d48 | ||
|
|
3b1de34451 | ||
|
|
4cf89d273e | ||
|
|
e58ce4af71 | ||
|
|
15122dd5e9 | ||
|
|
793c0c60e3 | ||
|
|
344454e574 | ||
|
|
86342d103a | ||
|
|
d4f3726875 | ||
|
|
817a3decb7 | ||
|
|
5f082e11f8 | ||
|
|
5fb5081faa | ||
|
|
17f328fc5e | ||
|
|
d19034c2d5 | ||
|
|
4cdcc85620 | ||
|
|
3f51ae626c | ||
|
|
8499aebe88 | ||
|
|
07f613050e | ||
|
|
2c8c8368a0 | ||
|
|
74d3a84e27 | ||
|
|
33c1d49c52 | ||
|
|
889ff177c1 | ||
|
|
d8a6c375d0 | ||
|
|
655127d155 | ||
|
|
17c4a10e33 | ||
|
|
30f9f19a9f | ||
|
|
be7d45afe2 | ||
|
|
d2bec46d89 | ||
|
|
b90500778e | ||
|
|
bddc235c3c | ||
|
|
360b729d08 | ||
|
|
5d325495a9 | ||
|
|
dead2b843a | ||
|
|
1174cb0d8e | ||
|
|
58e6f18594 | ||
|
|
ffe6a46e66 | ||
|
|
37c02572de | ||
|
|
b82b0a33a4 | ||
|
|
b1bbbcee90 | ||
|
|
1a381695e5 | ||
|
|
8831ff13c7 | ||
|
|
64845c670d | ||
|
|
ba06c6c26a | ||
|
|
49df99cf92 | ||
|
|
0cb2e60d9b | ||
|
|
b3485b07bf | ||
|
|
01933e5c15 | ||
|
|
5f5cf92456 | ||
|
|
29ccc58806 | ||
|
|
26bd02bd94 | ||
|
|
04bf23ed69 |
No files matched your search
+11
-3
@@ -15,10 +15,14 @@
|
|||||||
# - You can not repackage or sell the codebase
|
# - You can not repackage or sell the codebase
|
||||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||||
#
|
#
|
||||||
# To enable enterprise-only features, as an admin, go to /auth/setup to select your license and follow
|
|
||||||
# instructions. This environment variable is deprecated although still supported for backward compatibility.
|
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
|
||||||
# @see https://console.cal.com
|
|
||||||
CALCOM_LICENSE_KEY=
|
CALCOM_LICENSE_KEY=
|
||||||
|
# Signature token for the Cal.com License API (used for self-hosted integrations)
|
||||||
|
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
|
||||||
|
CAL_SIGNATURE_TOKEN=
|
||||||
|
# The route to the Cal.com License API
|
||||||
|
CALCOM_PRIVATE_API_ROUTE="https://goblin.cal.com"
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
# - DATABASE ************************************************************************************************
|
# - DATABASE ************************************************************************************************
|
||||||
@@ -180,6 +184,7 @@ API_KEY_PREFIX=cal_
|
|||||||
# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/
|
# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/
|
||||||
# Configures the global From: header whilst sending emails.
|
# Configures the global From: header whilst sending emails.
|
||||||
EMAIL_FROM='notifications@yourselfhostedcal.com'
|
EMAIL_FROM='notifications@yourselfhostedcal.com'
|
||||||
|
EMAIL_FROM_NAME='Cal.com'
|
||||||
|
|
||||||
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
|
||||||
# Configuration to receive emails locally (mailhog)
|
# Configuration to receive emails locally (mailhog)
|
||||||
@@ -359,3 +364,6 @@ UNKEY_ROOT_KEY=
|
|||||||
# Used for Cal.ai Enterprise Voice AI Agents
|
# Used for Cal.ai Enterprise Voice AI Agents
|
||||||
# https://retellai.com
|
# https://retellai.com
|
||||||
RETELL_AI_KEY=
|
RETELL_AI_KEY=
|
||||||
|
|
||||||
|
# Used to disallow emails as being added as guests on bookings
|
||||||
|
BLACKLISTED_GUEST_EMAILS=
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
apps/api/v2/dist
|
||||||
|
packages/platform/**/dist/*
|
||||||
**/**/node_modules
|
**/**/node_modules
|
||||||
**/**/.next
|
**/**/.next
|
||||||
**/**/public
|
**/**/public
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Report any issues with the platform
|
|
||||||
title: ""
|
|
||||||
labels: bug
|
|
||||||
assignees: ""
|
|
||||||
---
|
|
||||||
|
|
||||||
Found a bug? Please fill out the sections below. 👍
|
|
||||||
|
|
||||||
### Issue Summary
|
|
||||||
|
|
||||||
A summary of the issue. This needs to be a clear detailed-rich summary.
|
|
||||||
|
|
||||||
### Steps to Reproduce
|
|
||||||
|
|
||||||
1. (for example) Went to ...
|
|
||||||
2. Clicked on...
|
|
||||||
3. ...
|
|
||||||
|
|
||||||
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
|
|
||||||
|
|
||||||
### Actual Results
|
|
||||||
|
|
||||||
- What's happening right now that is different from what is expected
|
|
||||||
|
|
||||||
### Expected Results
|
|
||||||
|
|
||||||
- This is an ideal result that the system should get after the tests are performed
|
|
||||||
|
|
||||||
### Technical details
|
|
||||||
|
|
||||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
|
||||||
- Node.js version
|
|
||||||
- Anything else that you think could be an issue.
|
|
||||||
|
|
||||||
### Evidence
|
|
||||||
|
|
||||||
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: "Please make sure you have checked for any duplicate issues."
|
||||||
|
title: "(bug)"
|
||||||
|
labels: ["🐛 bug"]
|
||||||
|
assignees: []
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: issue-summary
|
||||||
|
attributes:
|
||||||
|
label: Issue Summary
|
||||||
|
description: A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
value: |
|
||||||
|
1. (for example) Went to ...
|
||||||
|
2. Clicked on...
|
||||||
|
3. ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: A clear and concise description of what you expected to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: other-information
|
||||||
|
attributes:
|
||||||
|
label: Other information
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: If applicable, add screenshots to help explain your problem.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: checkboxes
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
options:
|
||||||
|
- label: app.cal.com
|
||||||
|
- label: Self-hosted Cal
|
||||||
|
- type: textarea
|
||||||
|
id: desktop-version
|
||||||
|
attributes:
|
||||||
|
label: Desktop (please complete the following information)
|
||||||
|
description: |
|
||||||
|
examples:
|
||||||
|
- **OS**: [e.g. iOS]
|
||||||
|
- **Browser**: [e.g. chrome, safari]
|
||||||
|
- **Version**: [e.g. 22]
|
||||||
|
value: |
|
||||||
|
- OS:
|
||||||
|
- Node:
|
||||||
|
- npm:
|
||||||
|
render: markdown
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
id: nodejs-version
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
#### Node.JS version
|
||||||
|
|
||||||
|
[e.g. v18.15.0]
|
||||||
|
- type: markdown
|
||||||
|
id: anything-else
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
#### Anything else?
|
||||||
|
|
||||||
|
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
|
||||||
|
- Anything else that you think could be an issue?
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Questions
|
- name: Questions
|
||||||
url: https://go.cal.com/discord
|
url: https://github.com/calcom/cal.com/discussions
|
||||||
about: Ask a general question about the project on our discord community
|
about: Need help selfhosting or ask a general question about the project? Open a discussion
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
name: Feature request
|
name: Feature request
|
||||||
about: Suggest a feature or idea
|
about: Suggest a feature or idea
|
||||||
title: ""
|
title: ""
|
||||||
labels: feature
|
labels: ["✨ feature", "🚨 needs approval"]
|
||||||
assignees: ""
|
assignees: ""
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -53,3 +53,4 @@ assignees: ""
|
|||||||
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
|
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
|
||||||
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
|
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
|
||||||
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
|
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
|
||||||
|
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
|
|
||||||
## Mandatory Tasks (DO NOT REMOVE)
|
## Mandatory Tasks (DO NOT REMOVE)
|
||||||
|
|
||||||
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected)
|
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
|
||||||
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com)
|
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com). If N/A, write N/A here and check the checkbox.
|
||||||
- [ ] I have added or modified automated tests that prove my fix is effective or that my feature works (PRs might be rejected if logical changes are not properly tested)
|
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
|
||||||
|
|
||||||
## How should this be tested?
|
## How should this be tested?
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,14 @@ runs:
|
|||||||
key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }}
|
key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }}
|
||||||
key-3: ${{ github.event.pull_request.number || github.ref }}
|
key-3: ${{ github.event.pull_request.number || github.ref }}
|
||||||
key-4: ${{ github.sha }}
|
key-4: ${{ github.sha }}
|
||||||
|
key-5: ${{ github.event.pull_request.head.sha }}
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
${{ github.workspace }}/apps/web/.next
|
${{ github.workspace }}/apps/web/.next
|
||||||
${{ github.workspace }}/apps/web/public/embed
|
${{ github.workspace }}/apps/web/public/embed
|
||||||
**/.turbo/**
|
**/.turbo/**
|
||||||
**/dist/**
|
**/dist/**
|
||||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
|
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}-${{ env.key-5 }}
|
||||||
- run: |
|
- run: |
|
||||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||||
yarn build
|
yarn build
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ runs:
|
|||||||
cache-name: cache-db
|
cache-name: cache-db
|
||||||
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
|
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
|
||||||
key-2: ${{ github.event.pull_request.number || github.ref }}
|
key-2: ${{ github.event.pull_request.number || github.ref }}
|
||||||
|
key-3: ${{ github.event.pull_request.head.sha }}
|
||||||
DATABASE_URL: ${{ inputs.DATABASE_URL }}
|
DATABASE_URL: ${{ inputs.DATABASE_URL }}
|
||||||
DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }}
|
DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }}
|
||||||
E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }}
|
E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }}
|
||||||
@@ -24,7 +25,7 @@ runs:
|
|||||||
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
|
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
|
||||||
with:
|
with:
|
||||||
path: ${{ inputs.path }}
|
path: ${{ inputs.path }}
|
||||||
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}
|
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
|
||||||
- run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed
|
- run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed
|
||||||
if: steps.cache-db.outputs.cache-hit != 'true'
|
if: steps.cache-db.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
name: All checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
merge_group:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
type-check:
|
||||||
|
name: Type check
|
||||||
|
uses: ./.github/workflows/check-types.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Linters
|
||||||
|
uses: ./.github/workflows/lint.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
unit-test:
|
||||||
|
name: Tests
|
||||||
|
uses: ./.github/workflows/unit-tests.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
build-api-v1:
|
||||||
|
name: Production builds
|
||||||
|
uses: ./.github/workflows/api-v1-production-build.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
build-api-v2:
|
||||||
|
name: Production builds
|
||||||
|
uses: ./.github/workflows/api-v2-production-build.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Production builds
|
||||||
|
uses: ./.github/workflows/production-build-without-database.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
integration-test:
|
||||||
|
name: Tests
|
||||||
|
needs: [lint, build, build-api-v1, build-api-v2]
|
||||||
|
uses: ./.github/workflows/integration-tests.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: Tests
|
||||||
|
needs: [lint, build, build-api-v1, build-api-v2]
|
||||||
|
uses: ./.github/workflows/e2e.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
e2e-app-store:
|
||||||
|
name: Tests
|
||||||
|
needs: [lint, build, build-api-v1, build-api-v2]
|
||||||
|
uses: ./.github/workflows/e2e-app-store.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
e2e-embed:
|
||||||
|
name: Tests
|
||||||
|
needs: [lint, build, build-api-v1, build-api-v2]
|
||||||
|
uses: ./.github/workflows/e2e-embed.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
e2e-embed-react:
|
||||||
|
name: Tests
|
||||||
|
needs: [lint, build, build-api-v1, build-api-v2]
|
||||||
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
required:
|
||||||
|
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||||
|
if: always()
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- name: fail if conditional jobs failed
|
||||||
|
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
|
||||||
|
run: exit 1
|
||||||
@@ -3,6 +3,9 @@ name: Production Builds
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
@@ -65,7 +68,7 @@ jobs:
|
|||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
- uses: ./.github/actions/cache-db
|
- uses: ./.github/actions/cache-db
|
||||||
- name: Cache API v1 production build
|
- name: Cache API v1 production build
|
||||||
uses: buildjet/cache@v3
|
uses: buildjet/cache@v4
|
||||||
id: cache-api-v1-build
|
id: cache-api-v1-build
|
||||||
env:
|
env:
|
||||||
cache-name: api-v1-build
|
cache-name: api-v1-build
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build API v2
|
name: Build API v2
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
services:
|
services:
|
||||||
@@ -34,7 +36,7 @@ jobs:
|
|||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
- name: Cache API v2 production build
|
- name: Cache API v2 production build
|
||||||
uses: buildjet/cache@v3
|
uses: buildjet/cache@v4
|
||||||
id: cache-api-v2-build
|
id: cache-api-v2-build
|
||||||
env:
|
env:
|
||||||
cache-name: api-v2-build
|
cache-name: api-v2-build
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
check-types:
|
check-types:
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
name: E2E
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
env:
|
||||||
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
|
API_KEY_PREFIX: ${{ secrets.CI_API_KEY_PREFIX }}
|
||||||
|
API_PORT: ${{ vars.CI_API_V2_PORT }}
|
||||||
|
CALCOM_LICENSE_KEY: ${{ secrets.CI_CALCOM_LICENSE_KEY }}
|
||||||
|
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||||
|
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
|
DATABASE_READ_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
|
DATABASE_WRITE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
|
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||||
|
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||||
|
IS_E2E: true
|
||||||
|
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||||
|
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
REDIS_URL: "redis://localhost:6379"
|
||||||
|
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
|
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
timeout-minutes: 20
|
||||||
|
name: E2E API v2
|
||||||
|
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:13
|
||||||
|
credentials:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: calendso
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
steps:
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
|
- uses: ./.github/actions/yarn-install
|
||||||
|
- uses: ./.github/actions/yarn-playwright-install
|
||||||
|
- uses: ./.github/actions/cache-db
|
||||||
|
- name: Run Tests
|
||||||
|
working-directory: apps/api/v2
|
||||||
|
run: |
|
||||||
|
yarn test:e2e
|
||||||
|
EXIT_CODE=$?
|
||||||
|
echo "yarn test:e2e command exit code: $EXIT_CODE"
|
||||||
|
exit $EXIT_CODE
|
||||||
|
- name: Upload Test Results
|
||||||
|
if: ${{ always() }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-results-api-v2
|
||||||
|
path: test-results
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
name: E2E App Store Tests
|
name: E2E App Store Tests
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
@@ -41,7 +44,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
e2e-app-store:
|
e2e-app-store:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
name: E2E App Store (${{ matrix.shard }}/${{ strategy.job-total }})
|
name: E2E App Store
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -70,9 +73,6 @@ jobs:
|
|||||||
- 1025:1025
|
- 1025:1025
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
|
||||||
## There aren't many tests for AppStore. So, just start with 2 shards. Increase if needed.
|
|
||||||
shard: [1, 2]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
@@ -93,10 +93,10 @@ jobs:
|
|||||||
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
|
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
|
||||||
- uses: ./.github/actions/cache-build
|
- uses: ./.github/actions/cache-build
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: yarn e2e:app-store --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
run: yarn e2e:app-store
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: app-store-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
name: app-store-results
|
||||||
path: test-results
|
path: test-results
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
name: E2E Embed React tests and booking flow (for non-embed as well)
|
name: E2E Embed React tests and booking flow (for non-embed as well)
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
@@ -41,7 +44,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
e2e-embed:
|
e2e-embed:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
name: E2E Embed React (${{ matrix.shard }}/${{ strategy.job-total }})
|
name: E2E Embed React
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -62,9 +65,6 @@ jobs:
|
|||||||
- 5432:5432
|
- 5432:5432
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
|
||||||
## There aren't many tests for embed-react. So, just start with 2 shards. Increase if needed.
|
|
||||||
shard: [1, 2]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
@@ -79,11 +79,11 @@ jobs:
|
|||||||
- uses: ./.github/actions/cache-build
|
- uses: ./.github/actions/cache-build
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: |
|
run: |
|
||||||
yarn e2e:embed-react --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
yarn e2e:embed-react
|
||||||
yarn workspace @calcom/embed-react packaged:tests
|
yarn workspace @calcom/embed-react packaged:tests
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: embed-react-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
name: embed-react-results
|
||||||
path: test-results
|
path: test-results
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
name: E2E Embed Core tests and booking flow (for non-embed as well)
|
name: E2E Embed Core tests and booking flow (for non-embed as well)
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
@@ -41,7 +44,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
e2e-embed:
|
e2e-embed:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
name: E2E Embed Core (${{ matrix.shard }}/${{ strategy.job-total }})
|
name: E2E Embed Core
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -70,9 +73,6 @@ jobs:
|
|||||||
- 1025:1025
|
- 1025:1025
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
|
||||||
## There aren't many tests for embed-core. So, just start with 2 shards. Increase if needed.
|
|
||||||
shard: [1, 2]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
@@ -86,10 +86,10 @@ jobs:
|
|||||||
- uses: ./.github/actions/cache-db
|
- uses: ./.github/actions/cache-db
|
||||||
- uses: ./.github/actions/cache-build
|
- uses: ./.github/actions/cache-build
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: yarn e2e:embed --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
run: yarn e2e:embed
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: embed-core-results-${{ matrix.shard }}_${{ strategy.job-total }}
|
name: embed-core-results
|
||||||
path: test-results
|
path: test-results
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
name: E2E
|
name: E2E
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
@@ -42,7 +45,7 @@ jobs:
|
|||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
name: E2E (${{ matrix.shard }}/${{ strategy.job-total }})
|
name: E2E (${{ matrix.shard }}/${{ strategy.job-total }})
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:13
|
image: postgres:13
|
||||||
@@ -71,7 +74,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
shard: [1, 2, 3, 4, 5, 6, 7]
|
shard: [1, 2, 3, 4]
|
||||||
steps:
|
steps:
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
name: Integration
|
name: Integration
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
@@ -75,15 +77,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
- uses: ./.github/actions/cache-db
|
- uses: ./.github/actions/cache-db
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: yarn test -- --integrationTestsOnly
|
run: yarn test -- --integrationTestsOnly
|
||||||
- name: Upload Test Results
|
# TODO: Generate test results so we can upload them
|
||||||
if: ${{ always() }}
|
# - name: Upload Test Results
|
||||||
uses: actions/upload-artifact@v4
|
# if: ${{ always() }}
|
||||||
with:
|
# uses: actions/upload-artifact@v4
|
||||||
name: test-results
|
# with:
|
||||||
path: test-results
|
# name: test-results
|
||||||
|
# path: test-results
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
name: Lint
|
name: Lint
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
@@ -16,13 +19,6 @@ jobs:
|
|||||||
- name: Merge lint reports
|
- name: Merge lint reports
|
||||||
run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json
|
run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json
|
||||||
|
|
||||||
- name: Annotate Code Linting Results
|
|
||||||
uses: ataylorme/eslint-annotate-action@v2
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
report-json: "lint-results/eslint_report.json"
|
|
||||||
only-pr-files: false
|
|
||||||
|
|
||||||
- name: Upload ESLint report
|
- name: Upload ESLint report
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: "Next.js Bundle Analysis Annotation"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
annotate:
|
||||||
|
if: always()
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/yarn-install
|
||||||
|
- uses: ./.github/actions/cache-build
|
||||||
|
- name: Download base branch bundle stats
|
||||||
|
uses: dawidd6/action-download-artifact@v2
|
||||||
|
if: success()
|
||||||
|
with:
|
||||||
|
workflow: nextjs-bundle-analysis.yml
|
||||||
|
branch: ${{ github.event.pull_request.base.ref }}
|
||||||
|
path: apps/web/.next/analyze/base
|
||||||
|
|
||||||
|
# And here's the second place - this runs after we have both the current and
|
||||||
|
# base branch bundle stats, and will compare them to determine what changed.
|
||||||
|
# There are two configurable arguments that come from package.json:
|
||||||
|
#
|
||||||
|
# - budget: optional, set a budget (bytes) against which size changes are measured
|
||||||
|
# it's set to 350kb here by default, as informed by the following piece:
|
||||||
|
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
||||||
|
#
|
||||||
|
# - red-status-percentage: sets the percent size increase where you get a red
|
||||||
|
# status indicator, defaults to 20%
|
||||||
|
#
|
||||||
|
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
||||||
|
# entry in your package.json file.
|
||||||
|
|
||||||
|
- name: Compare with base branch bundle
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
cd apps/web
|
||||||
|
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
||||||
|
|
||||||
|
- name: Get comment body
|
||||||
|
id: get-comment-body
|
||||||
|
if: success() && github.event.number
|
||||||
|
run: |
|
||||||
|
cd apps/web
|
||||||
|
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
||||||
|
body="${body//'%'/'%25'}"
|
||||||
|
body="${body//$'\n'/'%0A'}"
|
||||||
|
body="${body//$'\r'/'%0D'}"
|
||||||
|
echo ::set-output name=body::$body
|
||||||
|
|
||||||
|
- name: Find Comment
|
||||||
|
uses: peter-evans/find-comment@v2
|
||||||
|
if: success() && github.event.number
|
||||||
|
id: fc
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
|
||||||
|
|
||||||
|
- name: Create Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v3
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
|
||||||
|
- name: Update Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v3
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
edit-mode: replace
|
||||||
@@ -7,6 +7,10 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
@@ -45,13 +49,7 @@ env:
|
|||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
|
||||||
name: Production builds
|
|
||||||
if: ${{ github.event_name == 'push' }}
|
|
||||||
uses: ./.github/workflows/production-build-without-database.yml
|
|
||||||
secrets: inherit
|
|
||||||
analyze:
|
analyze:
|
||||||
needs: build
|
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
@@ -69,65 +67,3 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: bundle
|
name: bundle
|
||||||
path: apps/web/.next/analyze/__bundle_analysis.json
|
path: apps/web/.next/analyze/__bundle_analysis.json
|
||||||
|
|
||||||
- name: Download base branch bundle stats
|
|
||||||
uses: dawidd6/action-download-artifact@v2
|
|
||||||
if: success()
|
|
||||||
with:
|
|
||||||
workflow: nextjs-bundle-analysis.yml
|
|
||||||
branch: ${{ github.event.pull_request.base.ref }}
|
|
||||||
path: apps/web/.next/analyze/base
|
|
||||||
|
|
||||||
# And here's the second place - this runs after we have both the current and
|
|
||||||
# base branch bundle stats, and will compare them to determine what changed.
|
|
||||||
# There are two configurable arguments that come from package.json:
|
|
||||||
#
|
|
||||||
# - budget: optional, set a budget (bytes) against which size changes are measured
|
|
||||||
# it's set to 350kb here by default, as informed by the following piece:
|
|
||||||
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
|
||||||
#
|
|
||||||
# - red-status-percentage: sets the percent size increase where you get a red
|
|
||||||
# status indicator, defaults to 20%
|
|
||||||
#
|
|
||||||
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
|
||||||
# entry in your package.json file.
|
|
||||||
- name: Compare with base branch bundle
|
|
||||||
if: success()
|
|
||||||
run: |
|
|
||||||
cd apps/web
|
|
||||||
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
|
||||||
|
|
||||||
- name: Get comment body
|
|
||||||
id: get-comment-body
|
|
||||||
if: success() && github.event.number
|
|
||||||
run: |
|
|
||||||
cd apps/web
|
|
||||||
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
|
||||||
body="${body//'%'/'%25'}"
|
|
||||||
body="${body//$'\n'/'%0A'}"
|
|
||||||
body="${body//$'\r'/'%0D'}"
|
|
||||||
echo ::set-output name=body::$body
|
|
||||||
|
|
||||||
- name: Find Comment
|
|
||||||
uses: peter-evans/find-comment@v2
|
|
||||||
if: success() && github.event.number
|
|
||||||
id: fc
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.number }}
|
|
||||||
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
|
|
||||||
|
|
||||||
- name: Create Comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v3
|
|
||||||
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.number }}
|
|
||||||
body: ${{ steps.get-comment-body.outputs.body }}
|
|
||||||
|
|
||||||
- name: Update Comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v3
|
|
||||||
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.number }}
|
|
||||||
body: ${{ steps.get-comment-body.outputs.body }}
|
|
||||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
|
||||||
edit-mode: replace
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: PR Reviewed
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Label PR as ready for E2E
|
||||||
|
if: github.event.review.state == 'approved'
|
||||||
|
uses: actions-ecosystem/action-add-labels@v1
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
labels: 'ready-for-e2e'
|
||||||
+97
-28
@@ -2,11 +2,16 @@ name: PR Update
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
|
types: [opened, synchronize, reopened, labeled]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
merge_group:
|
- gh-actions-test-branch
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -19,6 +24,7 @@ jobs:
|
|||||||
pull-requests: read
|
pull-requests: read
|
||||||
outputs:
|
outputs:
|
||||||
has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }}
|
has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }}
|
||||||
|
commit-sha: ${{ steps.get_sha.outputs.commit-sha }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
@@ -28,92 +34,155 @@ jobs:
|
|||||||
filters: |
|
filters: |
|
||||||
has-files-requiring-all-checks:
|
has-files-requiring-all-checks:
|
||||||
- "!(**.md|.github/CODEOWNERS)"
|
- "!(**.md|.github/CODEOWNERS)"
|
||||||
|
- name: Get Latest Commit SHA
|
||||||
|
id: get_sha
|
||||||
|
run: |
|
||||||
|
echo "commit-sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
check-label:
|
||||||
|
needs: [changes]
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
name: Check for E2E label
|
||||||
|
permissions:
|
||||||
|
pull-requests: read
|
||||||
|
outputs:
|
||||||
|
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
|
||||||
|
steps:
|
||||||
|
- name: Check if PR exists with ready-for-e2e label for this SHA
|
||||||
|
id: check-if-pr-has-label
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
let labels = [];
|
||||||
|
|
||||||
|
if (context.payload.pull_request) {
|
||||||
|
labels = context.payload.pull_request.labels;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const sha = '${{ needs.changes.outputs.commit-sha }}';
|
||||||
|
console.log('sha', sha);
|
||||||
|
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
commit_sha: sha
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prs.length === 0) {
|
||||||
|
core.setOutput('run-e2e', false);
|
||||||
|
console.log(`No pull requests found for commit SHA ${sha}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = prs[0];
|
||||||
|
console.log(`PR number: ${pr.number}`);
|
||||||
|
console.log(`PR title: ${pr.title}`);
|
||||||
|
console.log(`PR state: ${pr.state}`);
|
||||||
|
console.log(`PR URL: ${pr.html_url}`);
|
||||||
|
|
||||||
|
labels = pr.labels;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
core.setOutput('run-e2e', false);
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelFound = labels.map(l => l.name).includes('ready-for-e2e');
|
||||||
|
console.log('Found the label?', labelFound);
|
||||||
|
core.setOutput('run-e2e', labelFound);
|
||||||
|
|
||||||
type-check:
|
type-check:
|
||||||
name: Type check
|
name: Type check
|
||||||
needs: [changes]
|
needs: [changes, check-label]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/check-types.yml
|
uses: ./.github/workflows/check-types.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
unit-test:
|
|
||||||
name: Tests
|
|
||||||
needs: [changes]
|
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
|
||||||
uses: ./.github/workflows/unit-tests.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Linters
|
name: Linters
|
||||||
needs: [changes]
|
needs: [changes, check-label]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/lint.yml
|
uses: ./.github/workflows/lint.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
unit-test:
|
||||||
|
name: Tests
|
||||||
|
needs: [changes, check-label]
|
||||||
|
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
|
uses: ./.github/workflows/unit-tests.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
build-api-v1:
|
build-api-v1:
|
||||||
name: Production builds
|
name: Production builds
|
||||||
needs: [changes]
|
needs: [changes, check-label]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/api-v1-production-build.yml
|
uses: ./.github/workflows/api-v1-production-build.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
build-api-v2:
|
build-api-v2:
|
||||||
name: Production builds
|
name: Production builds
|
||||||
needs: [changes]
|
needs: [changes, check-label]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/api-v2-production-build.yml
|
uses: ./.github/workflows/api-v2-production-build.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Production builds
|
name: Production builds
|
||||||
needs: [changes]
|
needs: [changes, check-label]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/production-build-without-database.yml
|
uses: ./.github/workflows/production-build-without-database.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
integration-test:
|
integration-test:
|
||||||
name: Tests
|
name: Tests
|
||||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/integration-tests.yml
|
uses: ./.github/workflows/integration-tests.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: Tests
|
name: Tests
|
||||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/e2e.yml
|
uses: ./.github/workflows/e2e.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
e2e-api-v2:
|
||||||
|
name: Tests
|
||||||
|
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||||
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
|
uses: ./.github/workflows/e2e-api-v2.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
e2e-app-store:
|
e2e-app-store:
|
||||||
name: Tests
|
name: Tests
|
||||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/e2e-app-store.yml
|
uses: ./.github/workflows/e2e-app-store.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
e2e-embed:
|
e2e-embed:
|
||||||
name: Tests
|
name: Tests
|
||||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/e2e-embed.yml
|
uses: ./.github/workflows/e2e-embed.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
e2e-embed-react:
|
e2e-embed-react:
|
||||||
name: Tests
|
name: Tests
|
||||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
needs: [changes, check-label, build, build-api-v1, build-api-v2]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze Build
|
name: Analyze Build
|
||||||
needs: [changes, build]
|
needs: [build]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
|
||||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
required:
|
required:
|
||||||
needs: [changes, lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-api-v2, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
name: Pre-release checks
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
changes:
|
|
||||||
name: Detect changes
|
|
||||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
|
||||||
permissions:
|
|
||||||
pull-requests: read
|
|
||||||
outputs:
|
|
||||||
app-store: ${{ steps.filter.outputs.app-store }}
|
|
||||||
embed: ${{ steps.filter.outputs.embed }}
|
|
||||||
embed-react: ${{ steps.filter.outputs.embed-react }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
|
||||||
- uses: dorny/paths-filter@v3
|
|
||||||
id: filter
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
app-store:
|
|
||||||
- 'apps/web/**'
|
|
||||||
- 'packages/app-store/**'
|
|
||||||
- 'playwright.config.ts'
|
|
||||||
embed:
|
|
||||||
- 'apps/web/**'
|
|
||||||
- 'packages/embeds/**'
|
|
||||||
- 'playwright.config.ts'
|
|
||||||
embed-react:
|
|
||||||
- 'apps/web/**'
|
|
||||||
- 'packages/embeds/**'
|
|
||||||
- 'playwright.config.ts'
|
|
||||||
|
|
||||||
lint:
|
|
||||||
name: Linters
|
|
||||||
uses: ./.github/workflows/lint.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Production builds
|
|
||||||
uses: ./.github/workflows/production-build-without-database.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
name: E2E tests
|
|
||||||
needs: [changes, lint, build]
|
|
||||||
uses: ./.github/workflows/e2e.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
e2e-app-store:
|
|
||||||
name: E2E App Store tests
|
|
||||||
needs: [changes, lint, build]
|
|
||||||
uses: ./.github/workflows/e2e-app-store.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
e2e-embed:
|
|
||||||
name: E2E embeds tests
|
|
||||||
needs: [changes, lint, build]
|
|
||||||
uses: ./.github/workflows/e2e-embed.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
e2e-embed-react:
|
|
||||||
name: E2E React embeds tests
|
|
||||||
needs: [changes, lint, build]
|
|
||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
required:
|
|
||||||
needs: [changes, lint, build, e2e, e2e-app-store, e2e-embed, e2e-embed-react]
|
|
||||||
if: always()
|
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
|
||||||
steps:
|
|
||||||
- name: fail if conditional jobs failed
|
|
||||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
|
|
||||||
run: exit 1
|
|
||||||
@@ -2,6 +2,9 @@ name: Production Builds
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
|
|||||||
@@ -37,12 +37,3 @@ jobs:
|
|||||||
```
|
```
|
||||||
${{ steps.lint_pr_title.outputs.error_message }}
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
```
|
```
|
||||||
|
|
||||||
# Delete a previous comment when the issue has been resolved
|
|
||||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
|
||||||
uses: marocchino/sticky-pull-request-comment@v2
|
|
||||||
with:
|
|
||||||
header: pr-title-lint-error
|
|
||||||
message: |
|
|
||||||
Thank you for following the naming conventions! 🙏 Feel free to join our [discord](https://go.cal.com/discord) and post your PR link.
|
|
||||||
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
name: Unit
|
name: Unit
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
workflow_run:
|
permissions:
|
||||||
workflows: [Crowdin Action]
|
contents: read
|
||||||
types: [completed]
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Unit
|
name: Unit
|
||||||
@@ -13,7 +12,11 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
# 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
|
# We could add different timezones here that we need to run our tests in
|
||||||
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
|
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
|
||||||
|
- name: Run API v2 tests
|
||||||
|
working-directory: apps/api/v2
|
||||||
|
run: |
|
||||||
|
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||||
|
yarn test
|
||||||
@@ -3,6 +3,9 @@ name: Yarn install
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
setup:
|
setup:
|
||||||
name: Yarn install & cache
|
name: Yarn install & cache
|
||||||
|
|||||||
+2
-2
@@ -74,7 +74,7 @@ branch are tagged into a release monthly.
|
|||||||
|
|
||||||
To develop locally:
|
To develop locally:
|
||||||
|
|
||||||
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
|
1. [Fork](https://github.com/calcom/cal.com/fork/) this repository to your
|
||||||
own GitHub account and then
|
own GitHub account and then
|
||||||
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||||
2. Create a new branch:
|
2. Create a new branch:
|
||||||
@@ -99,7 +99,7 @@ To develop locally:
|
|||||||
|
|
||||||
- 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 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||||
|
|
||||||
6. Setup Node
|
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:
|
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:
|
||||||
|
|||||||
@@ -392,7 +392,37 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
|
|||||||
|
|
||||||
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
|
||||||
|
|
||||||
<!-- RORADMAP -->
|
<!-- LICENSE -->
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
|
||||||
|
|
||||||
|
| | AGPLv3 | EE |
|
||||||
|
|---|---|---|
|
||||||
|
| Self-host for commercial purposes | ✅ | ✅ |
|
||||||
|
| Clone privately | ✅ | ✅ |
|
||||||
|
| Fork publicly | ✅ | ✅ |
|
||||||
|
| Requires CLA | ✅ | ✅ |
|
||||||
|
| Official Support| ❌ | ✅ |
|
||||||
|
| Derivative work privately | ❌ | ✅ |
|
||||||
|
| SSO | ❌ | ✅ |
|
||||||
|
| Admin Panel | ❌ | ✅ |
|
||||||
|
| Impersonation | ❌ | ✅ |
|
||||||
|
| Managed Event Types | ❌ | ✅ |
|
||||||
|
| Organizations | ❌ | ✅ |
|
||||||
|
| Payments | ❌ | ✅ |
|
||||||
|
| Platform | ❌ | ✅ |
|
||||||
|
| Teams | ❌ | ✅ |
|
||||||
|
| Users | ❌ | ✅ |
|
||||||
|
| Video | ❌ | ✅ |
|
||||||
|
| Workflows | ❌ | ✅ |
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
|
||||||
|
|
||||||
## Repo Activity
|
## Repo Activity
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_RUNTIME === "edge") {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiResponse, NextApiRequest } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
|
|
||||||
|
import { rateLimitApiKey } from "~/lib/helpers/rateLimitApiKey";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
|
||||||
|
checkRateLimitAndThrowError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("rateLimitApiKey middleware", () => {
|
||||||
|
it("should return 401 if no apiKey is provided", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(401);
|
||||||
|
expect(res._getJSONData()).toEqual({ message: "No apiKey provided" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call checkRateLimitAndThrowError with correct parameters", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "GET",
|
||||||
|
query: { apiKey: "test-key" },
|
||||||
|
});
|
||||||
|
|
||||||
|
(checkRateLimitAndThrowError as any).mockResolvedValueOnce({
|
||||||
|
limit: 100,
|
||||||
|
remaining: 99,
|
||||||
|
reset: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-expect-error weird typing between middleware and createMocks
|
||||||
|
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||||
|
|
||||||
|
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
|
||||||
|
identifier: "test-key",
|
||||||
|
rateLimitingType: "api",
|
||||||
|
onRateLimiterResponse: expect.any(Function),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set rate limit headers correctly", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "GET",
|
||||||
|
query: { apiKey: "test-key" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rateLimiterResponse = {
|
||||||
|
limit: 100,
|
||||||
|
remaining: 99,
|
||||||
|
reset: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(checkRateLimitAndThrowError as any).mockImplementationOnce(({ onRateLimiterResponse }) => {
|
||||||
|
onRateLimiterResponse(rateLimiterResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-expect-error weird typing between middleware and createMocks
|
||||||
|
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||||
|
|
||||||
|
expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit);
|
||||||
|
expect(res.getHeader("X-RateLimit-Remaining")).toBe(rateLimiterResponse.remaining);
|
||||||
|
expect(res.getHeader("X-RateLimit-Reset")).toBe(rateLimiterResponse.reset);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 429 if rate limit is exceeded", async () => {
|
||||||
|
const { req, res } = createMocks({
|
||||||
|
method: "GET",
|
||||||
|
query: { apiKey: "test-key" },
|
||||||
|
});
|
||||||
|
|
||||||
|
(checkRateLimitAndThrowError as any).mockRejectedValue(new Error("Rate limit exceeded"));
|
||||||
|
|
||||||
|
// @ts-expect-error weird typing between middleware and createMocks
|
||||||
|
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(429);
|
||||||
|
expect(res._getJSONData()).toEqual({ message: "Rate limit exceeded" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,15 +6,19 @@ export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
|
|||||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||||
|
|
||||||
// TODO: Add a way to add trusted api keys
|
// TODO: Add a way to add trusted api keys
|
||||||
await checkRateLimitAndThrowError({
|
try {
|
||||||
identifier: req.query.apiKey as string,
|
await checkRateLimitAndThrowError({
|
||||||
rateLimitingType: "api",
|
identifier: req.query.apiKey as string,
|
||||||
onRateLimiterResponse: (response) => {
|
rateLimitingType: "api",
|
||||||
res.setHeader("X-RateLimit-Limit", response.limit);
|
onRateLimiterResponse: (response) => {
|
||||||
res.setHeader("X-RateLimit-Remaining", response.remaining);
|
res.setHeader("X-RateLimit-Limit", response.limit);
|
||||||
res.setHeader("X-RateLimit-Reset", response.reset);
|
res.setHeader("X-RateLimit-Remaining", response.remaining);
|
||||||
},
|
res.setHeader("X-RateLimit-Reset", response.reset);
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(429).json({ message: "Rate limit exceeded" });
|
||||||
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextMiddleware } from "next-api-middleware";
|
import type { NextMiddleware } from "next-api-middleware";
|
||||||
|
|
||||||
|
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -18,9 +18,12 @@ export const dateNotInPast = function (date: Date) {
|
|||||||
|
|
||||||
// This verifies the apiKey and sets the user if it is valid.
|
// This verifies the apiKey and sets the user if it is valid.
|
||||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||||
const hasValidLicense = await checkLicense(prisma);
|
const licenseKeyService = await LicenseKeyService.create();
|
||||||
if (!hasValidLicense && IS_PRODUCTION)
|
const hasValidLicense = await licenseKeyService.checkLicense();
|
||||||
|
|
||||||
|
if (!hasValidLicense && IS_PRODUCTION) {
|
||||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||||
|
}
|
||||||
// Check if the apiKey query param is provided.
|
// Check if the apiKey query param is provided.
|
||||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||||
// remove the prefix from the user provided api_key. If no env set default to "cal_"
|
// remove the prefix from the user provided api_key. If no env set default to "cal_"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(sch
|
|||||||
export const schemaBookingGetParams = z.object({
|
export const schemaBookingGetParams = z.object({
|
||||||
dateFrom: iso8601.optional(),
|
dateFrom: iso8601.optional(),
|
||||||
dateTo: iso8601.optional(),
|
dateTo: iso8601.optional(),
|
||||||
|
order: z.enum(["asc", "desc"]).default("asc"),
|
||||||
|
sortBy: z.enum(["createdAt", "updatedAt"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const schemaBookingEditParams = z
|
const schemaBookingEditParams = z
|
||||||
@@ -55,7 +57,7 @@ export const schemaBookingReadPublic = Booking.extend({
|
|||||||
timeZone: true,
|
timeZone: true,
|
||||||
locale: true,
|
locale: true,
|
||||||
})
|
})
|
||||||
.optional(),
|
.nullish(),
|
||||||
payment: z
|
payment: z
|
||||||
.array(
|
.array(
|
||||||
_PaymentModel.pick({
|
_PaymentModel.pick({
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ export const withValidQueryIdTransformParseInt = withValidation({
|
|||||||
type: "Zod",
|
type: "Zod",
|
||||||
mode: "query",
|
mode: "query",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getTranscriptFromRecordingId = schemaQueryIdParseInt.extend({
|
||||||
|
recordingId: z.string(),
|
||||||
|
});
|
||||||
@@ -2,7 +2,12 @@ const { withAxiom } = require("next-axiom");
|
|||||||
const { withSentryConfig } = require("@sentry/nextjs");
|
const { withSentryConfig } = require("@sentry/nextjs");
|
||||||
|
|
||||||
const plugins = [withAxiom];
|
const plugins = [withAxiom];
|
||||||
|
|
||||||
|
/** @type {import("next").NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
instrumentationHook: true,
|
||||||
|
},
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@calcom/app-store",
|
"@calcom/app-store",
|
||||||
"@calcom/core",
|
"@calcom/core",
|
||||||
@@ -87,12 +92,12 @@ const nextConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||||
nextConfig["sentry"] = {
|
plugins.push((nextConfig) =>
|
||||||
autoInstrumentServerFunctions: true,
|
withSentryConfig(nextConfig, {
|
||||||
hideSourceMaps: true,
|
autoInstrumentServerFunctions: true,
|
||||||
};
|
hideSourceMaps: true,
|
||||||
|
})
|
||||||
plugins.push(withSentryConfig);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@calcom/lib": "*",
|
"@calcom/lib": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "*",
|
||||||
"@sentry/nextjs": "^7.73.0",
|
"@sentry/nextjs": "^8.8.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "^13.5.4",
|
"next": "^13.5.4",
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTranscriptsAccessLinkFromRecordingId,
|
||||||
|
checkIfRoomNameMatchesInRecording,
|
||||||
|
} from "@calcom/core/videoClient";
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
import type { PartialReference } from "@calcom/types/EventManager";
|
||||||
|
|
||||||
|
import { getTranscriptFromRecordingId } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /bookings/{id}/transcripts/{recordingId}:
|
||||||
|
* get:
|
||||||
|
* summary: Find all Cal video transcripts of that recording
|
||||||
|
* operationId: getTranscriptsByRecordingId
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* required: true
|
||||||
|
* description: ID of the booking for which transcripts need to be fetched.
|
||||||
|
* - in: path
|
||||||
|
* name: recordingId
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* required: true
|
||||||
|
* description: ID of the recording(daily.co recording id) for which transcripts need to be fetched.
|
||||||
|
* - in: query
|
||||||
|
* name: apiKey
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Your API key
|
||||||
|
* tags:
|
||||||
|
* - bookings
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: OK
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* 401:
|
||||||
|
* description: Authorization information is missing or invalid.
|
||||||
|
* 404:
|
||||||
|
* description: Booking was not found
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function getHandler(req: NextApiRequest) {
|
||||||
|
const { query } = req;
|
||||||
|
const { id, recordingId } = getTranscriptFromRecordingId.parse(query);
|
||||||
|
|
||||||
|
await checkIfRecordingBelongsToBooking(id, recordingId);
|
||||||
|
|
||||||
|
const transcriptsAccessLinks = await getTranscriptsAccessLinkFromRecordingId(recordingId);
|
||||||
|
|
||||||
|
return transcriptsAccessLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkIfRecordingBelongsToBooking = async (bookingId: number, recordingId: string) => {
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: { references: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: `No Booking found with booking id ${bookingId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomName =
|
||||||
|
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
if (!roomName)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: `No Booking Reference with Daily Video found with booking id ${bookingId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canUserAccessRecordingId = await checkIfRoomNameMatchesInRecording(roomName, recordingId);
|
||||||
|
if (!canUserAccessRecordingId) {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: `This Recording Id ${recordingId} does not belong to booking ${bookingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defaultResponder(getHandler);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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"),
|
||||||
|
})(req, res);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -51,6 +51,18 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
|
|||||||
* type: string
|
* type: string
|
||||||
* format: email
|
* format: email
|
||||||
* example: [john.doe@example.com, jane.doe@example.com]
|
* example: [john.doe@example.com, jane.doe@example.com]
|
||||||
|
* - in: query
|
||||||
|
* name: order
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [asc, desc]
|
||||||
|
* - in: query
|
||||||
|
* name: sortBy
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [createdAt, updatedAt]
|
||||||
* operationId: listBookings
|
* operationId: listBookings
|
||||||
* tags:
|
* tags:
|
||||||
* - bookings
|
* - bookings
|
||||||
@@ -177,11 +189,13 @@ export async function handler(req: NextApiRequest) {
|
|||||||
isOrganizationOwnerOrAdmin,
|
isOrganizationOwnerOrAdmin,
|
||||||
pagination: { take, skip },
|
pagination: { take, skip },
|
||||||
} = req;
|
} = req;
|
||||||
const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query);
|
const { dateFrom, dateTo, order, sortBy } = schemaBookingGetParams.parse(req.query);
|
||||||
|
|
||||||
const args: Prisma.BookingFindManyArgs = {};
|
const args: Prisma.BookingFindManyArgs = {};
|
||||||
args.take = take;
|
if (req.query.take && req.query.page) {
|
||||||
args.skip = skip;
|
args.take = take;
|
||||||
|
args.skip = skip;
|
||||||
|
}
|
||||||
args.include = {
|
args.include = {
|
||||||
attendees: true,
|
attendees: true,
|
||||||
user: true,
|
user: true,
|
||||||
@@ -249,6 +263,18 @@ export async function handler(req: NextApiRequest) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortBy === "updatedAt") {
|
||||||
|
args.orderBy = {
|
||||||
|
updatedAt: order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy === "createdAt") {
|
||||||
|
args.orderBy = {
|
||||||
|
createdAt: order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const data = await prisma.booking.findMany(args);
|
const data = await prisma.booking.findMany(args);
|
||||||
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { NextApiRequest } from "next";
|
|||||||
|
|
||||||
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
|
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
|
||||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||||
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
|
|
||||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
@@ -45,6 +47,10 @@ import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
|||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* format: date-time
|
||||||
* description: 'End time of the Event'
|
* description: 'End time of the Event'
|
||||||
|
* rescheduleUid:
|
||||||
|
* type: string
|
||||||
|
* format: UID
|
||||||
|
* description: 'Uid of the booking to reschedule'
|
||||||
* responses:
|
* responses:
|
||||||
* type: object
|
* type: object
|
||||||
* required:
|
* required:
|
||||||
@@ -218,7 +224,16 @@ async function handler(req: NextApiRequest) {
|
|||||||
req.userId = requestedUserId || userId;
|
req.userId = requestedUserId || userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await handleNewBooking(req, getBookingDataSchemaForApi);
|
try {
|
||||||
|
return await handleNewBooking(req, getBookingDataSchemaForApi);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const knownError = error as Error;
|
||||||
|
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultResponder(handler);
|
export default defaultResponder(handler);
|
||||||
@@ -43,7 +43,6 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
language: data.language,
|
language: data.language,
|
||||||
teamId: data.teamId,
|
teamId: data.teamId,
|
||||||
usernameOrEmail: data.usernameOrEmail,
|
usernameOrEmail: data.usernameOrEmail,
|
||||||
isOrg: data.isOrg,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, message: `${data.usernameOrEmail} has been invited.` };
|
return { success: true, message: `${data.usernameOrEmail} has been invited.` };
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { schemaUserReadPublic } from "~/lib/validations/user";
|
|||||||
|
|
||||||
async function handler({ userId }: NextApiRequest) {
|
async function handler({ userId }: NextApiRequest) {
|
||||||
const data = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
const data = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||||
return { user: schemaUserReadPublic.parse(data) };
|
return {
|
||||||
|
user: schemaUserReadPublic.parse({
|
||||||
|
...data,
|
||||||
|
avatar: data.avatarUrl,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultResponder(handler);
|
export default defaultResponder(handler);
|
||||||
@@ -69,6 +69,9 @@ import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validati
|
|||||||
* ownerId:
|
* ownerId:
|
||||||
* type: number
|
* type: number
|
||||||
* description: ID of the team owner - only admins can set this.
|
* description: ID of the team owner - only admins can set this.
|
||||||
|
* parentId:
|
||||||
|
* type: number
|
||||||
|
* description: ID of the parent organization.
|
||||||
* tags:
|
* tags:
|
||||||
* - teams
|
* - teams
|
||||||
* responses:
|
* responses:
|
||||||
@@ -99,7 +102,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if parentId is related to this user
|
// Check if parentId is related to this user and is an organization
|
||||||
if (data.parentId) {
|
if (data.parentId) {
|
||||||
const parentTeam = await prisma.team.findFirst({
|
const parentTeam = await prisma.team.findFirst({
|
||||||
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
||||||
@@ -107,7 +110,13 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
if (!parentTeam)
|
if (!parentTeam)
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
message: "Unauthorized: Invalid parent id. You can only use parent id of your own teams.",
|
message: "Unauthorized: Invalid parent id. You can only use parent id if you are org owner or admin.",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parentTeam.parentId)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "parentId must be of an organization, not a team.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +128,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!IS_TEAM_BILLING_ENABLED) {
|
if (!IS_TEAM_BILLING_ENABLED || data.parentId) {
|
||||||
const team = await prisma.team.create({
|
const team = await prisma.team.create({
|
||||||
data: {
|
data: {
|
||||||
...cloneData,
|
...cloneData,
|
||||||
@@ -135,7 +144,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
return {
|
return {
|
||||||
team: schemaTeamReadPublic.parse(team),
|
team: schemaTeamReadPublic.parse(team),
|
||||||
owner: schemaMembershipPublic.parse(team.members[0]),
|
owner: schemaMembershipPublic.parse(team.members[0]),
|
||||||
message: `Team created successfully. We also made user with ID=${ownerId} the owner of this team.`,
|
message: `Team created successfully. We also made user with ID=${effectiveUserId} the owner of this team.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
if (!isSystemWideAdmin && query.userId !== req.userId)
|
if (!isSystemWideAdmin && query.userId !== req.userId)
|
||||||
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||||
const data = await prisma.user.findUnique({ where: { id: query.userId } });
|
const data = await prisma.user.findUnique({ where: { id: query.userId } });
|
||||||
const user = schemaUserReadPublic.parse(data);
|
const user = schemaUserReadPublic.parse({
|
||||||
|
...data,
|
||||||
|
avatar: data?.avatarUrl,
|
||||||
|
});
|
||||||
return { user };
|
return { user };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ 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 { uploadAvatar } from "@calcom/lib/server/avatar";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId";
|
import { schemaQueryUserId } from "~/lib/validations/shared/queryUserId";
|
||||||
import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validations/user";
|
import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validations/user";
|
||||||
@@ -101,7 +103,8 @@ export async function patchHandler(req: NextApiRequest) {
|
|||||||
if (!isSystemWideAdmin && query.userId !== req.userId)
|
if (!isSystemWideAdmin && query.userId !== req.userId)
|
||||||
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||||
|
|
||||||
const body = await schemaUserEditBodyParams.parseAsync(req.body);
|
const { avatar, ...body }: { avatar?: string | undefined } & Prisma.UserUpdateInput =
|
||||||
|
await schemaUserEditBodyParams.parseAsync(req.body);
|
||||||
// disable role or branding changes unless admin.
|
// disable role or branding changes unless admin.
|
||||||
if (!isSystemWideAdmin) {
|
if (!isSystemWideAdmin) {
|
||||||
if (body.role) body.role = undefined;
|
if (body.role) body.role = undefined;
|
||||||
@@ -119,6 +122,14 @@ export async function patchHandler(req: NextApiRequest) {
|
|||||||
message: "Bad request: Invalid default schedule id",
|
message: "Bad request: Invalid default schedule id",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (avatar) {
|
||||||
|
body.avatarUrl = await uploadAvatar({
|
||||||
|
userId: query.userId,
|
||||||
|
avatar: await (await import("@calcom/lib/server/resizeBase64Image")).resizeBase64Image(avatar),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const data = await prisma.user.update({
|
const data = await prisma.user.update({
|
||||||
where: { id: query.userId },
|
where: { id: query.userId },
|
||||||
data: body,
|
data: body,
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { init as SentryInit } from "@sentry/nextjs";
|
|
||||||
|
|
||||||
SentryInit({
|
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
||||||
});
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { init as SentryInit } from "@sentry/nextjs";
|
|
||||||
|
|
||||||
SentryInit({
|
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
||||||
tracesSampleRate: 1.0,
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import handler from "../../../pages/api/attendees/_post";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
describe("POST /api/attendees", () => {
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("Returns 403 if user is not admin and has no booking", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
bookingId: 1,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
timeZone: "UTC",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
req.userId = 123;
|
||||||
|
// req.isAdmin = false;
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(JSON.parse(res._getData()).message).toBe("Forbidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Returns 200 if user is admin", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
bookingId: 1,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
timeZone: "UTC",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendeeData = {
|
||||||
|
id: 1,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
timeZone: "UTC",
|
||||||
|
bookingId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.attendee.create.mockResolvedValue(attendeeData);
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
req.userId = 123;
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData);
|
||||||
|
expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Returns 200 if user is not admin but has a booking", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
bookingId: 1,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
timeZone: "UTC",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userBooking = { id: 1 };
|
||||||
|
|
||||||
|
prismaMock.booking.findFirst.mockResolvedValue(userBooking);
|
||||||
|
|
||||||
|
const attendeeData = {
|
||||||
|
id: 1,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
timeZone: "UTC",
|
||||||
|
bookingId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.attendee.create.mockResolvedValue(attendeeData);
|
||||||
|
|
||||||
|
req.userId = 123;
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData);
|
||||||
|
expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Returns 400 if request body is invalid", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
// Missing required fields
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = 123;
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import prismaMock from "../../../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTranscriptsAccessLinkFromRecordingId,
|
||||||
|
checkIfRoomNameMatchesInRecording,
|
||||||
|
} from "@calcom/core/videoClient";
|
||||||
|
import { buildBooking } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
|
import authMiddleware from "../../../../../../pages/api/bookings/[id]/_auth-middleware";
|
||||||
|
import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[recordingId]/_get";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
vi.mock("@calcom/core/videoClient", () => {
|
||||||
|
return {
|
||||||
|
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
|
||||||
|
checkIfRoomNameMatchesInRecording: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
|
||||||
|
return {
|
||||||
|
getAccessibleUsers: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockGetTranscripts = () => {
|
||||||
|
const downloadLinks = [{ format: "json", link: "https://URL1" }];
|
||||||
|
|
||||||
|
vi.mocked(getTranscriptsAccessLinkFromRecordingId).mockResolvedValue(downloadLinks);
|
||||||
|
vi.mocked(checkIfRoomNameMatchesInRecording).mockResolvedValue(true);
|
||||||
|
|
||||||
|
return downloadLinks;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordingId = "abc-xyz";
|
||||||
|
|
||||||
|
describe("GET /api/bookings/[id]/transcripts/[recordingId]", () => {
|
||||||
|
test("Returns transcripts if user is system-wide admin", async () => {
|
||||||
|
const adminUserId = 1;
|
||||||
|
const userId = 2;
|
||||||
|
|
||||||
|
const bookingId = 1111;
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(
|
||||||
|
buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId,
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: "daily_video",
|
||||||
|
uid: "17OHkCH53pBa03FhxMbw",
|
||||||
|
meetingId: "17OHkCH53pBa03FhxMbw",
|
||||||
|
meetingPassword: "password",
|
||||||
|
meetingUrl: "https://URL",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockedTranscripts = mockGetTranscripts();
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
body: {},
|
||||||
|
query: {
|
||||||
|
id: bookingId,
|
||||||
|
recordingId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
req.userId = adminUserId;
|
||||||
|
|
||||||
|
await authMiddleware(req);
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res._getData())).toEqual(mockedTranscripts);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Allows GET transcripts when user is org-wide admin", async () => {
|
||||||
|
const adminUserId = 1;
|
||||||
|
const memberUserId = 10;
|
||||||
|
const bookingId = 3333;
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(
|
||||||
|
buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: memberUserId,
|
||||||
|
references: [
|
||||||
|
{ id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
body: {},
|
||||||
|
query: {
|
||||||
|
id: bookingId,
|
||||||
|
recordingId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isOrganizationOwnerOrAdmin = true;
|
||||||
|
mockGetTranscripts();
|
||||||
|
|
||||||
|
vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
|
||||||
|
|
||||||
|
await authMiddleware(req);
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
|||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import type { Booking } from "@calcom/prisma";
|
||||||
|
|
||||||
import handler from "../../../pages/api/bookings/_post";
|
import handler from "../../../pages/api/bookings/_post";
|
||||||
|
|
||||||
@@ -157,6 +158,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
|
|
||||||
describe("Success", () => {
|
describe("Success", () => {
|
||||||
describe("Regular event-type", () => {
|
describe("Regular event-type", () => {
|
||||||
|
let createdBooking: Booking;
|
||||||
test("Creates one single booking", async () => {
|
test("Creates one single booking", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -181,9 +183,43 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
|
createdBooking = JSON.parse(res._getData());
|
||||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Reschedule created booking", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: "testReschedule",
|
||||||
|
start: dayjs().add(2, "day").format(),
|
||||||
|
end: dayjs().add(3, "day").format(),
|
||||||
|
eventTypeId: 2,
|
||||||
|
email: "test@example.com",
|
||||||
|
location: "Cal.com Video",
|
||||||
|
timeZone: "America/Montevideo",
|
||||||
|
language: "en",
|
||||||
|
customInputs: [],
|
||||||
|
metadata: {},
|
||||||
|
userId: 4,
|
||||||
|
rescheduleUid: createdBooking.uid,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
|
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
||||||
|
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid);
|
||||||
|
const previousBooking = await prisma.booking.findUnique({
|
||||||
|
where: { uid: createdBooking.uid },
|
||||||
|
});
|
||||||
|
expect(previousBooking?.status).toBe("cancelled");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Recurring event-type", () => {
|
describe("Recurring event-type", () => {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, vi, it, expect, afterEach } from "vitest";
|
import { describe, vi, it, expect, afterEach, beforeEach } from "vitest";
|
||||||
|
|
||||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||||
@@ -20,20 +20,21 @@ afterEach(() => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@calcom/features/ee/common/server/checkLicense", () => {
|
vi.mock("@calcom/prisma");
|
||||||
return {
|
vi.mock("~/lib/utils/isAdmin", () => ({
|
||||||
default: vi.fn(),
|
isAdminGuard: vi.fn(),
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("~/lib/utils/isAdmin", () => {
|
|
||||||
return {
|
|
||||||
isAdminGuard: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Verify API key", () => {
|
describe("Verify API key", () => {
|
||||||
it("It should throw an error if the api key is not valid", async () => {
|
let service: LicenseKeyService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
service = await LicenseKeyService.create();
|
||||||
|
|
||||||
|
vi.spyOn(service, "checkLicense");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error if the api key is not valid", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -43,7 +44,7 @@ describe("Verify API key", () => {
|
|||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(checkLicense).mockResolvedValue(false);
|
vi.mocked(service.checkLicense).mockResolvedValue(false);
|
||||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||||
|
|
||||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
@@ -53,9 +54,11 @@ describe("Verify API key", () => {
|
|||||||
await middleware.fn(req, res, serverNext);
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
expect(middlewareSpy).toBeCalled();
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
it("It should throw an error if no api key is provided", async () => {
|
|
||||||
|
it("should throw an error if no api key is provided", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -65,7 +68,7 @@ describe("Verify API key", () => {
|
|||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(checkLicense).mockResolvedValue(true);
|
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||||
|
|
||||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
@@ -75,10 +78,11 @@ describe("Verify API key", () => {
|
|||||||
await middleware.fn(req, res, serverNext);
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
expect(middlewareSpy).toBeCalled();
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("It should set correct permissions for system-wide admin", async () => {
|
it("should set correct permissions for system-wide admin", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -97,7 +101,7 @@ describe("Verify API key", () => {
|
|||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(checkLicense).mockResolvedValue(true);
|
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
|
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
|
||||||
|
|
||||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
@@ -107,11 +111,12 @@ describe("Verify API key", () => {
|
|||||||
await middleware.fn(req, res, serverNext);
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
expect(middlewareSpy).toBeCalled();
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
|
||||||
expect(req.isSystemWideAdmin).toBe(true);
|
expect(req.isSystemWideAdmin).toBe(true);
|
||||||
expect(req.isOrganizationOwnerOrAdmin).toBe(false);
|
expect(req.isOrganizationOwnerOrAdmin).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("It should set correct permissions for org-level admin", async () => {
|
it("should set correct permissions for org-level admin", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -130,7 +135,7 @@ describe("Verify API key", () => {
|
|||||||
fn: verifyApiKey,
|
fn: verifyApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(checkLicense).mockResolvedValue(true);
|
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
|
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
|
||||||
|
|
||||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||||
@@ -140,6 +145,7 @@ describe("Verify API key", () => {
|
|||||||
await middleware.fn(req, res, serverNext);
|
await middleware.fn(req, res, serverNext);
|
||||||
|
|
||||||
expect(middlewareSpy).toBeCalled();
|
expect(middlewareSpy).toBeCalled();
|
||||||
|
|
||||||
expect(req.isSystemWideAdmin).toBe(false);
|
expect(req.isSystemWideAdmin).toBe(false);
|
||||||
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
|
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
|
||||||
|
import handler from "../../../pages/api/selected-calendars/_post";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
describe("POST /api/selected-calendars", () => {
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("Returns 403 if non-admin user tries to set userId in body", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
userId: 444444,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = 333333;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handler(req, res);
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(HttpError);
|
||||||
|
expect((e as HttpError).statusCode).toBe(403);
|
||||||
|
expect((e as HttpError).message).toBe("ADMIN required for userId");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Returns 400 if request body is invalid", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
integration: "google",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = 333333;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData()).message).toBe("invalid_type in 'externalId': Required");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Success", () => {
|
||||||
|
test("Creates selected calendar if user is admin and sets bodyUserId", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
query: {
|
||||||
|
apiKey: "validApiKey",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
userId: 444444,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = 333333;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
prismaMock.user.findFirstOrThrow.mockResolvedValue({
|
||||||
|
id: 444444,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||||
|
credentialId: 1,
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
userId: 444444,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.selected_calendar.credentialId).toBe(1);
|
||||||
|
expect(responseData.message).toBe("Selected Calendar created successfully");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Creates selected calendar if user is non-admin and does not set bodyUserId", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
query: {
|
||||||
|
apiKey: "validApiKey",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = 333333;
|
||||||
|
|
||||||
|
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||||
|
credentialId: 1,
|
||||||
|
integration: "google",
|
||||||
|
externalId: "ext123",
|
||||||
|
userId: 333333,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.selected_calendar.credentialId).toBe(1);
|
||||||
|
expect(responseData.message).toBe("Selected Calendar created successfully");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,9 +14,16 @@ NEXT_PUBLIC_SENTRY_DSN=
|
|||||||
|
|
||||||
# Stripe Billing
|
# Stripe Billing
|
||||||
STRIPE_PRICE_ID_STARTER=
|
STRIPE_PRICE_ID_STARTER=
|
||||||
|
STRIPE_PRICE_ID_STARTER_OVERAGE=
|
||||||
STRIPE_PRICE_ID_ESSENTIALS=
|
STRIPE_PRICE_ID_ESSENTIALS=
|
||||||
|
STRIPE_PRICE_ID_ESSENTIALS_OVERAGE=
|
||||||
STRIPE_PRICE_ID_ENTERPRISE=
|
STRIPE_PRICE_ID_ENTERPRISE=
|
||||||
|
STRIPE_PRICE_ID_ENTERPRISE_OVERAGE=
|
||||||
STRIPE_API_KEY=
|
STRIPE_API_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
WEB_APP_URL=http://localhost:3000/
|
WEB_APP_URL=http://localhost:3000/
|
||||||
|
CALCOM_LICENSE_KEY=
|
||||||
|
API_KEY_PREFIX=cal_
|
||||||
|
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
|
||||||
|
IS_E2E=false
|
||||||
@@ -2,5 +2,6 @@ const rootConfig = require("../../../packages/config/prettier-preset");
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...rootConfig,
|
...rootConfig,
|
||||||
|
importOrder: ["^./instrument", ...rootConfig.importOrder],
|
||||||
importOrderParserPlugins: ["typescript", "decorators-legacy"],
|
importOrderParserPlugins: ["typescript", "decorators-legacy"],
|
||||||
};
|
};
|
||||||
@@ -42,6 +42,16 @@ $ yarn prisma generate
|
|||||||
|
|
||||||
Copy `.env.example` to `.env` and fill values.
|
Copy `.env.example` to `.env` and fill values.
|
||||||
|
|
||||||
|
## Add license Key to deployments table in DB
|
||||||
|
|
||||||
|
id, logo theme licenseKey agreedLicenseAt
|
||||||
|
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
|
||||||
|
|
||||||
|
your CALCOM_LICENSE_KEY env var need to contain the same value
|
||||||
|
|
||||||
|
.env
|
||||||
|
CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb
|
||||||
|
|
||||||
## Running the app
|
## Running the app
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
|
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
|
||||||
|
"reporters": ["default", "jest-summarizing-reporter"]
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
"build": "yarn dev:build && nest build",
|
"build": "yarn dev:build && nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch & yarn workspace @calcom/platform-libraries build:watch",
|
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
|
||||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
|
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
|
||||||
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
|
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node ./dist/apps/api/v2/src/main.js",
|
"start:prod": "node ./dist/apps/api/v2/src/main.js",
|
||||||
@@ -18,18 +18,21 @@
|
|||||||
"test:watch": "yarn dev:build && jest --watch",
|
"test:watch": "yarn dev:build && jest --watch",
|
||||||
"test:cov": "yarn dev:build && jest --coverage",
|
"test:cov": "yarn dev:build && jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "yarn dev:build && jest --runInBand --config ./jest-e2e.json",
|
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
|
||||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-libraries": "*",
|
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
|
"@calcom/platform-libraries-0.0.22": "npm:@calcom/platform-libraries@0.0.22",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
"@golevelup/ts-jest": "^0.4.0",
|
"@golevelup/ts-jest": "^0.4.0",
|
||||||
|
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||||
|
"@nestjs/bull": "^10.1.1",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
@@ -38,9 +41,9 @@
|
|||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/swagger": "^7.3.0",
|
"@nestjs/swagger": "^7.3.0",
|
||||||
"@nestjs/throttler": "^5.1.2",
|
"@nestjs/throttler": "^5.1.2",
|
||||||
"@sentry/node": "^7.86.0",
|
"@sentry/node": "^8.8.0",
|
||||||
"@sentry/tracing": "^7.86.0",
|
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
|
"bull": "^4.12.4",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
@@ -55,6 +58,7 @@
|
|||||||
"next-auth": "^4.22.1",
|
"next-auth": "^4.22.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"querystring": "^0.2.1",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"stripe": "^15.3.0",
|
"stripe": "^15.3.0",
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
|
|||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { RedisService } from "@/modules/redis/redis.service";
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { BullModule } from "@nestjs/bull";
|
||||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
|
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||||
|
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
@@ -26,21 +27,20 @@ import { AppController } from "./app.controller";
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig],
|
load: [appConfig],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
RedisModule,
|
RedisModule,
|
||||||
|
BullModule.forRoot({
|
||||||
|
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
|
||||||
|
}),
|
||||||
ThrottlerModule.forRootAsync({
|
ThrottlerModule.forRootAsync({
|
||||||
imports: [RedisModule],
|
imports: [RedisModule],
|
||||||
inject: [RedisService],
|
inject: [RedisService],
|
||||||
useFactory: (redisService: RedisService) => ({
|
useFactory: (redisService: RedisService) => ({
|
||||||
throttlers: [
|
throttlers: [
|
||||||
{
|
{
|
||||||
name: "short",
|
name: "long",
|
||||||
ttl: seconds(10),
|
ttl: seconds(60), // Time to live for the long period in seconds
|
||||||
limit: 3,
|
limit: 120, // Maximum number of requests within the long ttl
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "medium",
|
|
||||||
ttl: seconds(30),
|
|
||||||
limit: 10,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||||
@@ -50,8 +50,6 @@ import { AppController } from "./app.controller";
|
|||||||
EndpointsModule,
|
EndpointsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
JwtModule,
|
JwtModule,
|
||||||
//register prefix for all routes in EndpointsModule
|
|
||||||
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
|
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -59,6 +57,10 @@ import { AppController } from "./app.controller";
|
|||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: ResponseInterceptor,
|
useClass: ResponseInterceptor,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
+34
-15
@@ -1,26 +1,41 @@
|
|||||||
import { getEnv } from "@/env";
|
import "./instrument";
|
||||||
|
|
||||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||||
import { SentryFilter } from "@/filters/sentry-exception.filter";
|
|
||||||
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||||
import type { ValidationError } from "@nestjs/common";
|
import type { ValidationError } from "@nestjs/common";
|
||||||
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
|
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||||
import { HttpAdapterHost } from "@nestjs/core";
|
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
|
||||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import * as cookieParser from "cookie-parser";
|
import * as cookieParser from "cookie-parser";
|
||||||
|
import { Request } from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
|
|
||||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
import {
|
||||||
|
API_VERSIONS,
|
||||||
|
VERSION_2024_04_15,
|
||||||
|
API_VERSIONS_ENUM,
|
||||||
|
CAL_API_VERSION_HEADER,
|
||||||
|
X_CAL_CLIENT_ID,
|
||||||
|
X_CAL_SECRET_KEY,
|
||||||
|
} from "@calcom/platform-constants";
|
||||||
|
|
||||||
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
|
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
|
||||||
|
|
||||||
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
|
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
app.enableVersioning({
|
app.enableVersioning({
|
||||||
type: VersioningType.URI,
|
type: VersioningType.CUSTOM,
|
||||||
prefix: "v",
|
extractor: (request: unknown) => {
|
||||||
defaultVersion: "1",
|
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
|
||||||
|
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
|
||||||
|
return headerVersion;
|
||||||
|
}
|
||||||
|
return VERSION_2024_04_15;
|
||||||
|
},
|
||||||
|
defaultVersion: VERSION_2024_04_15,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
@@ -28,7 +43,15 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
|||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: "*",
|
origin: "*",
|
||||||
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
|
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
|
||||||
allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"],
|
allowedHeaders: [
|
||||||
|
X_CAL_CLIENT_ID,
|
||||||
|
X_CAL_SECRET_KEY,
|
||||||
|
CAL_API_VERSION_HEADER,
|
||||||
|
"Accept",
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"Origin",
|
||||||
|
],
|
||||||
maxAge: 86_400,
|
maxAge: 86_400,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,15 +69,11 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
if (process.env.SENTRY_DSN) {
|
|
||||||
Sentry.init({
|
|
||||||
dsn: getEnv("SENTRY_DSN"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exception filters, new filters go at the bottom, keep the order
|
// Exception filters, new filters go at the bottom, keep the order
|
||||||
const { httpAdapter } = app.get(HttpAdapterHost);
|
const { httpAdapter } = app.get(HttpAdapterHost);
|
||||||
app.useGlobalFilters(new SentryFilter(httpAdapter));
|
if (process.env.SENTRY_DSN) {
|
||||||
|
Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
|
||||||
|
}
|
||||||
app.useGlobalFilters(new PrismaExceptionFilter());
|
app.useGlobalFilters(new PrismaExceptionFilter());
|
||||||
app.useGlobalFilters(new ZodExceptionFilter());
|
app.useGlobalFilters(new ZodExceptionFilter());
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const loadConfig = (): AppConfig => {
|
|||||||
? `:${Number(getEnv("API_PORT", "5555"))}`
|
? `:${Number(getEnv("API_PORT", "5555"))}`
|
||||||
: ""
|
: ""
|
||||||
}/v2`,
|
}/v2`,
|
||||||
|
keyPrefix: getEnv("API_KEY_PREFIX", "cal_"),
|
||||||
|
licenseKey: getEnv("CALCOM_LICENSE_KEY", ""),
|
||||||
|
licenseKeyUrl: getEnv("GET_LICENSE_KEY_URL", "https://console.cal.com/api/license"),
|
||||||
},
|
},
|
||||||
db: {
|
db: {
|
||||||
readUrl: getEnv("DATABASE_READ_URL"),
|
readUrl: getEnv("DATABASE_READ_URL"),
|
||||||
@@ -31,6 +34,7 @@ const loadConfig = (): AppConfig => {
|
|||||||
app: {
|
app: {
|
||||||
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
|
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
|
||||||
},
|
},
|
||||||
|
e2e: getEnv("IS_E2E", false),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export type AppConfig = {
|
|||||||
port: number;
|
port: number;
|
||||||
path: string;
|
path: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
keyPrefix: string;
|
||||||
|
licenseKey: string;
|
||||||
|
licenseKeyUrl: string;
|
||||||
};
|
};
|
||||||
db: {
|
db: {
|
||||||
readUrl: string;
|
readUrl: string;
|
||||||
@@ -22,4 +25,5 @@ export type AppConfig = {
|
|||||||
app: {
|
app: {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
};
|
};
|
||||||
|
e2e: boolean;
|
||||||
};
|
};
|
||||||
@@ -2,11 +2,10 @@ import { bootstrap } from "@/app";
|
|||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||||
import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input";
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
import { SchedulesModule } from "@/ee/schedules/schedules.module";
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
|
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
@@ -17,10 +16,10 @@ import * as request from "supertest";
|
|||||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
import { handleNewBooking } from "@calcom/platform-libraries";
|
import { handleNewBooking } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
describe("Bookings Endpoints", () => {
|
describe("Bookings Endpoints", () => {
|
||||||
@@ -29,7 +28,7 @@ describe("Bookings Endpoints", () => {
|
|||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
let schedulesService: SchedulesService;
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
|
||||||
const userEmail = "bookings-controller-e2e@api.com";
|
const userEmail = "bookings-controller-e2e@api.com";
|
||||||
@@ -40,10 +39,10 @@ describe("Bookings Endpoints", () => {
|
|||||||
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
|
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withAccessTokenAuth(
|
const moduleRef = await withApiAuth(
|
||||||
userEmail,
|
userEmail,
|
||||||
Test.createTestingModule({
|
Test.createTestingModule({
|
||||||
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, SchedulesModule],
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.overrideGuard(PermissionsGuard)
|
.overrideGuard(PermissionsGuard)
|
||||||
@@ -55,13 +54,13 @@ describe("Bookings Endpoints", () => {
|
|||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
schedulesService = moduleRef.get<SchedulesService>(SchedulesService);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
user = await userRepositoryFixture.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userSchedule: CreateScheduleInput = {
|
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||||
name: "working time",
|
name: "working time",
|
||||||
timeZone: "Europe/Rome",
|
timeZone: "Europe/Rome",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
@@ -140,7 +139,6 @@ describe("Bookings Endpoints", () => {
|
|||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get("/v2/bookings?filters[status]=upcoming")
|
.get("/v2/bookings?filters[status]=upcoming")
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
|
|
||||||
const responseBody: GetBookingsOutput = response.body;
|
const responseBody: GetBookingsOutput = response.body;
|
||||||
const fetchedBooking = responseBody.data.bookings[0];
|
const fetchedBooking = responseBody.data.bookings[0];
|
||||||
|
|
||||||
@@ -241,7 +239,8 @@ describe("Bookings Endpoints", () => {
|
|||||||
// });
|
// });
|
||||||
// });
|
// });
|
||||||
|
|
||||||
it("should cancel a booking", async () => {
|
// cancelling a booking hangs the test for some reason
|
||||||
|
it.skip("should cancel a booking", async () => {
|
||||||
const bookingId = createdBooking.id;
|
const bookingId = createdBooking.id;
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||||
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
|
||||||
|
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
|
||||||
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
|
||||||
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
|
||||||
|
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
|
||||||
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
@@ -31,20 +34,19 @@ import { Request } from "express";
|
|||||||
import { NextApiRequest } from "next/types";
|
import { NextApiRequest } from "next/types";
|
||||||
|
|
||||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||||
import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
|
||||||
import {
|
|
||||||
getAllUserBookings,
|
|
||||||
getBookingInfo,
|
|
||||||
handleCancelBooking,
|
|
||||||
getBookingForReschedule,
|
|
||||||
} from "@calcom/platform-libraries";
|
|
||||||
import {
|
import {
|
||||||
handleNewBooking,
|
handleNewBooking,
|
||||||
BookingResponse,
|
BookingResponse,
|
||||||
HttpError,
|
HttpError,
|
||||||
handleNewRecurringBooking,
|
handleNewRecurringBooking,
|
||||||
handleInstantMeeting,
|
handleInstantMeeting,
|
||||||
} from "@calcom/platform-libraries";
|
handleMarkNoShow,
|
||||||
|
getAllUserBookings,
|
||||||
|
getBookingInfo,
|
||||||
|
handleCancelBooking,
|
||||||
|
getBookingForReschedule,
|
||||||
|
} from "@calcom/platform-libraries-0.0.22";
|
||||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||||
import { ApiResponse } from "@calcom/platform-types";
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
@@ -72,8 +74,8 @@ const DEFAULT_PLATFORM_PARAMS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: "/bookings",
|
path: "/v2/bookings",
|
||||||
version: "2",
|
version: API_VERSIONS_VALUES,
|
||||||
})
|
})
|
||||||
@UseGuards(PermissionsGuard)
|
@UseGuards(PermissionsGuard)
|
||||||
@DocsTags("Bookings")
|
@DocsTags("Bookings")
|
||||||
@@ -88,7 +90,7 @@ export class BookingsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@UseGuards(AccessTokenGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@Permissions([BOOKING_READ])
|
@Permissions([BOOKING_READ])
|
||||||
@ApiQuery({ name: "filters[status]", enum: Status, required: true })
|
@ApiQuery({ name: "filters[status]", enum: Status, required: true })
|
||||||
@ApiQuery({ name: "limit", type: "number", required: false })
|
@ApiQuery({ name: "limit", type: "number", required: false })
|
||||||
@@ -148,17 +150,21 @@ export class BookingsController {
|
|||||||
@Req() req: BookingRequest,
|
@Req() req: BookingRequest,
|
||||||
@Body() body: CreateBookingInput,
|
@Body() body: CreateBookingInput,
|
||||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||||
): Promise<ApiResponse<unknown>> {
|
): Promise<ApiResponse<Partial<BookingResponse>>> {
|
||||||
const oAuthClientId = clientId?.toString();
|
const oAuthClientId = clientId?.toString();
|
||||||
|
|
||||||
const { orgSlug, locationUrl } = body;
|
const { orgSlug, locationUrl } = body;
|
||||||
req.headers["x-cal-force-slug"] = orgSlug;
|
req.headers["x-cal-force-slug"] = orgSlug;
|
||||||
try {
|
try {
|
||||||
const booking = await handleNewBooking(
|
const booking = await handleNewBooking(
|
||||||
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
|
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
|
||||||
);
|
);
|
||||||
|
if (booking.userId && booking.uid && booking.startTime) {
|
||||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||||
|
uid: booking.uid,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
fromReschedule: booking.fromReschedule,
|
||||||
|
}));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: booking,
|
data: booking,
|
||||||
@@ -175,14 +181,22 @@ export class BookingsController {
|
|||||||
@Param("bookingId") bookingId: string,
|
@Param("bookingId") bookingId: string,
|
||||||
@Body() _: CancelBookingInput,
|
@Body() _: CancelBookingInput,
|
||||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
|
||||||
const oAuthClientId = clientId?.toString();
|
const oAuthClientId = clientId?.toString();
|
||||||
if (bookingId) {
|
if (bookingId) {
|
||||||
try {
|
try {
|
||||||
req.body.id = parseInt(bookingId);
|
req.body.id = parseInt(bookingId);
|
||||||
await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
|
const res = await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
|
||||||
|
if (!res.onlyRemovedAttendee) {
|
||||||
|
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
|
data: {
|
||||||
|
bookingId: res.bookingId,
|
||||||
|
bookingUid: res.bookingUid,
|
||||||
|
onlyRemovedAttendee: res.onlyRemovedAttendee,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleBookingErrors(err);
|
this.handleBookingErrors(err);
|
||||||
@@ -193,6 +207,27 @@ export class BookingsController {
|
|||||||
throw new InternalServerErrorException("Could not cancel booking.");
|
throw new InternalServerErrorException("Could not cancel booking.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("/:bookingUid/mark-no-show")
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
async markNoShow(
|
||||||
|
@Body() body: MarkNoShowInput,
|
||||||
|
@Param("bookingUid") bookingUid: string
|
||||||
|
): Promise<MarkNoShowOutput> {
|
||||||
|
try {
|
||||||
|
const markNoShowResponse = await handleMarkNoShow({
|
||||||
|
bookingUid: bookingUid,
|
||||||
|
attendees: body.attendees,
|
||||||
|
noShowHost: body.noShowHost,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: SUCCESS_STATUS, data: markNoShowResponse };
|
||||||
|
} catch (err) {
|
||||||
|
this.handleBookingErrors(err, "no-show");
|
||||||
|
}
|
||||||
|
throw new InternalServerErrorException("Could not mark no show.");
|
||||||
|
}
|
||||||
|
|
||||||
@Post("/recurring")
|
@Post("/recurring")
|
||||||
async createRecurringBooking(
|
async createRecurringBooking(
|
||||||
@Req() req: BookingRequest,
|
@Req() req: BookingRequest,
|
||||||
@@ -205,7 +240,14 @@ export class BookingsController {
|
|||||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||||
);
|
);
|
||||||
|
|
||||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
createdBookings.forEach(async (booking) => {
|
||||||
|
if (booking.userId && booking.uid && booking.startTime) {
|
||||||
|
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||||
|
uid: booking.uid,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -230,7 +272,15 @@ export class BookingsController {
|
|||||||
await this.createNextApiBookingRequest(req, oAuthClientId)
|
await this.createNextApiBookingRequest(req, oAuthClientId)
|
||||||
);
|
);
|
||||||
|
|
||||||
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
|
if (instantMeeting.userId && instantMeeting.bookingUid) {
|
||||||
|
const now = new Date();
|
||||||
|
// add a 10 secondes delay to the usage incrementation to give some time to cancel the booking if needed
|
||||||
|
now.setSeconds(now.getSeconds() + 10);
|
||||||
|
void (await this.billingService.increaseUsageByUserId(instantMeeting.userId, {
|
||||||
|
uid: instantMeeting.bookingUid,
|
||||||
|
startTime: now,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -286,8 +336,14 @@ export class BookingsController {
|
|||||||
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
|
private handleBookingErrors(
|
||||||
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
|
err: Error | HttpError | unknown,
|
||||||
|
type?: "recurring" | `instant` | "no-show"
|
||||||
|
): void {
|
||||||
|
const errMsg =
|
||||||
|
type === "no-show"
|
||||||
|
? `Error while marking no-show.`
|
||||||
|
: `Error while creating ${type ? type + " " : ""}booking.`;
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
const httpError = err as HttpError;
|
const httpError = err as HttpError;
|
||||||
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
|
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
||||||
|
|
||||||
import type { AppsStatus } from "@calcom/platform-libraries";
|
import type { AppsStatus } from "@calcom/platform-libraries-0.0.22";
|
||||||
|
|
||||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
class Attendee {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
noShow!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkNoShowInput {
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
noShowHost?: boolean;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Attendee)
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
attendees?: Attendee[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
class Attendee {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
noShow!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HandleMarkNoShowData {
|
||||||
|
@IsString()
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
noShowHost?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
messageKey?: string;
|
||||||
|
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Attendee)
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
attendees?: Attendee[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkNoShowOutput {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: HandleMarkNoShowData,
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => HandleMarkNoShowData)
|
||||||
|
data!: HandleMarkNoShowData;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
export interface CalendarApp {
|
||||||
|
save(state: string, code: string, origin: string): Promise<{ url: string }>;
|
||||||
|
check(userId: number): Promise<ApiResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialSyncCalendarApp {
|
||||||
|
save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>;
|
||||||
|
check(userId: number): Promise<ApiResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthCalendarApp extends CalendarApp {
|
||||||
|
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
|
||||||
|
}
|
||||||
@@ -1,13 +1,29 @@
|
|||||||
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||||
|
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
|
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||||
|
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||||
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, UsersModule],
|
imports: [PrismaModule, UsersModule, TokensModule],
|
||||||
providers: [CredentialsRepository, CalendarsService],
|
providers: [
|
||||||
|
CredentialsRepository,
|
||||||
|
CalendarsService,
|
||||||
|
OutlookService,
|
||||||
|
GoogleCalendarService,
|
||||||
|
AppleCalendarService,
|
||||||
|
SelectedCalendarsRepository,
|
||||||
|
AppsRepository,
|
||||||
|
CalendarsRepository,
|
||||||
|
],
|
||||||
controllers: [CalendarsController],
|
controllers: [CalendarsController],
|
||||||
exports: [CalendarsService],
|
exports: [CalendarsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||||
|
id: true,
|
||||||
|
appId: true,
|
||||||
|
type: true,
|
||||||
|
userId: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teamId: true,
|
||||||
|
key: true,
|
||||||
|
invalid: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CalendarsRepository {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||||
|
|
||||||
|
async getCalendarCredentials(credentialId: number, userId: number) {
|
||||||
|
return await this.dbRead.prisma.credential.findFirst({
|
||||||
|
where: {
|
||||||
|
id: credentialId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
...credentialForCalendarRepositorySelect,
|
||||||
|
app: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
categories: true,
|
||||||
|
dirName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCredentials(credentialId: number) {
|
||||||
|
return await this.dbWrite.prisma.credential.delete({
|
||||||
|
where: {
|
||||||
|
id: credentialId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { bootstrap } from "@/app";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||||
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
|
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||||
|
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client";
|
||||||
|
import * as request from "supertest";
|
||||||
|
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
GOOGLE_CALENDAR,
|
||||||
|
OFFICE_365_CALENDAR,
|
||||||
|
GOOGLE_CALENDAR_TYPE,
|
||||||
|
GOOGLE_CALENDAR_ID,
|
||||||
|
} from "@calcom/platform-constants";
|
||||||
|
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||||
|
|
||||||
|
describe("Platform Calendars Endpoints", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let oAuthClient: PlatformOAuthClient;
|
||||||
|
let organization: Team;
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||||
|
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
|
||||||
|
let user: User;
|
||||||
|
let office365Credentials: Credential;
|
||||||
|
let googleCalendarCredentials: Credential;
|
||||||
|
let accessTokenSecret: string;
|
||||||
|
let refreshTokenSecret: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||||
|
imports: [AppModule, UsersModule, TokensModule],
|
||||||
|
})
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||||
|
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
|
||||||
|
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||||
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id);
|
||||||
|
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
|
||||||
|
accessTokenSecret = tokens.accessToken;
|
||||||
|
refreshTokenSecret = tokens.refreshToken;
|
||||||
|
await app.init();
|
||||||
|
jest
|
||||||
|
.spyOn(CalendarsService.prototype, "getCalendars")
|
||||||
|
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: [CLIENT_REDIRECT_URI],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(oauthClientRepositoryFixture).toBeDefined();
|
||||||
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
|
expect(oAuthClient).toBeDefined();
|
||||||
|
expect(accessTokenSecret).toBeDefined();
|
||||||
|
expect(refreshTokenSecret).toBeDefined();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should respond 401 with invalid access token`, async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||||
|
.set("Authorization", `Bearer invalid_access_token`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Uncomment this once CI is ready to run proper Office365 tests
|
||||||
|
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
|
||||||
|
// const response = await request(app.getHttpServer())
|
||||||
|
// .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||||
|
// .set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
// .set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
// .expect(200);
|
||||||
|
// const data = response.body.data;
|
||||||
|
// expect(data.authUrl).toBeDefined();
|
||||||
|
// });
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
.expect(200);
|
||||||
|
const data = response.body.data;
|
||||||
|
expect(data.authUrl).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/random-calendar/connect: it should respond 400 with a message saying the calendar type is invalid`, async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/v2/calendars/random-calendar/connect`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without access token`, async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
|
||||||
|
)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without origin`, async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
|
||||||
|
)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check without access token`, async () => {
|
||||||
|
await request(app.getHttpServer()).get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`).expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with no credentials`, async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with access token, origin and office365 credentials`, async () => {
|
||||||
|
office365Credentials = await credentialsRepositoryFixture.create(
|
||||||
|
OFFICE_365_CALENDAR_TYPE,
|
||||||
|
{},
|
||||||
|
user.id,
|
||||||
|
OFFICE_365_CALENDAR_ID
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/check with access token, origin and google calendar credentials`, async () => {
|
||||||
|
googleCalendarCredentials = await credentialsRepositoryFixture.create(
|
||||||
|
GOOGLE_CALENDAR_TYPE,
|
||||||
|
{},
|
||||||
|
user.id,
|
||||||
|
GOOGLE_CALENDAR_ID
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/v2/calendars/${GOOGLE_CALENDAR}/check`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
|
||||||
|
const body = {
|
||||||
|
id: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/calendars/${OFFICE_365_CALENDAR}/disconnect`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.send(body)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: Promise<DeletedCalendarCredentialsOutputResponseDto> = response.body;
|
||||||
|
|
||||||
|
expect((await responseBody).status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect((await responseBody).data).toBeDefined();
|
||||||
|
expect((await responseBody).data.id).toEqual(body.id);
|
||||||
|
expect((await responseBody).data.userId).toEqual(user.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await credentialsRepositoryFixture.delete(office365Credentials.id);
|
||||||
|
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,24 +1,67 @@
|
|||||||
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
|
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
|
||||||
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
|
||||||
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||||
|
import {
|
||||||
|
DeletedCalendarCredentialsOutputResponseDto,
|
||||||
|
DeletedCalendarCredentialsOutputDto,
|
||||||
|
} from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||||
|
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
|
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||||
|
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||||
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Controller, Get, UseGuards, Query } from "@nestjs/common";
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
UseGuards,
|
||||||
|
Query,
|
||||||
|
HttpStatus,
|
||||||
|
HttpCode,
|
||||||
|
Req,
|
||||||
|
Param,
|
||||||
|
Headers,
|
||||||
|
Redirect,
|
||||||
|
BadRequestException,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { APPS_READ } from "@calcom/platform-constants";
|
||||||
import { CalendarBusyTimesInput } from "@calcom/platform-types";
|
import {
|
||||||
|
SUCCESS_STATUS,
|
||||||
|
CALENDARS,
|
||||||
|
GOOGLE_CALENDAR,
|
||||||
|
OFFICE_365_CALENDAR,
|
||||||
|
APPLE_CALENDAR,
|
||||||
|
} from "@calcom/platform-constants";
|
||||||
|
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: "/calendars",
|
path: "/v2/calendars",
|
||||||
version: "2",
|
version: API_VERSIONS_VALUES,
|
||||||
})
|
})
|
||||||
@UseGuards(AccessTokenGuard)
|
|
||||||
@DocsTags("Calendars")
|
@DocsTags("Calendars")
|
||||||
export class CalendarsController {
|
export class CalendarsController {
|
||||||
constructor(private readonly calendarsService: CalendarsService) {}
|
constructor(
|
||||||
|
private readonly calendarsService: CalendarsService,
|
||||||
|
private readonly outlookService: OutlookService,
|
||||||
|
private readonly googleCalendarService: GoogleCalendarService,
|
||||||
|
private readonly appleCalendarService: AppleCalendarService,
|
||||||
|
private readonly calendarsRepository: CalendarsRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
@Get("/busy-times")
|
@Get("/busy-times")
|
||||||
async getBusyTimes(
|
async getBusyTimes(
|
||||||
@Query() queryParams: CalendarBusyTimesInput,
|
@Query() queryParams: CalendarBusyTimesInput,
|
||||||
@@ -47,6 +90,7 @@ export class CalendarsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
|
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
|
||||||
const calendars = await this.calendarsService.getCalendars(userId);
|
const calendars = await this.calendarsService.getCalendars(userId);
|
||||||
|
|
||||||
@@ -55,4 +99,121 @@ export class CalendarsController {
|
|||||||
data: calendars,
|
data: calendars,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@Get("/:calendar/connect")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async redirect(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Headers("Authorization") authorization: string,
|
||||||
|
@Param("calendar") calendar: string,
|
||||||
|
@Query("redir") redir?: string | null
|
||||||
|
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||||
|
switch (calendar) {
|
||||||
|
case OFFICE_365_CALENDAR:
|
||||||
|
return await this.outlookService.connect(authorization, req, redir ?? "");
|
||||||
|
case GOOGLE_CALENDAR:
|
||||||
|
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Invalid calendar type, available calendars are: ",
|
||||||
|
CALENDARS.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/:calendar/save")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Redirect(undefined, 301)
|
||||||
|
async save(
|
||||||
|
@Query("state") state: string,
|
||||||
|
@Query("code") code: string,
|
||||||
|
@Param("calendar") calendar: string
|
||||||
|
): Promise<{ url: string }> {
|
||||||
|
// state params contains our user access token
|
||||||
|
const stateParams = new URLSearchParams(state);
|
||||||
|
const { accessToken, origin, redir } = z
|
||||||
|
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
|
||||||
|
.parse({
|
||||||
|
accessToken: stateParams.get("accessToken"),
|
||||||
|
origin: stateParams.get("origin"),
|
||||||
|
redir: stateParams.get("redir"),
|
||||||
|
});
|
||||||
|
switch (calendar) {
|
||||||
|
case OFFICE_365_CALENDAR:
|
||||||
|
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
|
||||||
|
case GOOGLE_CALENDAR:
|
||||||
|
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Invalid calendar type, available calendars are: ",
|
||||||
|
CALENDARS.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@Post("/:calendar/credentials")
|
||||||
|
async syncCredentials(
|
||||||
|
@GetUser() user: User,
|
||||||
|
@Param("calendar") calendar: string,
|
||||||
|
@Body() body: { username: string; password: string }
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
const { username, password } = body;
|
||||||
|
|
||||||
|
switch (calendar) {
|
||||||
|
case APPLE_CALENDAR:
|
||||||
|
return await this.appleCalendarService.save(user.id, user.email, username, password);
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Invalid calendar type, available calendars are: ",
|
||||||
|
CALENDARS.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/:calendar/check")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||||
|
@Permissions([APPS_READ])
|
||||||
|
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
|
||||||
|
switch (calendar) {
|
||||||
|
case OFFICE_365_CALENDAR:
|
||||||
|
return await this.outlookService.check(userId);
|
||||||
|
case GOOGLE_CALENDAR:
|
||||||
|
return await this.googleCalendarService.check(userId);
|
||||||
|
case APPLE_CALENDAR:
|
||||||
|
return await this.appleCalendarService.check(userId);
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Invalid calendar type, available calendars are: ",
|
||||||
|
CALENDARS.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(ApiAuthGuard)
|
||||||
|
@Post("/:calendar/disconnect")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async deleteCalendarCredentials(
|
||||||
|
@Param("calendar") calendar: string,
|
||||||
|
@Body() body: DeleteCalendarCredentialsInputBodyDto,
|
||||||
|
@GetUser() user: UserWithProfile
|
||||||
|
): Promise<DeletedCalendarCredentialsOutputResponseDto> {
|
||||||
|
const { id: credentialId } = body;
|
||||||
|
await this.calendarsService.checkCalendarCredentials(credentialId, user.id);
|
||||||
|
|
||||||
|
const { id, type, userId, teamId, appId, invalid } = await this.calendarsRepository.deleteCredentials(
|
||||||
|
credentialId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(
|
||||||
|
DeletedCalendarCredentialsOutputDto,
|
||||||
|
{ id, type, userId, teamId, appId, invalid },
|
||||||
|
{ strategy: "excludeAll" }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Expose } from "class-transformer";
|
||||||
|
import { IsInt } from "class-validator";
|
||||||
|
|
||||||
|
export class DeleteCalendarCredentialsInputBodyDto {
|
||||||
|
@IsInt()
|
||||||
|
@Expose()
|
||||||
|
@ApiProperty({
|
||||||
|
example: 10,
|
||||||
|
description: "Credential ID of the calendar to delete, as returned by the /calendars endpoint",
|
||||||
|
type: "integer",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
readonly id!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Expose, Type } from "class-transformer";
|
||||||
|
import { IsString, ValidateNested, IsEnum, IsInt, IsBoolean } from "class-validator";
|
||||||
|
|
||||||
|
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export class DeletedCalendarCredentialsOutputDto {
|
||||||
|
@IsInt()
|
||||||
|
@Expose()
|
||||||
|
readonly id!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
readonly type!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Expose()
|
||||||
|
readonly userId!: number | null;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Expose()
|
||||||
|
readonly teamId!: number | null;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Expose()
|
||||||
|
readonly appId!: string | null;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@Expose()
|
||||||
|
readonly invalid!: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeletedCalendarCredentialsOutputResponseDto {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => DeletedCalendarCredentialsOutputDto)
|
||||||
|
data!: DeletedCalendarCredentialsOutputDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||||
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
||||||
|
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.22";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||||
|
constructor(
|
||||||
|
private readonly calendarsService: CalendarsService,
|
||||||
|
private readonly credentialRepository: CredentialsRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async save(
|
||||||
|
userId: number,
|
||||||
|
userEmail: string,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
return await this.saveCalendarCredentials(userId, userEmail, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
|
return await this.checkIfCalendarConnected(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
|
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||||
|
APPLE_CALENDAR_TYPE,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!appleCalendarCredentials) {
|
||||||
|
throw new BadRequestException("Credentials for apple calendar not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appleCalendarCredentials.invalid) {
|
||||||
|
throw new BadRequestException("Invalid apple calendar credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||||
|
const appleCalendar = connectedCalendars.find(
|
||||||
|
(cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE
|
||||||
|
);
|
||||||
|
if (!appleCalendar) {
|
||||||
|
throw new UnauthorizedException("Apple calendar not connected.");
|
||||||
|
}
|
||||||
|
if (appleCalendar.error?.message) {
|
||||||
|
throw new UnauthorizedException(appleCalendar.error?.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
|
||||||
|
if (username.length <= 1 || password.length <= 1)
|
||||||
|
throw new BadRequestException(`Username or password cannot be empty`);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
type: APPLE_CALENDAR_TYPE,
|
||||||
|
key: symmetricEncrypt(
|
||||||
|
JSON.stringify({ username, password }),
|
||||||
|
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||||
|
),
|
||||||
|
userId: userId,
|
||||||
|
teamId: null,
|
||||||
|
appId: APPLE_CALENDAR_ID,
|
||||||
|
invalid: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dav = new CalendarService({
|
||||||
|
id: 0,
|
||||||
|
...data,
|
||||||
|
user: { email: userEmail },
|
||||||
|
});
|
||||||
|
await dav?.listCalendars();
|
||||||
|
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||||
|
} catch (reason) {
|
||||||
|
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import {
|
import {
|
||||||
CredentialsRepository,
|
CredentialsRepository,
|
||||||
CredentialsWithUserEmail,
|
CredentialsWithUserEmail,
|
||||||
@@ -11,21 +13,27 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries";
|
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { getBusyCalendarTimes } from "@calcom/platform-libraries";
|
|
||||||
import { Calendar } from "@calcom/platform-types";
|
import { Calendar } from "@calcom/platform-types";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CalendarsService {
|
export class CalendarsService {
|
||||||
|
private oAuthCalendarResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly credentialsRepository: CredentialsRepository,
|
private readonly credentialsRepository: CredentialsRepository,
|
||||||
|
private readonly appsRepository: AppsRepository,
|
||||||
|
private readonly calendarsRepository: CalendarsRepository,
|
||||||
private readonly dbRead: PrismaReadService,
|
private readonly dbRead: PrismaReadService,
|
||||||
private readonly dbWrite: PrismaWriteService
|
private readonly dbWrite: PrismaWriteService,
|
||||||
|
private readonly config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCalendars(userId: number) {
|
async getCalendars(userId: number) {
|
||||||
@@ -110,4 +118,31 @@ export class CalendarsService {
|
|||||||
});
|
});
|
||||||
return composedSelectedCalendars;
|
return composedSelectedCalendars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAppKeys(appName: string) {
|
||||||
|
const app = await this.appsRepository.getAppBySlug(appName);
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client_id, client_secret } = this.oAuthCalendarResponseSchema.parse(app.keys);
|
||||||
|
|
||||||
|
if (!client_id) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client_secret) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { client_id, client_secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkCalendarCredentials(credentialId: number, userId: number) {
|
||||||
|
const credential = await this.calendarsRepository.getCalendarCredentials(credentialId, userId);
|
||||||
|
if (!credential) {
|
||||||
|
throw new NotFoundException("Calendar credentials not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||||
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { google } from "googleapis";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
const CALENDAR_SCOPES = [
|
||||||
|
"https://www.googleapis.com/auth/calendar.readonly",
|
||||||
|
"https://www.googleapis.com/auth/calendar.events",
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleCalendarService implements OAuthCalendarApp {
|
||||||
|
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
|
||||||
|
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
|
||||||
|
private logger = new Logger("GcalService");
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly appsRepository: AppsRepository,
|
||||||
|
private readonly credentialRepository: CredentialsRepository,
|
||||||
|
private readonly calendarsService: CalendarsService,
|
||||||
|
private readonly tokensRepository: TokensRepository,
|
||||||
|
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async connect(
|
||||||
|
authorization: string,
|
||||||
|
req: Request,
|
||||||
|
redir?: string
|
||||||
|
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||||
|
const accessToken = authorization.replace("Bearer ", "");
|
||||||
|
const origin = req.get("origin") ?? req.get("host");
|
||||||
|
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||||
|
|
||||||
|
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||||
|
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
|
return await this.checkIfCalendarConnected(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||||
|
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||||
|
|
||||||
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
|
access_type: "offline",
|
||||||
|
scope: CALENDAR_SCOPES,
|
||||||
|
prompt: "consent",
|
||||||
|
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOAuthClient(redirectUri: string) {
|
||||||
|
this.logger.log("Getting Google Calendar OAuth Client");
|
||||||
|
const app = await this.appsRepository.getAppBySlug("google-calendar");
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
|
||||||
|
|
||||||
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
|
||||||
|
return oAuth2Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
|
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||||
|
|
||||||
|
if (!gcalCredentials) {
|
||||||
|
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gcalCredentials.invalid) {
|
||||||
|
throw new BadRequestException("Invalid google oauth credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||||
|
const googleCalendar = connectedCalendars.find(
|
||||||
|
(cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE
|
||||||
|
);
|
||||||
|
if (!googleCalendar) {
|
||||||
|
throw new UnauthorizedException("Google Calendar not connected.");
|
||||||
|
}
|
||||||
|
if (googleCalendar.error?.message) {
|
||||||
|
throw new UnauthorizedException(googleCalendar.error?.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: SUCCESS_STATUS };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCalendarCredentialsAndRedirect(
|
||||||
|
code: string,
|
||||||
|
accessToken: string,
|
||||||
|
origin: string,
|
||||||
|
redir?: string
|
||||||
|
) {
|
||||||
|
// User chose not to authorize your app or didn't authorize your app
|
||||||
|
// redirect directly without oauth code
|
||||||
|
if (!code) {
|
||||||
|
return { url: redir || origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedCode = z.string().parse(code);
|
||||||
|
|
||||||
|
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||||
|
|
||||||
|
if (!ownerId) {
|
||||||
|
throw new UnauthorizedException("Invalid Access token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||||
|
const token = await oAuth2Client.getToken(parsedCode);
|
||||||
|
// Google oAuth Credentials are stored in token.tokens
|
||||||
|
const key = token.tokens;
|
||||||
|
const credential = await this.credentialRepository.createAppCredential(
|
||||||
|
GOOGLE_CALENDAR_TYPE,
|
||||||
|
key as Prisma.InputJsonValue,
|
||||||
|
ownerId
|
||||||
|
);
|
||||||
|
|
||||||
|
oAuth2Client.setCredentials(key);
|
||||||
|
|
||||||
|
const calendar = google.calendar({
|
||||||
|
version: "v3",
|
||||||
|
auth: oAuth2Client,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
|
||||||
|
|
||||||
|
const primaryCal = cals.data.items?.find((cal) => cal.primary);
|
||||||
|
|
||||||
|
if (primaryCal?.id) {
|
||||||
|
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||||
|
primaryCal.id,
|
||||||
|
credential.id,
|
||||||
|
ownerId,
|
||||||
|
GOOGLE_CALENDAR_TYPE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: redir || origin };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||||
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
|
||||||
|
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { stringify } from "querystring";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SUCCESS_STATUS,
|
||||||
|
OFFICE_365_CALENDAR,
|
||||||
|
OFFICE_365_CALENDAR_ID,
|
||||||
|
OFFICE_365_CALENDAR_TYPE,
|
||||||
|
} from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutlookService implements OAuthCalendarApp {
|
||||||
|
private redirectUri = `${this.config.get("api.url")}/calendars/${OFFICE_365_CALENDAR}/save`;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly calendarsService: CalendarsService,
|
||||||
|
private readonly credentialRepository: CredentialsRepository,
|
||||||
|
private readonly tokensRepository: TokensRepository,
|
||||||
|
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async connect(
|
||||||
|
authorization: string,
|
||||||
|
req: Request,
|
||||||
|
redir?: string
|
||||||
|
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||||
|
const accessToken = authorization.replace("Bearer ", "");
|
||||||
|
const origin = req.get("origin") ?? req.get("host");
|
||||||
|
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||||
|
|
||||||
|
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||||
|
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
|
return await this.checkIfCalendarConnected(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||||
|
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||||
|
|
||||||
|
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||||
|
const params = {
|
||||||
|
response_type: "code",
|
||||||
|
scope: scopes.join(" "),
|
||||||
|
client_id,
|
||||||
|
prompt: "select_account",
|
||||||
|
redirect_uri: this.redirectUri,
|
||||||
|
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = stringify(params);
|
||||||
|
|
||||||
|
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
|
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||||
|
"office365_calendar",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!office365CalendarCredentials) {
|
||||||
|
throw new BadRequestException("Credentials for office_365_calendar not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (office365CalendarCredentials.invalid) {
|
||||||
|
throw new BadRequestException("Invalid office 365 calendar credentials.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||||
|
const office365Calendar = connectedCalendars.find(
|
||||||
|
(cal: { integration: { type: string } }) => cal.integration.type === OFFICE_365_CALENDAR_TYPE
|
||||||
|
);
|
||||||
|
if (!office365Calendar) {
|
||||||
|
throw new UnauthorizedException("Office 365 calendar not connected.");
|
||||||
|
}
|
||||||
|
if (office365Calendar.error?.message) {
|
||||||
|
throw new UnauthorizedException(office365Calendar.error?.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOAuthCredentials(code: string) {
|
||||||
|
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
|
||||||
|
const { client_id, client_secret } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||||
|
|
||||||
|
const toUrlEncoded = (payload: Record<string, string>) =>
|
||||||
|
Object.keys(payload)
|
||||||
|
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
const body = toUrlEncoded({
|
||||||
|
client_id,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
scope: scopes.join(" "),
|
||||||
|
redirect_uri: this.redirectUri,
|
||||||
|
client_secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = await response.json();
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDefaultCalendar(accessToken: string): Promise<OfficeCalendar> {
|
||||||
|
const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const responseBody = await response.json();
|
||||||
|
|
||||||
|
return responseBody as OfficeCalendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCalendarCredentialsAndRedirect(
|
||||||
|
code: string,
|
||||||
|
accessToken: string,
|
||||||
|
origin: string,
|
||||||
|
redir?: string
|
||||||
|
) {
|
||||||
|
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
|
||||||
|
if (!code) {
|
||||||
|
return { url: redir || origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedCode = z.string().parse(code);
|
||||||
|
|
||||||
|
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||||
|
|
||||||
|
if (!ownerId) {
|
||||||
|
throw new UnauthorizedException("Invalid Access token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const office365OAuthCredentials = await this.getOAuthCredentials(parsedCode);
|
||||||
|
|
||||||
|
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
|
||||||
|
|
||||||
|
if (defaultCalendar?.id) {
|
||||||
|
const credential = await this.credentialRepository.createAppCredential(
|
||||||
|
OFFICE_365_CALENDAR_TYPE,
|
||||||
|
office365OAuthCredentials,
|
||||||
|
ownerId
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.selectedCalendarsRepository.createSelectedCalendar(
|
||||||
|
defaultCalendar.id,
|
||||||
|
credential.id,
|
||||||
|
ownerId,
|
||||||
|
OFFICE_365_CALENDAR_TYPE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: redir || origin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { EventTypesController } from "@/ee/event-types/controllers/event-types.controller";
|
|
||||||
import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
|
|
||||||
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
|
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
|
||||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
|
||||||
import { Module } from "@nestjs/common";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
|
|
||||||
providers: [EventTypesRepository, EventTypesService],
|
|
||||||
controllers: [EventTypesController],
|
|
||||||
exports: [EventTypesService, EventTypesRepository],
|
|
||||||
})
|
|
||||||
export class EventTypesModule {}
|
|
||||||
File renamed without changes.
+143
-21
@@ -1,11 +1,13 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
import { EventTypesModule } from "@/ee/event-types/event-types.module";
|
import { Editable } from "@/ee/event-types/event-types_2024_04_15//inputs/enums/editable";
|
||||||
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
|
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||||
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
|
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||||
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
|
import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
|
||||||
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
|
import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
|
||||||
import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output";
|
import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
|
||||||
|
import { GetEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output";
|
||||||
|
import { GetEventTypesPublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output";
|
||||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
@@ -20,10 +22,20 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type
|
|||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import {
|
||||||
import { EventTypesByViewer, EventTypesPublic } from "@calcom/platform-libraries";
|
SUCCESS_STATUS,
|
||||||
|
VERSION_2024_06_11,
|
||||||
|
VERSION_2024_04_15,
|
||||||
|
CAL_API_VERSION_HEADER,
|
||||||
|
} from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
EventTypesByViewer,
|
||||||
|
EventTypesPublic,
|
||||||
|
eventTypeBookingFields,
|
||||||
|
eventTypeLocations,
|
||||||
|
} from "@calcom/platform-libraries-0.0.22";
|
||||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
describe("Event types Endpoints", () => {
|
describe("Event types Endpoints", () => {
|
||||||
@@ -33,7 +45,7 @@ describe("Event types Endpoints", () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||||
imports: [AppModule, UsersModule, EventTypesModule, TokensModule],
|
imports: [AppModule, UsersModule, EventTypesModule_2024_04_15, TokensModule],
|
||||||
})
|
})
|
||||||
.overrideGuard(PermissionsGuard)
|
.overrideGuard(PermissionsGuard)
|
||||||
.useValue({
|
.useValue({
|
||||||
@@ -72,11 +84,11 @@ describe("Event types Endpoints", () => {
|
|||||||
let user: User;
|
let user: User;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withAccessTokenAuth(
|
const moduleRef = await withApiAuth(
|
||||||
userEmail,
|
userEmail,
|
||||||
Test.createTestingModule({
|
Test.createTestingModule({
|
||||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||||
imports: [AppModule, UsersModule, EventTypesModule, TokensModule],
|
imports: [AppModule, UsersModule, EventTypesModule_2024_04_15, TokensModule],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.overrideGuard(PermissionsGuard)
|
.overrideGuard(PermissionsGuard)
|
||||||
@@ -125,7 +137,7 @@ describe("Event types Endpoints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should create an event type", async () => {
|
it("should create an event type", async () => {
|
||||||
const body: CreateEventTypeInput = {
|
const body: CreateEventTypeInput_2024_04_15 = {
|
||||||
title: "Test Event Type",
|
title: "Test Event Type",
|
||||||
slug: "test-event-type",
|
slug: "test-event-type",
|
||||||
description: "A description of the test event type.",
|
description: "A description of the test event type.",
|
||||||
@@ -147,6 +159,7 @@ describe("Event types Endpoints", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post("/api/v2/event-types")
|
.post("/api/v2/event-types")
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||||
.send(body)
|
.send(body)
|
||||||
.expect(201)
|
.expect(201)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@@ -166,7 +179,7 @@ describe("Event types Endpoints", () => {
|
|||||||
it("should update event type", async () => {
|
it("should update event type", async () => {
|
||||||
const newTitle = "Updated title";
|
const newTitle = "Updated title";
|
||||||
|
|
||||||
const body: UpdateEventTypeInput = {
|
const body: UpdateEventTypeInput_2024_04_15 = {
|
||||||
title: newTitle,
|
title: newTitle,
|
||||||
disableGuests: false,
|
disableGuests: false,
|
||||||
slotInterval: 30,
|
slotInterval: 30,
|
||||||
@@ -177,6 +190,7 @@ describe("Event types Endpoints", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||||
.send(body)
|
.send(body)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@@ -197,10 +211,110 @@ describe("Event types Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return 400 if param event type id is null", async () => {
|
||||||
|
const locations = [{ type: "inPerson", address: "123 Main St" }];
|
||||||
|
|
||||||
|
const body: UpdateEventTypeInput_2024_04_15 = {
|
||||||
|
locations,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer()).patch(`/api/v2/event-types/null`).send(body).expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update event type locations", async () => {
|
||||||
|
const locations = [{ type: "inPerson", address: "123 Main St" }];
|
||||||
|
|
||||||
|
const body: UpdateEventTypeInput_2024_04_15 = {
|
||||||
|
locations,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||||
|
.send(body)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||||
|
const responseLocations = eventTypeLocations.parse(responseBody.data.locations);
|
||||||
|
expect(responseLocations).toBeDefined();
|
||||||
|
expect(responseLocations.length).toEqual(locations.length);
|
||||||
|
expect(responseLocations).toEqual(locations);
|
||||||
|
eventType.locations = responseLocations;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update event type bookingFields", async () => {
|
||||||
|
const bookingFieldName = "location-name";
|
||||||
|
const bookingFields = [
|
||||||
|
{
|
||||||
|
name: bookingFieldName,
|
||||||
|
type: BaseField.radio,
|
||||||
|
label: "Location",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "Via Bari 10, Roma, 90119, Italy",
|
||||||
|
value: "Via Bari 10, Roma, 90119, Italy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Via Reale 28, Roma, 9001, Italy",
|
||||||
|
value: "Via Reale 28, Roma, 9001, Italy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "user",
|
||||||
|
label: "User",
|
||||||
|
fieldRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
editable: Editable.user,
|
||||||
|
required: true,
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const body: UpdateEventTypeInput_2024_04_15 = {
|
||||||
|
bookingFields,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||||
|
.send(body)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||||
|
const responseBookingFields = eventTypeBookingFields.parse(responseBody.data.bookingFields);
|
||||||
|
expect(responseBookingFields).toBeDefined();
|
||||||
|
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
||||||
|
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
||||||
|
expect(responseBookingField).toEqual(bookingFields[0]);
|
||||||
|
eventType.bookingFields = responseBookingFields;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it(`/GET/:id`, async () => {
|
it(`/GET/:id`, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v2/event-types/${eventType.id}`)
|
.get(`/api/v2/event-types/${eventType.id}`)
|
||||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||||
|
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||||
|
.set("Authorization", `Bearer whatever`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const responseBody: GetEventTypeOutput = response.body;
|
||||||
|
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.eventType.id).toEqual(eventType.id);
|
||||||
|
expect(responseBody.data.eventType.title).toEqual(eventType.title);
|
||||||
|
expect(responseBody.data.eventType.slug).toEqual(eventType.slug);
|
||||||
|
expect(responseBody.data.eventType.userId).toEqual(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`/GET/:id with version VERSION_2024_06_11`, async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v2/event-types/${eventType.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
|
||||||
|
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||||
.set("Authorization", `Bearer whatever`)
|
.set("Authorization", `Bearer whatever`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -217,7 +331,8 @@ describe("Event types Endpoints", () => {
|
|||||||
it(`/GET/:username/public`, async () => {
|
it(`/GET/:username/public`, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v2/event-types/${username}/public`)
|
.get(`/api/v2/event-types/${username}/public`)
|
||||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||||
|
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||||
.set("Authorization", `Bearer whatever`)
|
.set("Authorization", `Bearer whatever`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -235,7 +350,8 @@ describe("Event types Endpoints", () => {
|
|||||||
it(`/GET/:username/:eventSlug/public`, async () => {
|
it(`/GET/:username/:eventSlug/public`, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v2/event-types/${username}/${eventType.slug}/public`)
|
.get(`/api/v2/event-types/${username}/${eventType.slug}/public`)
|
||||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||||
|
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||||
.set("Authorization", `Bearer whatever`)
|
.set("Authorization", `Bearer whatever`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -252,7 +368,8 @@ describe("Event types Endpoints", () => {
|
|||||||
it(`/GET/`, async () => {
|
it(`/GET/`, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v2/event-types`)
|
.get(`/api/v2/event-types`)
|
||||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||||
|
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||||
.set("Authorization", `Bearer whatever`)
|
.set("Authorization", `Bearer whatever`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -271,7 +388,8 @@ describe("Event types Endpoints", () => {
|
|||||||
it(`/GET/public/:username/`, async () => {
|
it(`/GET/public/:username/`, async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v2/event-types/${username}/public`)
|
.get(`/api/v2/event-types/${username}/public`)
|
||||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||||
|
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||||
.set("Authorization", `Bearer whatever`)
|
.set("Authorization", `Bearer whatever`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -287,13 +405,17 @@ describe("Event types Endpoints", () => {
|
|||||||
it(`/GET/:id not existing`, async () => {
|
it(`/GET/:id not existing`, async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/api/v2/event-types/1000`)
|
.get(`/api/v2/event-types/1000`)
|
||||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||||
|
// note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above
|
||||||
.set("Authorization", `Bearer whatever`)
|
.set("Authorization", `Bearer whatever`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should delete schedule", async () => {
|
it("should delete schedule", async () => {
|
||||||
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
|
return request(app.getHttpServer())
|
||||||
|
.delete(`/api/v2/event-types/${eventType.id}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_04_15)
|
||||||
|
.expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
+37
-29
@@ -1,17 +1,22 @@
|
|||||||
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
|
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||||
import { GetPublicEventTypeQueryParams } from "@/ee/event-types/inputs/get-public-event-type-query-params.input";
|
import { EventTypeIdParams_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-id.input";
|
||||||
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
|
import { GetPublicEventTypeQueryParams_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/get-public-event-type-query-params.input";
|
||||||
import { CreateEventTypeOutput } from "@/ee/event-types/outputs/create-event-type.output";
|
import { UpdateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
|
||||||
import { DeleteEventTypeOutput } from "@/ee/event-types/outputs/delete-event-type.output";
|
import { CreateEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/create-event-type.output";
|
||||||
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
|
import { DeleteEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/delete-event-type.output";
|
||||||
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
|
import { GetEventTypePublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type-public.output";
|
||||||
import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output";
|
import { GetEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-type.output";
|
||||||
import { GetEventTypesData, GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
|
import { GetEventTypesPublicOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types-public.output";
|
||||||
import { UpdateEventTypeOutput } from "@/ee/event-types/outputs/update-event-type.output";
|
import {
|
||||||
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
|
GetEventTypesData,
|
||||||
|
GetEventTypesOutput,
|
||||||
|
} from "@/ee/event-types/event-types_2024_04_15/outputs/get-event-types.output";
|
||||||
|
import { UpdateEventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/update-event-type.output";
|
||||||
|
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||||
|
import { VERSION_2024_04_15, VERSION_2024_06_11 } from "@/lib/api-versions";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
@@ -29,31 +34,32 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Query,
|
Query,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
|
ParseIntPipe,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { getPublicEvent } from "@calcom/platform-libraries";
|
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||||
import { getEventTypesByViewer } from "@calcom/platform-libraries";
|
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: "event-types",
|
path: "/v2/event-types",
|
||||||
version: "2",
|
version: [VERSION_2024_04_15, VERSION_2024_06_11],
|
||||||
})
|
})
|
||||||
@UseGuards(PermissionsGuard)
|
@UseGuards(PermissionsGuard)
|
||||||
@DocsTags("Event types")
|
@DocsTags("Event types")
|
||||||
export class EventTypesController {
|
export class EventTypesController_2024_04_15 {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly eventTypesService: EventTypesService,
|
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||||
private readonly prismaReadService: PrismaReadService
|
private readonly prismaReadService: PrismaReadService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@Permissions([EVENT_TYPE_WRITE])
|
@Permissions([EVENT_TYPE_WRITE])
|
||||||
@UseGuards(AccessTokenGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
async createEventType(
|
async createEventType(
|
||||||
@Body() body: CreateEventTypeInput,
|
@Body() body: CreateEventTypeInput_2024_04_15,
|
||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
): Promise<CreateEventTypeOutput> {
|
): Promise<CreateEventTypeOutput> {
|
||||||
const eventType = await this.eventTypesService.createUserEventType(user, body);
|
const eventType = await this.eventTypesService.createUserEventType(user, body);
|
||||||
@@ -66,9 +72,9 @@ export class EventTypesController {
|
|||||||
|
|
||||||
@Get("/:eventTypeId")
|
@Get("/:eventTypeId")
|
||||||
@Permissions([EVENT_TYPE_READ])
|
@Permissions([EVENT_TYPE_READ])
|
||||||
@UseGuards(AccessTokenGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
async getEventType(
|
async getEventType(
|
||||||
@Param("eventTypeId") eventTypeId: string,
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
): Promise<GetEventTypeOutput> {
|
): Promise<GetEventTypeOutput> {
|
||||||
const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId));
|
const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId));
|
||||||
@@ -85,7 +91,7 @@ export class EventTypesController {
|
|||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@Permissions([EVENT_TYPE_READ])
|
@Permissions([EVENT_TYPE_READ])
|
||||||
@UseGuards(AccessTokenGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
async getEventTypes(@GetUser() user: UserWithProfile): Promise<GetEventTypesOutput> {
|
async getEventTypes(@GetUser() user: UserWithProfile): Promise<GetEventTypesOutput> {
|
||||||
const eventTypes = await getEventTypesByViewer({
|
const eventTypes = await getEventTypesByViewer({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -104,7 +110,7 @@ export class EventTypesController {
|
|||||||
async getPublicEventType(
|
async getPublicEventType(
|
||||||
@Param("username") username: string,
|
@Param("username") username: string,
|
||||||
@Param("eventSlug") eventSlug: string,
|
@Param("eventSlug") eventSlug: string,
|
||||||
@Query() queryParams: GetPublicEventTypeQueryParams
|
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
|
||||||
): Promise<GetEventTypePublicOutput> {
|
): Promise<GetEventTypePublicOutput> {
|
||||||
try {
|
try {
|
||||||
const event = await getPublicEvent(
|
const event = await getPublicEvent(
|
||||||
@@ -141,11 +147,12 @@ export class EventTypesController {
|
|||||||
|
|
||||||
@Patch("/:eventTypeId")
|
@Patch("/:eventTypeId")
|
||||||
@Permissions([EVENT_TYPE_WRITE])
|
@Permissions([EVENT_TYPE_WRITE])
|
||||||
@UseGuards(AccessTokenGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async updateEventType(
|
async updateEventType(
|
||||||
@Param("eventTypeId") eventTypeId: number,
|
@Param() params: EventTypeIdParams_2024_04_15,
|
||||||
@Body() body: UpdateEventTypeInput,
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
|
@Body() body: UpdateEventTypeInput_2024_04_15,
|
||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
): Promise<UpdateEventTypeOutput> {
|
): Promise<UpdateEventTypeOutput> {
|
||||||
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
|
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
|
||||||
@@ -158,9 +165,10 @@ export class EventTypesController {
|
|||||||
|
|
||||||
@Delete("/:eventTypeId")
|
@Delete("/:eventTypeId")
|
||||||
@Permissions([EVENT_TYPE_WRITE])
|
@Permissions([EVENT_TYPE_WRITE])
|
||||||
@UseGuards(AccessTokenGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
async deleteEventType(
|
async deleteEventType(
|
||||||
@Param("eventTypeId") eventTypeId: number,
|
@Param() params: EventTypeIdParams_2024_04_15,
|
||||||
|
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||||
@GetUser("id") userId: number
|
@GetUser("id") userId: number
|
||||||
): Promise<DeleteEventTypeOutput> {
|
): Promise<DeleteEventTypeOutput> {
|
||||||
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
|
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/controllers/event-types.controller";
|
||||||
|
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
|
||||||
|
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||||
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||||
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
|
||||||
|
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
|
||||||
|
controllers: [EventTypesController_2024_04_15],
|
||||||
|
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
|
||||||
|
})
|
||||||
|
export class EventTypesModule_2024_04_15 {}
|
||||||
+4
-4
@@ -1,19 +1,19 @@
|
|||||||
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
|
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { getEventTypeById } from "@calcom/platform-libraries";
|
import { getEventTypeById } from "@calcom/platform-libraries-0.0.22";
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventTypesRepository {
|
export class EventTypesRepository_2024_04_15 {
|
||||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||||
|
|
||||||
async createUserEventType(
|
async createUserEventType(
|
||||||
userId: number,
|
userId: number,
|
||||||
body: Pick<CreateEventTypeInput, "title" | "slug" | "length" | "hidden">
|
body: Pick<CreateEventTypeInput_2024_04_15, "title" | "slug" | "length" | "hidden">
|
||||||
) {
|
) {
|
||||||
return this.dbWrite.prisma.eventType.create({
|
return this.dbWrite.prisma.eventType.create({
|
||||||
data: {
|
data: {
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
|
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
|
||||||
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
|
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +20,7 @@ export const CREATE_EVENT_DESCRIPTION_EXAMPLE =
|
|||||||
|
|
||||||
// note(Lauris): We will gradually expose more properties if any customer needs them.
|
// note(Lauris): We will gradually expose more properties if any customer needs them.
|
||||||
// Just uncomment any below when requested.
|
// Just uncomment any below when requested.
|
||||||
export class CreateEventTypeInput {
|
export class CreateEventTypeInput_2024_04_15 {
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
|
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
|
||||||
@@ -46,9 +46,9 @@ export class CreateEventTypeInput {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => EventTypeLocation)
|
@Type(() => EventTypeLocation_2024_04_15)
|
||||||
@IsArray()
|
@IsArray()
|
||||||
locations?: EventTypeLocation[];
|
locations?: EventTypeLocation_2024_04_15[];
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsNumberString } from "class-validator";
|
||||||
|
|
||||||
|
export class EventTypeIdParams_2024_04_15 {
|
||||||
|
@IsNumberString()
|
||||||
|
eventTypeId!: number;
|
||||||
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validato
|
|||||||
// note(Lauris): We will gradually expose more properties if any customer needs them.
|
// note(Lauris): We will gradually expose more properties if any customer needs them.
|
||||||
// Just uncomment any below when requested.
|
// Just uncomment any below when requested.
|
||||||
|
|
||||||
export class EventTypeLocation {
|
export class EventTypeLocation_2024_04_15 {
|
||||||
@IsString()
|
@IsString()
|
||||||
@DocsProperty({ example: "link" })
|
@DocsProperty({ example: "link" })
|
||||||
type!: string;
|
type!: string;
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import { ApiProperty } from "@nestjs/swagger";
|
|||||||
import { Transform } from "class-transformer";
|
import { Transform } from "class-transformer";
|
||||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
export class GetPublicEventTypeQueryParams {
|
export class GetPublicEventTypeQueryParams_2024_04_15 {
|
||||||
@Transform(({ value }: { value: string }) => value === "true")
|
@Transform(({ value }: { value: string }) => value === "true")
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
+15
-15
@@ -1,7 +1,7 @@
|
|||||||
import { Editable } from "@/ee/event-types/inputs/enums/editable";
|
import { Editable } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/editable";
|
||||||
import { BaseField } from "@/ee/event-types/inputs/enums/field-type";
|
import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
|
||||||
import { Frequency } from "@/ee/event-types/inputs/enums/frequency";
|
import { Frequency } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/frequency";
|
||||||
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
|
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
@@ -105,7 +105,7 @@ class VariantsConfig {
|
|||||||
variants!: Record<string, Variant>;
|
variants!: Record<string, Variant>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingField {
|
export class BookingField_2024_04_15 {
|
||||||
@IsEnum(BaseField)
|
@IsEnum(BaseField)
|
||||||
type!: BaseField;
|
type!: BaseField;
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export class BookingField {
|
|||||||
sources?: Source[];
|
sources?: Source[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RecurringEvent {
|
export class RecurringEvent_2024_04_15 {
|
||||||
@IsDate()
|
@IsDate()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
dtstart?: Date;
|
dtstart?: Date;
|
||||||
@@ -202,7 +202,7 @@ export class RecurringEvent {
|
|||||||
tzid?: string;
|
tzid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IntervalLimits {
|
export class IntervalLimits_2024_04_15 {
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
PER_DAY?: number;
|
PER_DAY?: number;
|
||||||
@@ -220,7 +220,7 @@ export class IntervalLimits {
|
|||||||
PER_YEAR?: number;
|
PER_YEAR?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateEventTypeInput {
|
export class UpdateEventTypeInput_2024_04_15 {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -243,9 +243,9 @@ export class UpdateEventTypeInput {
|
|||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => EventTypeLocation)
|
@Type(() => EventTypeLocation_2024_04_15)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
locations?: EventTypeLocation[];
|
locations?: EventTypeLocation_2024_04_15[];
|
||||||
|
|
||||||
// @IsInt()
|
// @IsInt()
|
||||||
// @IsOptional()
|
// @IsOptional()
|
||||||
@@ -275,11 +275,11 @@ export class UpdateEventTypeInput {
|
|||||||
// @IsOptional()
|
// @IsOptional()
|
||||||
// parentId?: number;
|
// parentId?: number;
|
||||||
|
|
||||||
// @IsOptional()
|
@IsOptional()
|
||||||
// @IsArray()
|
@IsArray()
|
||||||
// @ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
// @Type(() => BookingField)
|
@Type(() => BookingField_2024_04_15)
|
||||||
// bookingFields?: BookingField[];
|
bookingFields?: BookingField_2024_04_15[];
|
||||||
|
|
||||||
// @IsString()
|
// @IsString()
|
||||||
// @IsOptional()
|
// @IsOptional()
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
|
import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs/event-type.output";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import {
|
|||||||
CREATE_EVENT_LENGTH_EXAMPLE,
|
CREATE_EVENT_LENGTH_EXAMPLE,
|
||||||
CREATE_EVENT_SLUG_EXAMPLE,
|
CREATE_EVENT_SLUG_EXAMPLE,
|
||||||
CREATE_EVENT_TITLE_EXAMPLE,
|
CREATE_EVENT_TITLE_EXAMPLE,
|
||||||
} from "@/ee/event-types/inputs/create-event-type.input";
|
} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
|
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
+18
-18
@@ -3,15 +3,15 @@ import {
|
|||||||
CREATE_EVENT_LENGTH_EXAMPLE,
|
CREATE_EVENT_LENGTH_EXAMPLE,
|
||||||
CREATE_EVENT_SLUG_EXAMPLE,
|
CREATE_EVENT_SLUG_EXAMPLE,
|
||||||
CREATE_EVENT_TITLE_EXAMPLE,
|
CREATE_EVENT_TITLE_EXAMPLE,
|
||||||
} from "@/ee/event-types/inputs/create-event-type.input";
|
} from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
|
||||||
import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
|
import { PeriodType } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/period-type";
|
||||||
import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
|
import { SchedulingType } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/scheduling-type";
|
||||||
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
|
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
|
||||||
import {
|
import {
|
||||||
BookingField,
|
BookingField_2024_04_15,
|
||||||
IntervalLimits,
|
IntervalLimits_2024_04_15,
|
||||||
RecurringEvent,
|
RecurringEvent_2024_04_15,
|
||||||
} from "@/ee/event-types/inputs/update-event-type.input";
|
} from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
|
||||||
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
|
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import {
|
import {
|
||||||
@@ -53,9 +53,9 @@ export class EventTypeOutput {
|
|||||||
hidden!: boolean;
|
hidden!: boolean;
|
||||||
|
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => EventTypeLocation)
|
@Type(() => EventTypeLocation_2024_04_15)
|
||||||
@IsArray()
|
@IsArray()
|
||||||
locations!: EventTypeLocation[] | null;
|
locations!: EventTypeLocation_2024_04_15[] | null;
|
||||||
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
@@ -91,9 +91,9 @@ export class EventTypeOutput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => BookingField)
|
@Type(() => BookingField_2024_04_15)
|
||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
bookingFields!: BookingField[] | null;
|
bookingFields!: BookingField_2024_04_15[] | null;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
@@ -132,10 +132,10 @@ export class EventTypeOutput {
|
|||||||
requiresBookerEmailVerification!: boolean;
|
requiresBookerEmailVerification!: boolean;
|
||||||
|
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => RecurringEvent)
|
@Type(() => RecurringEvent_2024_04_15)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
recurringEvent!: RecurringEvent | null;
|
recurringEvent!: RecurringEvent_2024_04_15 | null;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
@@ -203,15 +203,15 @@ export class EventTypeOutput {
|
|||||||
successRedirectUrl!: string | null;
|
successRedirectUrl!: string | null;
|
||||||
|
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => IntervalLimits)
|
@Type(() => IntervalLimits_2024_04_15)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
bookingLimits!: IntervalLimits;
|
bookingLimits!: IntervalLimits_2024_04_15;
|
||||||
|
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => IntervalLimits)
|
@Type(() => IntervalLimits_2024_04_15)
|
||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
durationLimits!: IntervalLimits;
|
durationLimits!: IntervalLimits_2024_04_15;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@ApiHideProperty()
|
@ApiHideProperty()
|
||||||
File renamed without changes.
Loaded 100 of 1305 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user