Compare commits
244
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d28215c5e | ||
|
|
045ea34580 | ||
|
|
3228d8d869 | ||
|
|
6b9115dfde | ||
|
|
6259b0b3cb | ||
|
|
f991669b8d | ||
|
|
b93568359a | ||
|
|
66b52bb19f | ||
|
|
b9c49b4567 | ||
|
|
29e1dcb43c | ||
|
|
3ab676794f | ||
|
|
877fdbf46a | ||
|
|
b05e1b4589 | ||
|
|
611f76bf68 | ||
|
|
9b886036ad | ||
|
|
c88ed8cae7 | ||
|
|
098b520129 | ||
|
|
eb4eebe6f9 | ||
|
|
16a7bb7bc5 | ||
|
|
ee9699e267 | ||
|
|
7813d3739f | ||
|
|
fa47456d5f | ||
|
|
dbaa0727ae | ||
|
|
42a4a38939 | ||
|
|
97bde083ca | ||
|
|
48bd12be32 | ||
|
|
0cbc8a3816 | ||
|
|
4e1048b221 | ||
|
|
cb78692347 | ||
|
|
3784421f02 | ||
|
|
e7535b9db3 | ||
|
|
bb4260c98b | ||
|
|
9d65b6f794 | ||
|
|
4874c6c2ee | ||
|
|
30a92a4d66 | ||
|
|
04ce054cf7 | ||
|
|
e901e8d189 | ||
|
|
0aa595ddac | ||
|
|
2115a045ec | ||
|
|
f67522dbbd | ||
|
|
dc5aef0dd4 | ||
|
|
e1d7631215 | ||
|
|
05e1ad0c91 | ||
|
|
cf392562da | ||
|
|
c8de8ca681 | ||
|
|
2c91c0e309 | ||
|
|
cef007d852 | ||
|
|
97b81d6f6b | ||
|
|
ac117a14eb | ||
|
|
f85f226982 | ||
|
|
f4b48e863b | ||
|
|
4dc22606d9 | ||
|
|
292073eb4a | ||
|
|
1878802213 | ||
|
|
9cfc74a74f | ||
|
|
c472bce54f | ||
|
|
a498641b61 | ||
|
|
ec58e59865 | ||
|
|
4f95cff70e | ||
|
|
fa66f2531f | ||
|
|
b40d448e07 | ||
|
|
21add18ad8 | ||
|
|
10fca86ddc | ||
|
|
c2fc8774df | ||
|
|
969b58f17d | ||
|
|
a0e25ff270 | ||
|
|
df30f5c703 | ||
|
|
fb59342de4 | ||
|
|
a84e8987cb | ||
|
|
c4eda5f21d | ||
|
|
e6da5c85e0 | ||
|
|
bfc6d6e1e9 | ||
|
|
10bf982028 | ||
|
|
8ee7b12c2b | ||
|
|
afe5fea6eb | ||
|
|
5854de3c23 | ||
|
|
967442892a | ||
|
|
202d248ae4 | ||
|
|
f667417d84 | ||
|
|
750e6315dc | ||
|
|
f117b2df32 | ||
|
|
9631f083b8 | ||
|
|
c8a52b78e1 | ||
|
|
fe4946a269 | ||
|
|
3de9c2bda0 | ||
|
|
cee30c53b8 | ||
|
|
f22b5d9daa | ||
|
|
88f8753329 | ||
|
|
fe02a97fa1 | ||
|
|
a9172fd832 | ||
|
|
96a659ae5a | ||
|
|
13739dabd8 | ||
|
|
22acfbae28 | ||
|
|
adf50b2419 | ||
|
|
35022a0478 | ||
|
|
2838053ba8 | ||
|
|
66397a6626 | ||
|
|
dfe997dce2 | ||
|
|
163c7ff791 | ||
|
|
bd5e14b488 | ||
|
|
8cd16ee3dc | ||
|
|
57564381cb | ||
|
|
0207109612 | ||
|
|
b84405c5eb | ||
|
|
2743b32536 | ||
|
|
a251422f7a | ||
|
|
d7c9bd6ed8 | ||
|
|
8b3ff0b789 | ||
|
|
94a537bfda | ||
|
|
64305ffee1 | ||
|
|
45698db0b4 | ||
|
|
14e14289f0 | ||
|
|
18650b1215 | ||
|
|
d2bbbf3f45 | ||
|
|
13cb99b7fe | ||
|
|
a1571d01bd | ||
|
|
bbd8af074e | ||
|
|
c9a47dd0ce | ||
|
|
1c9778e667 | ||
|
|
758143ef70 | ||
|
|
b5911a9a2d | ||
|
|
260f4e1b51 | ||
|
|
e6c36af3b4 | ||
|
|
2fb2458eee | ||
|
|
b340b0edbe | ||
|
|
f7fd1c4402 | ||
|
|
c316721051 | ||
|
|
80ac118242 | ||
|
|
efd15c5a3e | ||
|
|
7d02d1bb23 | ||
|
|
0d877e7be3 | ||
|
|
e4c47640fc | ||
|
|
89e7fca183 | ||
|
|
33b5bf1db8 | ||
|
|
f69c4a900e | ||
|
|
1da25cc85f | ||
|
|
3a65b372c5 | ||
|
|
9892d521fb | ||
|
|
3285b252c2 | ||
|
|
97af2fc0f9 | ||
|
|
5c508bce6d | ||
|
|
db84343581 | ||
|
|
2b44d09191 | ||
|
|
10d67aee86 | ||
|
|
b33bbb8af0 | ||
|
|
3fe9327724 | ||
|
|
179a547bd0 | ||
|
|
7ada02177d | ||
|
|
59b55e40b5 | ||
|
|
19563aa697 | ||
|
|
6410687fb6 | ||
|
|
4e4c8f5f49 | ||
|
|
416b17cf69 | ||
|
|
27cd7c8496 | ||
|
|
02321c33b2 | ||
|
|
c3dab3e4f8 | ||
|
|
0c182e2813 | ||
|
|
51a076212b | ||
|
|
27dce7374b | ||
|
|
52f2c18800 | ||
|
|
35a8918ea9 | ||
|
|
b39eab72e7 | ||
|
|
928b2477be | ||
|
|
dc13251bcc | ||
|
|
4843658dfb | ||
|
|
076afdc00e | ||
|
|
4404699fa6 | ||
|
|
52940ce254 | ||
|
|
cd6293d220 | ||
|
|
108f95723b | ||
|
|
f611c3d767 | ||
|
|
6091208f8a | ||
|
|
585a12fa48 | ||
|
|
bfc4f7b66c | ||
|
|
9dca13305f | ||
|
|
0ab9823c9a | ||
|
|
b3fd4f307f | ||
|
|
06ed0de456 | ||
|
|
79b6278883 | ||
|
|
673823cb97 | ||
|
|
15b26dff73 | ||
|
|
2303f2e9e1 | ||
|
|
056b821070 | ||
|
|
c90fded6b7 | ||
|
|
aa6f1a7f93 | ||
|
|
0cdd7c9605 | ||
|
|
f40a1c92ef | ||
|
|
739ef153f6 | ||
|
|
850ac8092f | ||
|
|
5b1eb48e6b | ||
|
|
da6fc9f2d5 | ||
|
|
20434856e9 | ||
|
|
7f891adff2 | ||
|
|
eb66fbe054 | ||
|
|
29122934a9 | ||
|
|
0825cce82c | ||
|
|
fd964131d1 | ||
|
|
77d9c15fed | ||
|
|
b21295f268 | ||
|
|
5769553659 | ||
|
|
d23d68bf2d | ||
|
|
d04c7f58a1 | ||
|
|
93e2426760 | ||
|
|
9f6c05f389 | ||
|
|
f8a1bd7b94 | ||
|
|
02967961f3 | ||
|
|
053b8b5c0a | ||
|
|
d59efd3ba0 | ||
|
|
ce56cd1ab0 | ||
|
|
54b6deee19 | ||
|
|
8315641724 | ||
|
|
aa3086cfae | ||
|
|
b599ce4919 | ||
|
|
52f16604c1 | ||
|
|
c98c06062b | ||
|
|
4963d247dc | ||
|
|
9405bc9553 | ||
|
|
8a080b91b9 | ||
|
|
4aa05fe523 | ||
|
|
d74d99dde7 | ||
|
|
0ee137efbb | ||
|
|
90f55670dc | ||
|
|
9c53f392ca | ||
|
|
cc19a81875 | ||
|
|
67032ddaf1 | ||
|
|
2fe71c3695 | ||
|
|
21422fe027 | ||
|
|
95575291ff | ||
|
|
3e61a0633d | ||
|
|
85c9a790f2 | ||
|
|
e17d55457f | ||
|
|
d8d8bc4d00 | ||
|
|
55cc1aea59 | ||
|
|
ae383164bc | ||
|
|
0d8863fe8e | ||
|
|
51f6654635 | ||
|
|
fa11e10bcb | ||
|
|
a8f5bd6fe9 | ||
|
|
a1c725bb35 | ||
|
|
e4eeb11a61 | ||
|
|
3b0bb25251 | ||
|
|
d27490e6f2 | ||
|
|
b3c191b553 | ||
|
|
0b977e615d |
@@ -0,0 +1,28 @@
|
|||||||
|
# Review Instructions
|
||||||
|
|
||||||
|
- Prefer early returns. It is recommended to throw/return early so we can ensure null-checks and prevent further nesting.
|
||||||
|
- Prefer Composition over Prop Drilling. Instead of relying on prop drilling, let's try to take advantage of react children feature.
|
||||||
|
- For Prisma queries:
|
||||||
|
- Only select data you need
|
||||||
|
1) Increased performance overhead: When you select all columns from a table, Prisma retrieves all the data associated with each row, which can result in a significant performance impact. This includes reading unnecessary columns and transferring a large volume of data between your application and the database.
|
||||||
|
2) Unnecessary data exposure: Selecting all columns exposes sensitive data that might not be required by your application. This can pose security risks, especially if the table contains sensitive user information or proprietary data. (In our case app credentials and booking information)
|
||||||
|
- TLDR: Never use Includes - use select
|
||||||
|
- Select selects only the fields you specify explicitly
|
||||||
|
- Include selects the relationship AND all the fields of the table the relationship belongs to
|
||||||
|
- Make sure the credential.key field is never returned back from tRPC endpoints or APIs
|
||||||
|
- Always use t() for text localization in frontend code; direct text embedding should trigger a warning.
|
||||||
|
- Check if there's any O(n^2) logic in backend code; we should aim for O(n log n) or O(n) ideally.
|
||||||
|
- Check if there are circular references introduced. We should never allow these.
|
||||||
|
- Flag excessive Day.js use in performance-critical code. Functions like `.add`, `.diff`, `.isBefore`, and `.isAfter` are slow, especially in timezone mode. Prefer `.utc()` for better performance. Where possible, replace with native Date and direct `.valueOf()` comparisons for faster execution. Recommend using native methods or Day.js `.utc()` consistently in hot paths like loops.
|
||||||
|
- When docs changes are made in /docs/api-reference/v2/openapi.json, ensure the following
|
||||||
|
- "summary" fields are short and concise
|
||||||
|
- "summary" fields do not end with a period
|
||||||
|
- "summary" fields are written in proper American english
|
||||||
|
- When changes to API v2 or v1 are made, ensure there are no breaking changes on existing endpoints. Instead, there needs to be a newly versioned endpoint with the updated functionality but the old one must remain functional
|
||||||
|
- For large pull requests (>500 lines changed or >10 files touched), advise splitting into smaller, focused PRs:
|
||||||
|
- Split by feature boundaries: separate different features or user stories
|
||||||
|
- Split by layer/component: frontend changes, backend changes, database migrations, and tests in separate PRs
|
||||||
|
- Split by dependency chain: create PRs that can be merged sequentially
|
||||||
|
- Split by file/module: group related file changes together
|
||||||
|
- Suggested pattern: Database migrations → Backend logic → Frontend components → Integration tests
|
||||||
|
- Benefits: easier review, faster feedback, reduced conflicts, easier rollback, better history
|
||||||
@@ -164,6 +164,7 @@ SENTRY_PROJECT=
|
|||||||
SENTRY_AUTH_TOKEN=
|
SENTRY_AUTH_TOKEN=
|
||||||
# Used for capturing exceptions and logging messages
|
# Used for capturing exceptions and logging messages
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN_CLIENT=
|
||||||
SENTRY_DEBUG=
|
SENTRY_DEBUG=
|
||||||
SENTRY_MAX_SPANS=
|
SENTRY_MAX_SPANS=
|
||||||
SENTRY_SAMPLE_RATE=
|
SENTRY_SAMPLE_RATE=
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
/packages/lib/getUserAvailability.ts @calcom/Foundation
|
/packages/lib/getUserAvailability.ts @calcom/Foundation
|
||||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||||
/packages/lib/slots.ts @calcom/Foundation
|
/packages/lib/slots.ts @calcom/Foundation
|
||||||
|
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
|
||||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||||
/packages/prisma/**/* @calcom/DBA
|
/packages/prisma/**/* @calcom/DBA
|
||||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||||
|
|||||||
@@ -27,8 +27,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Configure git
|
- name: Configure git
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "github-actions@github.com"
|
# Define authorized users and their emails using organization secret
|
||||||
git config --local user.name "GitHub Actions"
|
# Expected format: JSON object with username -> email mapping
|
||||||
|
# Example: {"zomars": "zomars@cal.com", "peer": "peer@cal.com", "username3": "email3@cal.com"}
|
||||||
|
USER_EMAILS='${{ secrets.RELEASE_USER_EMAILS }}'
|
||||||
|
|
||||||
|
# Extract email for the triggering user
|
||||||
|
USER_EMAIL=$(echo "$USER_EMAILS" | jq -r --arg user "${{ github.actor }}" '.[$user] // empty')
|
||||||
|
|
||||||
|
# Fail if user is not authorized or not in mapping
|
||||||
|
if [ -z "$USER_EMAIL" ] || [ "$USER_EMAIL" = "null" ]; then
|
||||||
|
echo "Error: User '${{ github.actor }}' is not authorized to run the release workflow."
|
||||||
|
echo "Only authorized team members can trigger releases."
|
||||||
|
echo "Contact your administrator to be added to the authorized users list."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config --local user.email "$USER_EMAIL"
|
||||||
|
git config --local user.name "${{ github.actor }}"
|
||||||
|
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
name: E2E API v2
|
name: E2E API v2
|
||||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:13
|
image: postgres:13
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
- uses: lingodotdev/lingo.dev@main
|
- uses: lingodotdev/lingo.dev@main
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||||
organization-name: calcom
|
organization-name: calcom
|
||||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||||
apply-labels-from-issue:
|
apply-labels-from-issue:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
outputs:
|
outputs:
|
||||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
|
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || github.event.label.name == 'ready-for-e2e') }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check if PR exists with ready-for-e2e label for this SHA
|
- name: Check if PR exists with ready-for-e2e label for this SHA
|
||||||
id: check-if-pr-has-label
|
id: check-if-pr-has-label
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
undecided:
|
||||||
|
- calcom-monorepo
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
@@ -80,6 +80,8 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
|
|||||||
|
|
||||||
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
|
This project is tested with browserstack
|
||||||
|
|
||||||
### Built With
|
### Built With
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/?ref=cal.com)
|
- [Next.js](https://nextjs.org/?ref=cal.com)
|
||||||
@@ -488,13 +490,8 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
|
|||||||
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
||||||
|
|
||||||
## Single Org Mode
|
## Single Org Mode
|
||||||
If you want to have booker.yourcompany.com to be the domain used for both dashboard(e.g. https://booker.yourcompany.com/event-types) and booking pages(e.g. https://booker.yourcompany.com/john.joe/15min).
|
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
|
||||||
- Set the `NEXT_PUBLIC_SINGLE_ORG_SLUG` environment variable to the slug of the organization you want to use. `NEXT_PUBLIC_SINGLE_ORG_SLUG=booker`
|
|
||||||
- Set the `NEXT_PUBLIC_WEBAPP_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBAPP_URL=https://booker.yourcompany.com`.
|
|
||||||
- Set the `NEXT_PUBLIC_WEBSITE_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBSITE_URL=https://booker.yourcompany.com`.
|
|
||||||
- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`.
|
|
||||||
|
|
||||||
Note: It causes root to serve the dashboard and not the organization profile page which shows all bookable users in the organization.
|
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
|
|||||||
@@ -14,19 +14,29 @@ import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
|||||||
|
|
||||||
import { verifyApiKey } from "./verifyApiKey";
|
import { verifyApiKey } from "./verifyApiKey";
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/crypto", () => ({
|
||||||
|
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
|
||||||
|
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
|
||||||
|
}));
|
||||||
|
|
||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv("CALENDSO_ENCRYPTION_KEY", "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX");
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockDeploymentRepository: IDeploymentRepository = {
|
const mockDeploymentRepository: IDeploymentRepository = {
|
||||||
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
|
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
|
||||||
|
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Fix the skip condition for this test suite
|
describe("Verify API key", () => {
|
||||||
describe.skip("Verify API key", () => {
|
|
||||||
let service: ILicenseKeyService;
|
let service: ILicenseKeyService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ enum locales {
|
|||||||
SR = "sr",
|
SR = "sr",
|
||||||
SV = "sv",
|
SV = "sv",
|
||||||
VI = "vi",
|
VI = "vi",
|
||||||
|
BN = "bn",
|
||||||
}
|
}
|
||||||
enum theme {
|
enum theme {
|
||||||
DARK = "dark",
|
DARK = "dark",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ const plugins = [withAxiom];
|
|||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
experimental: {
|
|
||||||
instrumentationHook: true,
|
|
||||||
},
|
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@calcom/app-store",
|
"@calcom/app-store",
|
||||||
"@calcom/dayjs",
|
"@calcom/dayjs",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@sentry/nextjs": "^9.15.0",
|
"@sentry/nextjs": "^9.15.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "^13.5.9",
|
"next": "^15.3.0",
|
||||||
"next-api-middleware": "^1.0.1",
|
"next-api-middleware": "^1.0.1",
|
||||||
"next-axiom": "^0.17.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
|
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -106,6 +108,11 @@ export async function getHandler(req: NextApiRequest) {
|
|||||||
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new ErrorWithCode(ErrorCode.BookingNotFound, "Booking not found");
|
||||||
|
}
|
||||||
|
|
||||||
return { booking: schemaBookingReadPublic.parse(booking) };
|
return { booking: schemaBookingReadPublic.parse(booking) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,13 @@ async function handler(req: NextApiRequest) {
|
|||||||
};
|
};
|
||||||
if (isSystemWideAdmin) userId = req.body.userId || userId;
|
if (isSystemWideAdmin) userId = req.body.userId || userId;
|
||||||
|
|
||||||
|
if (req.body.eventTypeId !== undefined && typeof req.body.eventTypeId !== "number") {
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Bad request, eventTypeId must be a number",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isOrganizationOwnerOrAdmin) {
|
if (isOrganizationOwnerOrAdmin) {
|
||||||
const accessibleUsersIds = await getAccessibleUsers({
|
const accessibleUsersIds = await getAccessibleUsers({
|
||||||
adminUserId: userId,
|
adminUserId: userId,
|
||||||
@@ -247,6 +254,14 @@ async function handler(req: NextApiRequest) {
|
|||||||
throw new HttpError({ statusCode: 400, message: knownError.message });
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (knownError?.message === ErrorCode.RequestBodyInvalid) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownError?.message === ErrorCode.EventTypeNotFound) {
|
||||||
|
throw new HttpError({ statusCode: 400, message: knownError.message });
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ async function getHandler(req: NextApiRequest) {
|
|||||||
|
|
||||||
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
|
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
|
||||||
|
|
||||||
const usersWithCalendars = await UserRepository.findManyByIdsIncludeDestinationAndSelectedCalendars({
|
const usersWithCalendars = await new UserRepository(
|
||||||
|
prisma
|
||||||
|
).findManyByIdsIncludeDestinationAndSelectedCalendars({
|
||||||
ids: userIds,
|
ids: userIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||||
|
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { createContext } from "@calcom/trpc/server/createContext";
|
import { createContext } from "@calcom/trpc/server/createContext";
|
||||||
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||||
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||||
@@ -27,7 +27,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest });
|
const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest });
|
||||||
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
|
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
|
||||||
const availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
|
||||||
|
const availableSlotsService = getAvailableSlotsService();
|
||||||
|
const availableSlots = await availableSlotsService.getAvailableSlots({
|
||||||
|
ctx: await createContext({ req, res }),
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
|
||||||
const slotsInProvidedTimeZone = timeZoneSupported
|
const slotsInProvidedTimeZone = timeZoneSupported
|
||||||
? Object.keys(availableSlots.slots).reduce(
|
? Object.keys(availableSlots.slots).reduce(
|
||||||
(acc: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>, date) => {
|
(acc: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>, date) => {
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { buildBooking, buildEventType } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import handler from "../../../../pages/api/bookings/[id]/_delete";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/bookings/lib/handleCancelBooking", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
handleCancelBooking: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const userId = 1;
|
||||||
|
const bookingId = 123;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/bookings/[id]", () => {
|
||||||
|
describe("Success", () => {
|
||||||
|
test("should cancel booking successfully", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "User requested cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow system-wide admin to cancel any booking", async () => {
|
||||||
|
const adminUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Admin cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("should return 404 when booking not found", async () => {
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: "999",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Test cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid booking ID", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: "invalid",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Test cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 403 when user doesn't have permission to cancel booking", async () => {
|
||||||
|
const otherUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: otherUserId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
reason: "Unauthorized cancellation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 when required cancellation reason is missing", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { buildBooking, buildEventType } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import handler from "../../../../pages/api/bookings/[id]/_patch";
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const userId = 1;
|
||||||
|
const bookingId = 123;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/bookings/[id]", () => {
|
||||||
|
describe("Success", () => {
|
||||||
|
test("should update booking successfully", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
title: "Original Title",
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
title: "Updated Title",
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
prismaMock.booking.update.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(responseData.booking.title).toBe("Updated Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow system-wide admin to update any booking", async () => {
|
||||||
|
const adminUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
title: "Original Title",
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
title: "Admin Updated Title",
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
prismaMock.booking.update.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Admin Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update booking times successfully", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
startTime: new Date("2024-01-01T10:00:00Z"),
|
||||||
|
endTime: new Date("2024-01-01T11:00:00Z"),
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newStartTime = new Date("2024-01-01T14:00:00Z");
|
||||||
|
const newEndTime = new Date("2024-01-01T15:00:00Z");
|
||||||
|
|
||||||
|
const updatedBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
startTime: newStartTime,
|
||||||
|
endTime: newEndTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
prismaMock.booking.update.mockResolvedValue(updatedBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
startTime: newStartTime.toISOString(),
|
||||||
|
endTime: newEndTime.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
expect(new Date(responseData.booking.startTime)).toEqual(newStartTime);
|
||||||
|
expect(new Date(responseData.booking.endTime)).toEqual(newEndTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Errors", () => {
|
||||||
|
test("should return 404 when booking not found", async () => {
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: "999",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid booking ID", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: "invalid",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Updated Title",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 403 when user doesn't have permission to update booking", async () => {
|
||||||
|
const otherUserId = 999;
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: otherUserId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: "Unauthorized Update",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 401 when non-admin tries to change userId", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
userId: 999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return 400 for invalid date format", async () => {
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: bookingId,
|
||||||
|
userId: userId,
|
||||||
|
eventTypeId: buildEventType().id,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||||
|
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "PATCH",
|
||||||
|
query: {
|
||||||
|
id: bookingId.toString(),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
startTime: "invalid-date",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, test, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
|
import { buildBooking } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAccessibleUsers,
|
||||||
|
retrieveOrgScopedAccessibleUsers,
|
||||||
|
} from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
|
import { handler } from "../../../pages/api/bookings/_get";
|
||||||
|
|
||||||
|
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => ({
|
||||||
|
getAccessibleUsers: vi.fn(),
|
||||||
|
retrieveOrgScopedAccessibleUsers: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
|
const userId = 1;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
});
|
||||||
|
(getAccessibleUsers as any).mockResolvedValue([userId]);
|
||||||
|
(retrieveOrgScopedAccessibleUsers as any).mockResolvedValue([userId]);
|
||||||
|
|
||||||
|
prismaMock.membership.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
id: 1,
|
||||||
|
isOrganization: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.user.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: userId,
|
||||||
|
email: "test@example.com",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/bookings", () => {
|
||||||
|
describe("Query parameter validation", () => {
|
||||||
|
test("should validate status parameter correctly", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "invalid_status",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow(ZodError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept valid status parameter", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
status: "upcoming",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate dateFrom parameter format", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateFrom: "invalid-date",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate dateTo parameter format", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
dateTo: "invalid-date",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate sortBy parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
sortBy: "invalid_field",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should validate order parameter", async () => {
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
order: "invalid_order",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Permission logic", () => {
|
||||||
|
test("should only return user's own bookings for regular user", async () => {
|
||||||
|
const mockBookings = [buildBooking({ id: 1, userId: userId }), buildBooking({ id: 2, userId: userId })];
|
||||||
|
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue(mockBookings);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toHaveLength(2);
|
||||||
|
expect(result.bookings.every((b) => b.userId === userId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow system-wide admin to access all bookings", async () => {
|
||||||
|
const adminUserId = 999;
|
||||||
|
const mockBookings = [
|
||||||
|
buildBooking({ id: 1, userId: 1 }),
|
||||||
|
buildBooking({ id: 2, userId: 2 }),
|
||||||
|
buildBooking({ id: 3, userId: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue(mockBookings);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(3);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = adminUserId;
|
||||||
|
req.isSystemWideAdmin = true;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow org admin to access org bookings", async () => {
|
||||||
|
const orgAdminUserId = 999;
|
||||||
|
const mockBookings = [buildBooking({ id: 1, userId: 1 }), buildBooking({ id: 2, userId: 2 })];
|
||||||
|
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue(mockBookings);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
(retrieveOrgScopedAccessibleUsers as any).mockResolvedValue([1, 2]);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = orgAdminUserId;
|
||||||
|
req.isOrganizationOwnerOrAdmin = true;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Filtering edge cases", () => {
|
||||||
|
test("should handle empty attendeeEmail array", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: [],
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle single attendeeEmail string", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
attendeeEmail: "test@example.com",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle pagination edge cases", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 0,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle large skip values", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(5);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Expand parameter functionality", () => {
|
||||||
|
test("should handle invalid expand parameter", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
expand: "invalid_field",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
await expect(handler(req)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle valid expand parameter", async () => {
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
expand: "team",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
take: 10,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.userId = userId;
|
||||||
|
|
||||||
|
const result = await handler(req);
|
||||||
|
expect(result.bookings).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,30 +4,243 @@ import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
|
||||||
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
import { buildBooking, buildEventType, buildWebhook, buildUser } from "@calcom/lib/test/builder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { Booking } from "@calcom/prisma/client";
|
import type { Booking } from "@calcom/prisma/client";
|
||||||
import { CreationSource } from "@calcom/prisma/enums";
|
import { CreationSource } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import handler from "../../../pages/api/bookings/_post";
|
import handler from "../../../pages/api/bookings/_post";
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB", () => ({
|
||||||
|
getEventTypesFromDB: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockEventTypeData = {
|
||||||
|
eventType: {
|
||||||
|
id: 1,
|
||||||
|
title: "Test Event",
|
||||||
|
profile: { organizationId: null },
|
||||||
|
users: [{ id: 1, email: "test@example.com", credentials: [] }],
|
||||||
|
hosts: [],
|
||||||
|
customInputs: [],
|
||||||
|
recurringEvent: null,
|
||||||
|
length: 15,
|
||||||
|
slug: "test-event",
|
||||||
|
price: 0,
|
||||||
|
currency: "USD",
|
||||||
|
requiresConfirmation: false,
|
||||||
|
disableGuests: false,
|
||||||
|
minimumBookingNotice: 120,
|
||||||
|
beforeEventBuffer: 0,
|
||||||
|
afterEventBuffer: 0,
|
||||||
|
seatsPerTimeSlot: null,
|
||||||
|
seatsShowAttendees: false,
|
||||||
|
schedulingType: null,
|
||||||
|
periodType: "UNLIMITED",
|
||||||
|
periodStartDate: null,
|
||||||
|
periodEndDate: null,
|
||||||
|
periodDays: null,
|
||||||
|
periodCountCalendarDays: false,
|
||||||
|
locations: [],
|
||||||
|
metadata: {},
|
||||||
|
successRedirectUrl: null,
|
||||||
|
description: null,
|
||||||
|
team: null,
|
||||||
|
owner: { id: 1, email: "test@example.com", credentials: [] },
|
||||||
|
},
|
||||||
|
users: [{ id: 1, email: "test@example.com", credentials: [] }],
|
||||||
|
allCredentials: [],
|
||||||
|
destinationCalendar: null,
|
||||||
|
};
|
||||||
|
|
||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
vi.mock("@calcom/features/webhooks/lib/sendPayload");
|
vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFindOriginalRescheduledBooking = vi.fn();
|
||||||
|
vi.mock("@calcom/lib/server/repository/booking", () => ({
|
||||||
|
BookingRepository: vi.fn().mockImplementation(() => ({
|
||||||
|
findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.controller", () => ({
|
||||||
|
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials", () => ({
|
||||||
|
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||||
|
qualifiedRRHosts: [],
|
||||||
|
allFallbackRRHosts: [],
|
||||||
|
fixedHosts: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/EventManager", () => ({
|
||||||
|
default: vi.fn().mockImplementation(() => ({
|
||||||
|
reschedule: vi.fn().mockResolvedValue({
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
}),
|
||||||
|
create: vi.fn().mockResolvedValue({
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockResolvedValue({
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
}),
|
||||||
|
createAllCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||||
|
updateAllCalendarEvents: vi.fn().mockResolvedValue([]),
|
||||||
|
deleteEventsAndMeetings: vi.fn().mockResolvedValue([]),
|
||||||
|
})),
|
||||||
|
placeholderCreatedEvent: {
|
||||||
|
results: [],
|
||||||
|
referencesToCreate: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/availability", () => ({
|
||||||
|
getUserAvailability: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
start: new Date("1970-01-01T09:00:00.000Z"),
|
||||||
|
end: new Date("1970-01-01T17:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
getAvailableSlots: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
attendees: 1,
|
||||||
|
bookingUid: null,
|
||||||
|
users: [2],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers", () => ({
|
||||||
|
ensureAvailableUsers: vi.fn().mockImplementation(async (eventType) => {
|
||||||
|
return eventType.users || [{ id: 2, email: "test@example.com", name: "Test User", isFixed: false }];
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/server/repository/profile", () => ({
|
||||||
|
ProfileRepository: {
|
||||||
|
findManyForUser: vi.fn().mockResolvedValue([]),
|
||||||
|
buildPersonalProfileFromUser: vi.fn().mockReturnValue({
|
||||||
|
id: null,
|
||||||
|
upId: "usr-2",
|
||||||
|
username: "test-user",
|
||||||
|
organizationId: null,
|
||||||
|
organization: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@calcom/features/flags/features.repository", () => ({
|
||||||
|
FeaturesRepository: vi.fn().mockImplementation(() => ({
|
||||||
|
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(false),
|
||||||
|
checkIfTeamHasFeature: vi.fn().mockResolvedValue(false),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/webhooks/lib/getWebhooks", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflows", () => ({
|
||||||
|
getAllWorkflows: vi.fn().mockResolvedValue([]),
|
||||||
|
workflowSelect: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/trpc/server/routers/viewer/workflows/util", () => ({
|
||||||
|
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@calcom/lib/server/i18n", () => {
|
vi.mock("@calcom/lib/server/i18n", () => {
|
||||||
|
const mockT = (key: string, options?: any) => {
|
||||||
|
if (key === "event_between_users") {
|
||||||
|
return `${options?.eventName} between ${options?.host} and ${options?.attendeeName}`;
|
||||||
|
}
|
||||||
|
if (key === "scheduler") {
|
||||||
|
return "Scheduler";
|
||||||
|
}
|
||||||
|
if (key === "google_meet_warning") {
|
||||||
|
return "Google Meet warning";
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getTranslation: (key: string) => key,
|
getTranslation: vi.fn().mockResolvedValue(mockT),
|
||||||
|
t: mockT,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(true)("POST /api/bookings", () => {
|
describe("POST /api/bookings - eventTypeId validation", () => {
|
||||||
|
test("String eventTypeId should return 400", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
eventTypeId: "invalid-string",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Bad request, eventTypeId must be a number",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Number eventTypeId should not trigger validation error", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
eventTypeId: 123,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(
|
||||||
|
buildEventType({
|
||||||
|
id: 123,
|
||||||
|
profileId: null,
|
||||||
|
locations: [{ type: "integrations:daily" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
const statusCode = res._getStatusCode();
|
||||||
|
const responseData = JSON.parse(res._getData());
|
||||||
|
|
||||||
|
if (statusCode === 400) {
|
||||||
|
expect(responseData.message).not.toBe("Bad request, eventTypeId must be a number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/bookings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFindOriginalRescheduledBooking.mockResolvedValue(null);
|
||||||
|
|
||||||
|
(getEventTypesFromDB as any).mockResolvedValue(mockEventTypeData.eventType);
|
||||||
|
});
|
||||||
|
|
||||||
describe("Errors", () => {
|
describe("Errors", () => {
|
||||||
test("Missing required data", async () => {
|
test("Missing required data", async () => {
|
||||||
|
(getEventTypesFromDB as any).mockRejectedValue(new Error(ErrorCode.RequestBodyInvalid));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {},
|
body: {},
|
||||||
@@ -35,102 +248,45 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res._getStatusCode()).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message:
|
message: ErrorCode.RequestBodyInvalid,
|
||||||
"invalid_type in 'eventTypeId': Required; invalid_type in 'title': Required; invalid_type in 'startTime': Required; invalid_type in 'startTime': Required; invalid_type in 'endTime': Required; invalid_type in 'endTime': Required",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Invalid eventTypeId", async () => {
|
test("Invalid eventTypeId", async () => {
|
||||||
|
(getEventTypesFromDB as any).mockRejectedValue(new Error(ErrorCode.EventTypeNotFound));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
title: "test",
|
title: "test",
|
||||||
eventTypeId: 2,
|
eventTypeId: 999,
|
||||||
startTime: dayjs().toDate(),
|
startTime: dayjs().add(1, "day").toDate(),
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
endTime: dayjs().add(1, "day").add(15, "minutes").toDate(),
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
expect(res._getStatusCode()).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message:
|
message: ErrorCode.EventTypeNotFound,
|
||||||
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Missing recurringCount", async () => {
|
test.skip("Missing recurringCount", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
title: "test",
|
|
||||||
eventTypeId: 2,
|
|
||||||
startTime: dayjs().toDate(),
|
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
|
||||||
},
|
|
||||||
prisma,
|
|
||||||
});
|
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
|
||||||
);
|
|
||||||
|
|
||||||
await handler(req, res);
|
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Invalid recurringCount", async () => {
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
title: "test",
|
|
||||||
eventTypeId: 2,
|
|
||||||
startTime: dayjs().toDate(),
|
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
|
||||||
recurringCount: 15,
|
|
||||||
},
|
|
||||||
prisma,
|
|
||||||
});
|
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
|
||||||
);
|
|
||||||
|
|
||||||
await handler(req, res);
|
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(400);
|
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("No available users", async () => {
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "test",
|
name: "test",
|
||||||
start: dayjs().format(),
|
start: dayjs().add(1, "day").format(),
|
||||||
end: dayjs().add(1, "day").format(),
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -143,15 +299,93 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining("recurringCount"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("Invalid recurringCount", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: "test",
|
||||||
|
start: dayjs().add(1, "day").format(),
|
||||||
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
|
eventTypeId: 2,
|
||||||
|
email: "test@example.com",
|
||||||
|
location: "Cal.com Video",
|
||||||
|
timeZone: "America/Montevideo",
|
||||||
|
language: "en",
|
||||||
|
customInputs: [],
|
||||||
|
metadata: {},
|
||||||
|
userId: 4,
|
||||||
|
recurringCount: 15,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res._getStatusCode()).toBe(400);
|
||||||
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining("recurringCount"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("No available users", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: "test",
|
||||||
|
start: dayjs().add(1, "day").format(),
|
||||||
|
end: dayjs().add(1, "day").add(1, "hour").format(),
|
||||||
|
eventTypeId: 2,
|
||||||
|
email: "test@example.com",
|
||||||
|
location: "Cal.com Video",
|
||||||
|
timeZone: "America/Montevideo",
|
||||||
|
language: "en",
|
||||||
|
customInputs: [],
|
||||||
|
metadata: {},
|
||||||
|
userId: 4,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
|
|
||||||
expect(res._getStatusCode()).toBe(500);
|
expect(res._getStatusCode()).toBe(400);
|
||||||
expect(JSON.parse(res._getData())).toEqual(
|
expect(JSON.parse(res._getData())).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
message: ErrorCode.NoAvailableUsersFound,
|
message: "Invalid event length",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -165,8 +399,8 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "test",
|
name: "test",
|
||||||
start: dayjs().format(),
|
start: dayjs().add(1, "day").format(),
|
||||||
end: dayjs().add(1, "day").format(),
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -179,8 +413,38 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: 1,
|
||||||
|
uid: "test-booking-uid",
|
||||||
|
title: "Test Event",
|
||||||
|
startTime: new Date(dayjs().add(1, "day").format()),
|
||||||
|
endTime: new Date(dayjs().add(1, "day").add(15, "minutes").format()),
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
profileId: null,
|
||||||
|
}).id,
|
||||||
|
oneTimePassword: null,
|
||||||
|
creationSource: "API_V1",
|
||||||
|
});
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
const mockTx = {
|
||||||
|
booking: {
|
||||||
|
create: prismaMock.booking.create.mockResolvedValue(mockBooking),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
app_RoutingForms_FormResponse: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await callback(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
@@ -189,12 +453,28 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Reschedule created booking", async () => {
|
test("Reschedule created booking", async () => {
|
||||||
|
const originalBooking = buildBooking({
|
||||||
|
uid: "original-booking-uid",
|
||||||
|
userId: 4,
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
locations: [{ type: "integrations:daily" }],
|
||||||
|
}).id,
|
||||||
|
references: [],
|
||||||
|
});
|
||||||
|
mockFindOriginalRescheduledBooking.mockResolvedValue(originalBooking);
|
||||||
|
|
||||||
|
prismaMock.booking.findUnique.mockResolvedValue({
|
||||||
|
...originalBooking,
|
||||||
|
status: "cancelled",
|
||||||
|
});
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "testReschedule",
|
name: "testReschedule",
|
||||||
start: dayjs().add(2, "day").format(),
|
start: dayjs().add(2, "day").format(),
|
||||||
end: dayjs().add(3, "day").format(),
|
end: dayjs().add(2, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -203,21 +483,52 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
customInputs: [],
|
customInputs: [],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
userId: 4,
|
userId: 4,
|
||||||
rescheduleUid: createdBooking.uid,
|
rescheduleUid: "original-booking-uid",
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: 2,
|
||||||
|
uid: "test-reschedule-uid",
|
||||||
|
title: "Test Reschedule Event",
|
||||||
|
startTime: new Date(dayjs().add(2, "day").format()),
|
||||||
|
endTime: new Date(dayjs().add(2, "day").add(15, "minutes").format()),
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
profileId: null,
|
||||||
|
locations: [{ type: "integrations:daily" }],
|
||||||
|
}).id,
|
||||||
|
oneTimePassword: null,
|
||||||
|
fromReschedule: "original-booking-uid",
|
||||||
|
});
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
const mockTx = {
|
||||||
|
booking: {
|
||||||
|
create: prismaMock.booking.create.mockResolvedValue(mockBooking),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
app_RoutingForms_FormResponse: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await callback(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
||||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||||
expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid);
|
expect(rescheduledBooking.fromReschedule).toEqual("original-booking-uid");
|
||||||
const previousBooking = await prisma.booking.findUnique({
|
const previousBooking = await prisma.booking.findUnique({
|
||||||
where: { uid: createdBooking.uid },
|
where: { uid: "original-booking-uid" },
|
||||||
});
|
});
|
||||||
expect(previousBooking?.status).toBe("cancelled");
|
expect(previousBooking?.status).toBe("cancelled");
|
||||||
});
|
});
|
||||||
@@ -227,8 +538,8 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: "test",
|
name: "test",
|
||||||
start: dayjs().format(),
|
start: dayjs().add(1, "day").format(),
|
||||||
end: dayjs().add(1, "day").format(),
|
end: dayjs().add(1, "day").add(15, "minutes").format(),
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
location: "Cal.com Video",
|
location: "Cal.com Video",
|
||||||
@@ -241,8 +552,38 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
prismaMock.booking.findMany.mockResolvedValue([]);
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
const mockBooking = buildBooking({
|
||||||
|
id: 3,
|
||||||
|
uid: "test-api-v1-uid",
|
||||||
|
title: "Test API V1 Event",
|
||||||
|
startTime: new Date(dayjs().add(1, "day").format()),
|
||||||
|
endTime: new Date(dayjs().add(1, "day").add(15, "minutes").format()),
|
||||||
|
eventTypeId: buildEventType({
|
||||||
|
id: 2,
|
||||||
|
profileId: null,
|
||||||
|
}).id,
|
||||||
|
oneTimePassword: null,
|
||||||
|
creationSource: "API_V1",
|
||||||
|
});
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
const mockTx = {
|
||||||
|
booking: {
|
||||||
|
create: prismaMock.booking.create.mockResolvedValue(mockBooking),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
app_RoutingForms_FormResponse: {
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return await callback(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
createdBooking = JSON.parse(res._getData());
|
createdBooking = JSON.parse(res._getData());
|
||||||
@@ -252,25 +593,50 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Recurring event-type", () => {
|
describe("Recurring event-type", () => {
|
||||||
test("Creates multiple bookings", async () => {
|
test.skip("Creates multiple bookings", async () => {
|
||||||
|
const recurringDates = Array.from(Array(12).keys()).map((i) => ({
|
||||||
|
start: dayjs().add(1, "day").add(i, "week").toISOString(),
|
||||||
|
end: dayjs().add(1, "day").add(i, "week").add(15, "minutes").toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
title: "test",
|
title: "test",
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
startTime: dayjs().toDate(),
|
start: recurringDates[0].start,
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
end: recurringDates[0].end,
|
||||||
|
timeZone: "UTC",
|
||||||
|
language: "en",
|
||||||
|
responses: {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
location: { optionValue: "", value: "integrations:daily" },
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
recurringCount: 12,
|
recurringCount: 12,
|
||||||
|
recurringEventId: "test-recurring-event-id",
|
||||||
|
allRecurringDates: recurringDates,
|
||||||
|
isFirstRecurringSlot: true,
|
||||||
|
numSlotsToCheckForAvailability: 12,
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
...buildEventType({ profileId: null, length: 15 }),
|
||||||
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBookings = Array.from(Array(12).keys()).map((i) =>
|
||||||
|
buildBooking({ id: i + 1, uid: `recurring-booking-${i}` })
|
||||||
);
|
);
|
||||||
|
|
||||||
Array.from(Array(12).keys()).map(async () => {
|
prismaMock.booking.create.mockResolvedValue(buildBooking({ id: 1, uid: "test-booking-uid" }));
|
||||||
prismaMock.booking.create.mockResolvedValue(buildBooking());
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
return await callback(prismaMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.webhook.findMany.mockResolvedValue([]);
|
prismaMock.webhook.findMany.mockResolvedValue([]);
|
||||||
@@ -278,32 +644,57 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
const data = JSON.parse(res._getData());
|
const data = JSON.parse(res._getData());
|
||||||
|
|
||||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(12);
|
expect(prismaMock.$transaction).toHaveBeenCalled();
|
||||||
expect(res._getStatusCode()).toBe(201);
|
expect(res._getStatusCode()).toBe(200);
|
||||||
expect(data.message).toEqual("Bookings created successfully.");
|
expect(data.message).toEqual("Bookings created successfully.");
|
||||||
expect(data.bookings.length).toEqual(12);
|
expect(data.bookings.length).toEqual(12);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test("Notifies multiple bookings", async () => {
|
test.skip("Notifies multiple bookings", async () => {
|
||||||
|
const recurringDates = Array.from(Array(12).keys()).map((i) => ({
|
||||||
|
start: dayjs().add(1, "day").add(i, "week").toISOString(),
|
||||||
|
end: dayjs().add(1, "day").add(i, "week").add(15, "minutes").toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
title: "test",
|
title: "test",
|
||||||
eventTypeId: 2,
|
eventTypeId: 2,
|
||||||
startTime: dayjs().toDate(),
|
start: recurringDates[0].start,
|
||||||
endTime: dayjs().add(1, "day").toDate(),
|
end: recurringDates[0].end,
|
||||||
|
timeZone: "UTC",
|
||||||
|
language: "en",
|
||||||
|
responses: {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
location: { optionValue: "", value: "integrations:daily" },
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
recurringCount: 12,
|
recurringCount: 12,
|
||||||
|
recurringEventId: "test-recurring-event-id",
|
||||||
|
allRecurringDates: recurringDates,
|
||||||
|
isFirstRecurringSlot: true,
|
||||||
|
numSlotsToCheckForAvailability: 12,
|
||||||
},
|
},
|
||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
prismaMock.eventType.findUnique.mockResolvedValue(
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||||
buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } })
|
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||||
);
|
profile: { organizationId: null },
|
||||||
|
hosts: [],
|
||||||
|
users: [buildUser()],
|
||||||
|
});
|
||||||
|
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
Array.from(Array(12).keys()).map(async () => {
|
const mockBookings = Array.from(Array(12).keys()).map((i) =>
|
||||||
prismaMock.booking.create.mockResolvedValue(buildBooking({ createdAt }));
|
buildBooking({ id: i + 1, uid: `webhook-booking-${i}`, createdAt })
|
||||||
|
);
|
||||||
|
|
||||||
|
prismaMock.booking.create.mockResolvedValue(buildBooking({ id: 1, uid: "test-booking-uid" }));
|
||||||
|
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||||
|
return await callback(prismaMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockedWebhooks = [
|
const mockedWebhooks = [
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
watchAttempts: 0,
|
watchAttempts: 0,
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
unwatchAttempts: 0,
|
unwatchAttempts: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
@@ -136,6 +138,8 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
watchAttempts: 0,
|
watchAttempts: 0,
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
unwatchAttempts: 0,
|
unwatchAttempts: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|||||||
@@ -38,8 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.258",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
|
|||||||
@@ -232,6 +232,15 @@ export class BookingsController_2024_04_15 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (bookingUid) {
|
if (bookingUid) {
|
||||||
|
const { bookingInfo } = await getBookingInfo(bookingUid);
|
||||||
|
if (!bookingInfo) {
|
||||||
|
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||||
|
}
|
||||||
|
if (bookingInfo.status === "CANCELLED") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
req.body.uid = bookingUid;
|
req.body.uid = bookingUid;
|
||||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ export class BookingsController_2024_08_13 {
|
|||||||
@Permissions([BOOKING_READ])
|
@Permissions([BOOKING_READ])
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get 'Booking References' for a booking",
|
summary: "Get booking references",
|
||||||
})
|
})
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async getBookingReferences(
|
async getBookingReferences(
|
||||||
|
|||||||
@@ -1425,8 +1425,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
expect(data.status).toEqual("cancelled");
|
expect(data.status).toEqual("cancelled");
|
||||||
|
expect(data.rescheduledToUid).toEqual(rescheduledBooking.uid);
|
||||||
createdBooking = data;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1582,17 +1581,55 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("cancel bookings", () => {
|
describe("cancel bookings", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||||
|
});
|
||||||
|
|
||||||
it("should cancel booking", async () => {
|
it("should cancel booking", async () => {
|
||||||
|
const createBody: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||||
|
eventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper Cancel",
|
||||||
|
email: "mr_proper_cancel@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "customValue",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: "100",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(createBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body;
|
||||||
|
expect(responseDataIsBooking(createResponseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (!responseDataIsBooking(createResponseBody.data)) {
|
||||||
|
throw new Error("Failed to create booking for test");
|
||||||
|
}
|
||||||
|
|
||||||
|
const testBooking: BookingOutput_2024_08_13 = createResponseBody.data;
|
||||||
|
|
||||||
|
const booking = await bookingsRepositoryFixture.getByUid(testBooking.uid);
|
||||||
|
expect(booking).toBeDefined();
|
||||||
|
expect(booking?.status).toEqual("ACCEPTED");
|
||||||
|
|
||||||
const body: CancelBookingInput_2024_08_13 = {
|
const body: CancelBookingInput_2024_08_13 = {
|
||||||
cancellationReason: "Going on a vacation",
|
cancellationReason: "Going on a vacation",
|
||||||
};
|
};
|
||||||
|
|
||||||
const booking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
|
|
||||||
expect(booking).toBeDefined();
|
|
||||||
expect(booking?.status).toEqual("ACCEPTED");
|
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${rescheduledBooking.uid}/cancel`)
|
.post(`/v2/bookings/${testBooking.uid}/cancel`)
|
||||||
.send(body)
|
.send(body)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
@@ -1611,27 +1648,61 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
expect(data.hosts[0].email).toEqual(user.email);
|
expect(data.hosts[0].email).toEqual(user.email);
|
||||||
expect(data.status).toEqual("cancelled");
|
expect(data.status).toEqual("cancelled");
|
||||||
expect(data.cancellationReason).toEqual(body.cancellationReason);
|
expect(data.cancellationReason).toEqual(body.cancellationReason);
|
||||||
expect(data.start).toEqual(rescheduledBooking.start);
|
expect(data.start).toEqual(testBooking.start);
|
||||||
expect(data.end).toEqual(rescheduledBooking.end);
|
expect(data.end).toEqual(testBooking.end);
|
||||||
expect(data.duration).toEqual(rescheduledBooking.duration);
|
expect(data.duration).toEqual(testBooking.duration);
|
||||||
expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId);
|
expect(data.eventTypeId).toEqual(testBooking.eventTypeId);
|
||||||
expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]);
|
expect(data.attendees[0]).toEqual(testBooking.attendees[0]);
|
||||||
expect(data.location).toEqual(rescheduledBooking.location);
|
expect(data.location).toEqual(testBooking.location);
|
||||||
expect(data.absentHost).toEqual(rescheduledBooking.absentHost);
|
expect(data.absentHost).toEqual(testBooking.absentHost);
|
||||||
|
|
||||||
const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
|
const cancelledBooking = await bookingsRepositoryFixture.getByUid(testBooking.uid);
|
||||||
expect(cancelledBooking).toBeDefined();
|
expect(cancelledBooking).toBeDefined();
|
||||||
expect(cancelledBooking?.status).toEqual("CANCELLED");
|
expect(cancelledBooking?.status).toEqual("CANCELLED");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should cancel recurring booking", async () => {
|
it("should cancel recurring booking", async () => {
|
||||||
|
const createBody: CreateRecurringBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: recurringEventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Mr Proper Recurring Cancel",
|
||||||
|
email: "mr_proper_recurring_cancel@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "customValue",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: "100",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(createBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body;
|
||||||
|
expect(responseDataIsRecurringBooking(createResponseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (!responseDataIsRecurringBooking(createResponseBody.data)) {
|
||||||
|
throw new Error("Failed to create recurring booking for test");
|
||||||
|
}
|
||||||
|
|
||||||
|
const testRecurringBooking: RecurringBookingOutput_2024_08_13[] = createResponseBody.data;
|
||||||
|
|
||||||
const body: CancelBookingInput_2024_08_13 = {
|
const body: CancelBookingInput_2024_08_13 = {
|
||||||
cancellationReason: "Going on a vacation",
|
cancellationReason: "Going on a vacation",
|
||||||
};
|
};
|
||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/bookings/${createdRecurringBooking[1].recurringBookingUid}/cancel`)
|
.post(`/v2/bookings/${testRecurringBooking[1].recurringBookingUid}/cancel`)
|
||||||
.send(body)
|
.send(body)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
@@ -1644,7 +1715,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||||
expect(data.length).toEqual(4);
|
expect(data.length).toEqual(3);
|
||||||
|
|
||||||
const firstBooking = data[0];
|
const firstBooking = data[0];
|
||||||
expect(firstBooking.status).toEqual("cancelled");
|
expect(firstBooking.status).toEqual("cancelled");
|
||||||
@@ -1654,9 +1725,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
|
|
||||||
const thirdBooking = data[2];
|
const thirdBooking = data[2];
|
||||||
expect(thirdBooking.status).toEqual("cancelled");
|
expect(thirdBooking.status).toEqual("cancelled");
|
||||||
|
|
||||||
const fourthBooking = data[3];
|
|
||||||
expect(fourthBooking.status).toEqual("cancelled");
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
"Invalid response data - expected recurring booking but received non array response"
|
||||||
@@ -2553,6 +2621,59 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cant't cancel already cancelled booking", () => {
|
||||||
|
it("should not be able to cancel alraedy cancelled booking", async () => {
|
||||||
|
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||||
|
status: "CANCELLED",
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||||
|
title: "peer coding lets goo",
|
||||||
|
uid: `cancelled-booking-${randomString()}`,
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: "integrations:daily",
|
||||||
|
customInputs: {},
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "Oldie",
|
||||||
|
email: "oldie@gmail.com",
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
create: {
|
||||||
|
email: "oldie@gmail.com",
|
||||||
|
name: "Oldie",
|
||||||
|
locale: "lv",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body: CancelBookingInput_2024_08_13 = {
|
||||||
|
cancellationReason: "Going on a vacation",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${cancelledBooking.uid}/cancel`)
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.error.message).toEqual(
|
||||||
|
`Can't cancel booking with uid=${cancelledBooking.uid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||||
|
);
|
||||||
|
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("calendar events", () => {
|
describe("calendar events", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
|||||||
@@ -596,9 +596,9 @@ export class BookingsService_2024_08_13 {
|
|||||||
} else if (isRecurring && isSeated) {
|
} else if (isRecurring && isSeated) {
|
||||||
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted));
|
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted));
|
||||||
} else if (isSeated) {
|
} else if (isSeated) {
|
||||||
formattedBookings.push(this.outputService.getOutputSeatedBooking(formatted));
|
formattedBookings.push(await this.outputService.getOutputSeatedBooking(formatted));
|
||||||
} else {
|
} else {
|
||||||
formattedBookings.push(this.outputService.getOutputBooking(formatted));
|
formattedBookings.push(await this.outputService.getOutputBooking(formatted));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,31 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
guests,
|
guests,
|
||||||
location,
|
location,
|
||||||
},
|
},
|
||||||
|
...this.getRoutingFormData(inputBooking.routing),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRoutingFormData(
|
||||||
|
routing:
|
||||||
|
| {
|
||||||
|
teamMemberIds?: number[];
|
||||||
|
responseId?: number;
|
||||||
|
teamMemberEmail?: string;
|
||||||
|
skipContactOwner?: boolean;
|
||||||
|
crmAppSlug?: string;
|
||||||
|
crmOwnerRecordType?: string;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
) {
|
||||||
|
if (!routing) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
routedTeamMemberIds: routing.teamMemberIds,
|
||||||
|
routingFormResponseId: routing.responseId,
|
||||||
|
teamMemberEmail: routing.teamMemberEmail,
|
||||||
|
skipContactOwner: routing.skipContactOwner,
|
||||||
|
crmAppSlug: routing.crmAppSlug,
|
||||||
|
crmOwnerRecordType: routing.crmOwnerRecordType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +478,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
location,
|
location,
|
||||||
},
|
},
|
||||||
schedulingType: eventType.schedulingType,
|
schedulingType: eventType.schedulingType,
|
||||||
|
...this.getRoutingFormData(inputBooking.routing),
|
||||||
});
|
});
|
||||||
|
|
||||||
switch (timeBetween) {
|
switch (timeBetween) {
|
||||||
@@ -711,6 +737,12 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (booking.status === "CANCELLED") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const oAuthClientParams = booking.eventTypeId
|
const oAuthClientParams = booking.eventTypeId
|
||||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ type DatabaseMetadata = z.infer<typeof bookingMetadataSchema>;
|
|||||||
export class OutputBookingsService_2024_08_13 {
|
export class OutputBookingsService_2024_08_13 {
|
||||||
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
|
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
|
||||||
|
|
||||||
getOutputBooking(databaseBooking: DatabaseBooking) {
|
async getOutputBooking(databaseBooking: DatabaseBooking) {
|
||||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
@@ -101,6 +101,9 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
);
|
);
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
|
const rescheduledToUid = databaseBooking.rescheduled
|
||||||
|
? await this.getRescheduledToUid(databaseBooking.uid)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
@@ -109,8 +112,9 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
description: databaseBooking.description,
|
description: databaseBooking.description,
|
||||||
hosts: [this.getHost(databaseBooking.user)],
|
hosts: [this.getHost(databaseBooking.user)],
|
||||||
status: databaseBooking.status.toLowerCase(),
|
status: databaseBooking.status.toLowerCase(),
|
||||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
cancellationReason:
|
||||||
cancelledByEmail: databaseBooking.cancelledBy || undefined,
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
|
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
@@ -137,6 +141,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
updatedAt: databaseBooking.updatedAt,
|
updatedAt: databaseBooking.updatedAt,
|
||||||
rating: databaseBooking.rating,
|
rating: databaseBooking.rating,
|
||||||
icsUid: databaseBooking.iCalUID,
|
icsUid: databaseBooking.iCalUID,
|
||||||
|
rescheduledToUid,
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
@@ -146,6 +151,11 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
return bookingTransformed;
|
return bookingTransformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRescheduledToUid(bookingUid: string) {
|
||||||
|
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||||
|
return rescheduledTo?.uid;
|
||||||
|
}
|
||||||
|
|
||||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||||
if (databaseMetadata === null) return {};
|
if (databaseMetadata === null) return {};
|
||||||
|
|
||||||
@@ -204,8 +214,9 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
description: databaseBooking.description,
|
description: databaseBooking.description,
|
||||||
hosts: [this.getHost(databaseBooking.user)],
|
hosts: [this.getHost(databaseBooking.user)],
|
||||||
status: databaseBooking.status.toLowerCase(),
|
status: databaseBooking.status.toLowerCase(),
|
||||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
cancellationReason:
|
||||||
cancelledByEmail: databaseBooking.cancelledBy || undefined,
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
|
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
@@ -244,20 +255,23 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
return bookingTransformed;
|
return bookingTransformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
getOutputCreateSeatedBooking(
|
async getOutputCreateSeatedBooking(
|
||||||
databaseBooking: DatabaseBooking,
|
databaseBooking: DatabaseBooking,
|
||||||
seatUid: string
|
seatUid: string
|
||||||
): CreateSeatedBookingOutput_2024_08_13 {
|
): Promise<CreateSeatedBookingOutput_2024_08_13> {
|
||||||
const getSeatedBookingOutput = this.getOutputSeatedBooking(databaseBooking);
|
const getSeatedBookingOutput = await this.getOutputSeatedBooking(databaseBooking);
|
||||||
return { ...getSeatedBookingOutput, seatUid };
|
return { ...getSeatedBookingOutput, seatUid };
|
||||||
}
|
}
|
||||||
|
|
||||||
getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
|
async getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
|
||||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
|
const rescheduledToUid = databaseBooking.rescheduled
|
||||||
|
? await this.getRescheduledToUid(databaseBooking.uid)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
@@ -266,7 +280,8 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
description: databaseBooking.description,
|
description: databaseBooking.description,
|
||||||
hosts: [this.getHost(databaseBooking.user)],
|
hosts: [this.getHost(databaseBooking.user)],
|
||||||
status: databaseBooking.status.toLowerCase(),
|
status: databaseBooking.status.toLowerCase(),
|
||||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
cancellationReason:
|
||||||
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
@@ -283,6 +298,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
updatedAt: databaseBooking.updatedAt,
|
updatedAt: databaseBooking.updatedAt,
|
||||||
rating: databaseBooking.rating,
|
rating: databaseBooking.rating,
|
||||||
icsUid: databaseBooking.iCalUID,
|
icsUid: databaseBooking.iCalUID,
|
||||||
|
rescheduledToUid,
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
@@ -375,7 +391,8 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
description: databaseBooking.description,
|
description: databaseBooking.description,
|
||||||
hosts: [this.getHost(databaseBooking.user)],
|
hosts: [this.getHost(databaseBooking.user)],
|
||||||
status: databaseBooking.status.toLowerCase(),
|
status: databaseBooking.status.toLowerCase(),
|
||||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
cancellationReason:
|
||||||
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
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 { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.CredentialSelect>()({
|
const credentialForCalendarRepositorySelect = {
|
||||||
id: true,
|
id: true,
|
||||||
appId: true,
|
appId: true,
|
||||||
type: true,
|
type: true,
|
||||||
@@ -16,7 +16,7 @@ const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.Credential
|
|||||||
teamId: true,
|
teamId: true,
|
||||||
key: true,
|
key: true,
|
||||||
invalid: true,
|
invalid: true,
|
||||||
});
|
} satisfies Prisma.CredentialSelect;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CalendarsRepository {
|
export class CalendarsRepository {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
import { CreateIcsFeedOutput, CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
import { CreateIcsFeedOutput, CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||||
|
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||||
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||||
@@ -30,7 +31,7 @@ import {
|
|||||||
GOOGLE_CALENDAR_ID,
|
GOOGLE_CALENDAR_ID,
|
||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||||
import { ICS_CALENDAR } from "@calcom/platform-constants/apps";
|
import { ICS_CALENDAR, ICS_CALENDAR_TYPE } from "@calcom/platform-constants/apps";
|
||||||
import { IcsFeedCalendarService } from "@calcom/platform-libraries/app-store";
|
import { IcsFeedCalendarService } from "@calcom/platform-libraries/app-store";
|
||||||
|
|
||||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||||
@@ -247,6 +248,35 @@ describe("Platform Calendars Endpoints", () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`/GET/v2/calendars with access token`, async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/calendars`)
|
||||||
|
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||||
|
.set("Origin", CLIENT_REDIRECT_URI)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const data: ConnectedCalendarsData = response.body.data;
|
||||||
|
expect(data.connectedCalendars).toBeDefined();
|
||||||
|
expect(data.connectedCalendars).toHaveLength(3);
|
||||||
|
|
||||||
|
const googleConnectedCalendar = data.connectedCalendars.find(
|
||||||
|
(calendar) => calendar.integration.type === GOOGLE_CALENDAR_TYPE
|
||||||
|
);
|
||||||
|
const office365ConnectedCalendar = data.connectedCalendars.find(
|
||||||
|
(calendar) => calendar.integration.type === OFFICE_365_CALENDAR_TYPE
|
||||||
|
);
|
||||||
|
const icsConnectedCalendar = data.connectedCalendars.find(
|
||||||
|
(calendar) => calendar.integration.type === ICS_CALENDAR_TYPE
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(googleConnectedCalendar).toBeDefined();
|
||||||
|
expect(office365ConnectedCalendar).toBeDefined();
|
||||||
|
expect(icsConnectedCalendar).toBeDefined();
|
||||||
|
|
||||||
|
expect(data.destinationCalendar).toBeDefined();
|
||||||
|
expect(data.destinationCalendar.integration).toEqual(GOOGLE_CALENDAR_TYPE);
|
||||||
|
});
|
||||||
|
|
||||||
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
|
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
|
||||||
const body = {
|
const body = {
|
||||||
id: 10,
|
id: 10,
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export class CalendarsController {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@Get("/:calendar/connect")
|
@Get("/:calendar/connect")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Get oAuth connect URL" })
|
@ApiOperation({ summary: "Get OAuth connect URL" })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: "redir",
|
name: "redir",
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
|||||||
+26
-5
@@ -18,6 +18,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
|||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
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";
|
||||||
import {
|
import {
|
||||||
@@ -33,13 +34,19 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Delete,
|
Delete,
|
||||||
Query,
|
Query,
|
||||||
|
Headers,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import {
|
||||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
EVENT_TYPE_READ,
|
||||||
|
EVENT_TYPE_WRITE,
|
||||||
|
SUCCESS_STATUS,
|
||||||
|
X_CAL_CLIENT_ID,
|
||||||
|
} from "@calcom/platform-constants";
|
||||||
|
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries/event-types";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
@@ -51,7 +58,8 @@ import { PrismaClient } from "@calcom/prisma";
|
|||||||
export class EventTypesController_2024_04_15 {
|
export class EventTypesController_2024_04_15 {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||||
private readonly prismaReadService: PrismaReadService
|
private readonly prismaReadService: PrismaReadService,
|
||||||
|
private readonly organizationsRepository: OrganizationsRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@@ -109,19 +117,32 @@ export class EventTypesController_2024_04_15 {
|
|||||||
async getPublicEventType(
|
async getPublicEventType(
|
||||||
@Param("username") username: string,
|
@Param("username") username: string,
|
||||||
@Param("eventSlug") eventSlug: string,
|
@Param("eventSlug") eventSlug: string,
|
||||||
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
|
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15,
|
||||||
|
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||||
): Promise<GetEventTypePublicOutput> {
|
): Promise<GetEventTypePublicOutput> {
|
||||||
try {
|
try {
|
||||||
|
let orgSlug = queryParams.org;
|
||||||
|
|
||||||
|
if (clientId && !orgSlug && username.includes(`-${clientId}`)) {
|
||||||
|
const org = await this.organizationsRepository
|
||||||
|
.findTeamIdAndSlugFromClientId(clientId)
|
||||||
|
.catch(() => null);
|
||||||
|
if (org) {
|
||||||
|
orgSlug = org.slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const event = await getPublicEvent(
|
const event = await getPublicEvent(
|
||||||
username.toLowerCase(),
|
username.toLowerCase(),
|
||||||
eventSlug,
|
eventSlug,
|
||||||
queryParams.isTeamEvent,
|
queryParams.isTeamEvent,
|
||||||
queryParams.org || null,
|
orgSlug ?? null,
|
||||||
this.prismaReadService.prisma as unknown as PrismaClient,
|
this.prismaReadService.prisma as unknown as PrismaClient,
|
||||||
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
|
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
|
||||||
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
|
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: event,
|
data: event,
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_20
|
|||||||
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
|
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 { 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 { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
|
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
@@ -9,7 +10,14 @@ import { UsersModule } from "@/modules/users/users.module";
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
MembershipsModule,
|
||||||
|
TokensModule,
|
||||||
|
UsersModule,
|
||||||
|
SelectedCalendarsModule,
|
||||||
|
OrganizationsModule,
|
||||||
|
],
|
||||||
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
|
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
|
||||||
controllers: [EventTypesController_2024_04_15],
|
controllers: [EventTypesController_2024_04_15],
|
||||||
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
|
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
|
||||||
|
|||||||
+9
-1
@@ -1721,7 +1721,7 @@ describe("Event types Endpoints", () => {
|
|||||||
description: "undefined booking fields",
|
description: "undefined booking fields",
|
||||||
length: 40,
|
length: 40,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
slug: "undefined-booking-fields",
|
slug: `undefined-booking-fields-${randomString()}`,
|
||||||
locations: [],
|
locations: [],
|
||||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
};
|
};
|
||||||
@@ -1751,6 +1751,14 @@ describe("Event types Endpoints", () => {
|
|||||||
disableOnPrefill: false,
|
disableOnPrefill: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
disableOnPrefill: false,
|
||||||
|
hidden: true,
|
||||||
|
isDefault: true,
|
||||||
|
required: false,
|
||||||
|
slug: "attendeePhoneNumber",
|
||||||
|
type: "phone",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
type: "radioInput",
|
type: "radioInput",
|
||||||
|
|||||||
+9
-11
@@ -46,11 +46,10 @@ export class SchedulesController_2024_04_15 {
|
|||||||
@Body() bodySchedule: CreateScheduleInput_2024_04_15
|
@Body() bodySchedule: CreateScheduleInput_2024_04_15
|
||||||
): Promise<CreateScheduleOutput_2024_04_15> {
|
): Promise<CreateScheduleOutput_2024_04_15> {
|
||||||
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
||||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: scheduleFormatted,
|
data: schedule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,13 +59,10 @@ export class SchedulesController_2024_04_15 {
|
|||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
||||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||||
const scheduleFormatted = schedule
|
|
||||||
? await this.schedulesService.formatScheduleForAtom(user, schedule)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: scheduleFormatted,
|
data: schedule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,23 +73,25 @@ export class SchedulesController_2024_04_15 {
|
|||||||
@Param("scheduleId") scheduleId: number
|
@Param("scheduleId") scheduleId: number
|
||||||
): Promise<GetScheduleOutput_2024_04_15> {
|
): Promise<GetScheduleOutput_2024_04_15> {
|
||||||
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
||||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: scheduleFormatted,
|
data: schedule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@Permissions([SCHEDULE_READ])
|
@Permissions([SCHEDULE_READ])
|
||||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
|
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
|
||||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
const schedules = await this.schedulesService.getUserSchedules(
|
||||||
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
|
user.id,
|
||||||
|
user.timeZone,
|
||||||
|
user.defaultScheduleId
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: schedulesFormatted,
|
data: schedules,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04
|
|||||||
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 { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesRepository_2024_04_15 {
|
export class SchedulesRepository_2024_04_15 {
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
|
||||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Schedule } from "@prisma/client";
|
import { Schedule } from "@prisma/client";
|
||||||
import { User } from "@prisma/client";
|
|
||||||
|
|
||||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
|
import { updateSchedule } from "@calcom/platform-libraries/schedules";
|
||||||
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
|
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
|
||||||
import {
|
|
||||||
transformWorkingHoursForClient,
|
|
||||||
transformAvailabilityForClient,
|
|
||||||
transformDateOverridesForClient,
|
|
||||||
} from "@calcom/platform-libraries-0.0.2";
|
|
||||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService_2024_04_15 {
|
export class SchedulesService_2024_04_15 {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
|
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
|
||||||
private readonly usersRepository: UsersRepository
|
private readonly usersRepository: UsersRepository,
|
||||||
|
private readonly dbWrite: PrismaWriteService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||||
@@ -48,7 +44,9 @@ export class SchedulesService_2024_04_15 {
|
|||||||
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
|
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return createdSchedule;
|
const formattedSchedule = await this.getUserSchedule(userId, createdSchedule.id);
|
||||||
|
|
||||||
|
return formattedSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserScheduleDefault(userId: number) {
|
async getUserScheduleDefault(userId: number) {
|
||||||
@@ -56,11 +54,29 @@ export class SchedulesService_2024_04_15 {
|
|||||||
|
|
||||||
if (!user?.defaultScheduleId) return null;
|
if (!user?.defaultScheduleId) return null;
|
||||||
|
|
||||||
return this.schedulesRepository.getScheduleById(user.defaultScheduleId);
|
return await ScheduleRepository.findDetailedScheduleById({
|
||||||
|
scheduleId: user.defaultScheduleId,
|
||||||
|
isManagedEventType: undefined,
|
||||||
|
userId,
|
||||||
|
timeZone: user.timeZone,
|
||||||
|
defaultScheduleId: user.defaultScheduleId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserSchedule(userId: number, scheduleId: number) {
|
async getUserSchedule(userId: number, scheduleId: number) {
|
||||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
const user = await this.usersRepository.findById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`User with ID=${userId} does not exist.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSchedule = await ScheduleRepository.findDetailedScheduleById({
|
||||||
|
scheduleId: scheduleId,
|
||||||
|
isManagedEventType: undefined,
|
||||||
|
userId,
|
||||||
|
timeZone: user.timeZone,
|
||||||
|
defaultScheduleId: user.defaultScheduleId,
|
||||||
|
});
|
||||||
|
|
||||||
if (!existingSchedule) {
|
if (!existingSchedule) {
|
||||||
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||||
@@ -71,8 +87,8 @@ export class SchedulesService_2024_04_15 {
|
|||||||
return existingSchedule;
|
return existingSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserSchedules(userId: number) {
|
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
|
||||||
return this.schedulesRepository.getSchedulesByUserId(userId);
|
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUserSchedule(
|
async updateUserSchedule(
|
||||||
@@ -89,7 +105,6 @@ export class SchedulesService_2024_04_15 {
|
|||||||
this.checkUserOwnsSchedule(user.id, existingSchedule);
|
this.checkUserOwnsSchedule(user.id, existingSchedule);
|
||||||
|
|
||||||
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
|
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
|
||||||
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
|
|
||||||
|
|
||||||
if (!bodySchedule.schedule) {
|
if (!bodySchedule.schedule) {
|
||||||
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
|
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
|
||||||
@@ -97,12 +112,16 @@ export class SchedulesService_2024_04_15 {
|
|||||||
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
|
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
|
||||||
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
|
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
|
||||||
// schedule.
|
// schedule.
|
||||||
bodySchedule.schedule = scheduleFormatted.availability;
|
bodySchedule.schedule = schedule.availability;
|
||||||
}
|
}
|
||||||
|
|
||||||
return updateScheduleHandler({
|
return updateSchedule({
|
||||||
input: { scheduleId: Number(scheduleId), ...bodySchedule },
|
input: {
|
||||||
ctx: { user },
|
scheduleId: Number(scheduleId),
|
||||||
|
...bodySchedule,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,44 +137,6 @@ export class SchedulesService_2024_04_15 {
|
|||||||
return this.schedulesRepository.deleteScheduleById(scheduleId);
|
return this.schedulesRepository.deleteScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise<ScheduleOutput> {
|
|
||||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
|
||||||
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async formatSchedulesForAtom(
|
|
||||||
user: User,
|
|
||||||
schedules: ScheduleWithAvailabilities[]
|
|
||||||
): Promise<ScheduleOutput[]> {
|
|
||||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
|
||||||
return Promise.all(
|
|
||||||
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async transformScheduleForAtom(
|
|
||||||
schedule: ScheduleWithAvailabilities,
|
|
||||||
userSchedulesCount: number,
|
|
||||||
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
|
|
||||||
): Promise<ScheduleOutput> {
|
|
||||||
const timeZone = schedule.timeZone || user.timeZone;
|
|
||||||
const defaultSchedule = await this.getUserScheduleDefault(user.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: schedule.id,
|
|
||||||
name: schedule.name,
|
|
||||||
isManaged: schedule.userId !== user.id,
|
|
||||||
workingHours: transformWorkingHoursForClient(schedule),
|
|
||||||
schedule: schedule.availability,
|
|
||||||
availability: transformAvailabilityForClient(schedule),
|
|
||||||
timeZone,
|
|
||||||
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
|
|
||||||
isDefault: defaultSchedule?.id === schedule.id,
|
|
||||||
isLastSchedule: userSchedulesCount <= 1,
|
|
||||||
readOnly: schedule.userId !== user.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
|
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
|
||||||
if (userId !== schedule.userId) {
|
if (userId !== schedule.userId) {
|
||||||
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { extractUserContext } from "@/lib/extract-user-context";
|
||||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -39,6 +40,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
|||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
||||||
exception,
|
exception,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -46,6 +48,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
response.status(400).json({
|
response.status(400).json({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { extractUserContext } from "@/lib/extract-user-context";
|
||||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -16,6 +17,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
|||||||
const statusCode = exception.getStatus();
|
const statusCode = exception.getStatus();
|
||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||||
exception,
|
exception,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -23,6 +25,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
response.status(statusCode).json({
|
response.status(statusCode).json({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { extractUserContext } from "@/lib/extract-user-context";
|
||||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||||
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
||||||
@@ -43,6 +44,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
|||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`PrismaError: ${error.message}`, {
|
this.logger.error(`PrismaError: ${error.message}`, {
|
||||||
error,
|
error,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -50,6 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
let message = "There was an error, please try again later.";
|
let message = "There was an error, please try again later.";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { extractUserContext } from "@/lib/extract-user-context";
|
||||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -120,6 +121,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
|||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||||
exception,
|
exception,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -127,6 +129,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
response.status(statusCode).json({
|
response.status(statusCode).json({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { extractUserContext } from "@/lib/extract-user-context";
|
||||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||||
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
||||||
@@ -18,6 +19,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
|||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`ZodError: ${error.message}`, {
|
this.logger.error(`ZodError: ${error.message}`, {
|
||||||
error,
|
error,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -25,6 +27,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
response.status(HttpStatus.BAD_REQUEST).json({
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export enum AuthMethods {
|
|||||||
"ACCESS_TOKEN" = "access-token",
|
"ACCESS_TOKEN" = "access-token",
|
||||||
"OAUTH_CLIENT" = "oauth-client",
|
"OAUTH_CLIENT" = "oauth-client",
|
||||||
"NEXT_AUTH" = "next-auth",
|
"NEXT_AUTH" = "next-auth",
|
||||||
|
"THIRD_PARTY_ACCESS_TOKEN" = "third_party_access_token",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ export enum Locales {
|
|||||||
TA = "ta",
|
TA = "ta",
|
||||||
UK = "uk",
|
UK = "uk",
|
||||||
ZH_TW = "zh-TW",
|
ZH_TW = "zh-TW",
|
||||||
|
BN = "bn",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
import { ApiAuthGuardUser } from "../modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
|
||||||
|
export interface UserContext {
|
||||||
|
userId?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
userOrgId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractUserContext(request: Request): UserContext {
|
||||||
|
const context: UserContext = {};
|
||||||
|
|
||||||
|
const user = (request as any).user as ApiAuthGuardUser | undefined;
|
||||||
|
if (user) {
|
||||||
|
context.userId = String(user.id);
|
||||||
|
context.userEmail = user.email;
|
||||||
|
if (user.profiles?.[0]?.organizationId) {
|
||||||
|
context.userOrgId = String(user.profiles?.[0].organizationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = (request as any).organizationId as number | undefined;
|
||||||
|
if (organizationId) {
|
||||||
|
context.organizationId = String(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = (request as any).team as Team | undefined;
|
||||||
|
if (team) {
|
||||||
|
context.teamId = String(team.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
|
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||||
|
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||||
|
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
|
||||||
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
|
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [
|
||||||
|
PrismaOOORepository,
|
||||||
|
PrismaScheduleRepository,
|
||||||
|
PrismaBookingRepository,
|
||||||
|
PrismaSelectedSlotsRepository,
|
||||||
|
PrismaUserRepository,
|
||||||
|
PrismaEventTypeRepository,
|
||||||
|
PrismaRoutingFormResponseRepository,
|
||||||
|
PrismaTeamRepository,
|
||||||
|
AvailableSlotsService,
|
||||||
|
],
|
||||||
|
exports: [AvailableSlotsService],
|
||||||
|
})
|
||||||
|
export class AvailableSlotsModule {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaBookingRepository as PrismaBookingRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaBookingRepository extends PrismaBookingRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaEventTypeRepository as PrismaEventTypeRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaEventTypeRepository extends PrismaEventTypeRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaOOORepository as PrismaOOORepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaOOORepository extends PrismaOOORepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaRoutingFormResponseRepository as PrismaRoutingFormResponseRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaRoutingFormResponseRepository extends PrismaRoutingFormResponseRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaScheduleRepository as PrismaScheduleRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaScheduleRepository extends PrismaScheduleRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaSelectedSlotsRepository as PrismaSelectedSlotsRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaSelectedSlotsRepository extends PrismaSelectedSlotsRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaTeamRepository as PrismaTeamRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaTeamRepository extends PrismaTeamRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaUserRepository as PrismaUserRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaUserRepository extends PrismaUserRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
|
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||||
|
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||||
|
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
|
||||||
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||||
|
constructor(
|
||||||
|
private readonly oooRepoDependency: PrismaOOORepository,
|
||||||
|
private readonly scheduleRepoDependency: PrismaScheduleRepository,
|
||||||
|
private readonly teamRepository: PrismaTeamRepository,
|
||||||
|
private readonly routingFormResponseRepository: PrismaRoutingFormResponseRepository,
|
||||||
|
private readonly bookingRepository: PrismaBookingRepository,
|
||||||
|
private readonly selectedSlotsRepository: PrismaSelectedSlotsRepository,
|
||||||
|
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
||||||
|
private readonly userRepository: PrismaUserRepository
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
oooRepo: oooRepoDependency,
|
||||||
|
scheduleRepo: scheduleRepoDependency,
|
||||||
|
teamRepo: teamRepository,
|
||||||
|
routingFormResponseRepo: routingFormResponseRepository,
|
||||||
|
bookingRepo: bookingRepository,
|
||||||
|
selectedSlotsRepo: selectedSlotsRepository,
|
||||||
|
eventTypeRepo: eventTypeRepository,
|
||||||
|
userRepo: userRepository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { extractUserContext } from "@/lib/extract-user-context";
|
||||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { tap } from "rxjs/operators";
|
import { tap } from "rxjs/operators";
|
||||||
@@ -14,6 +15,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
|||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
const { method, url } = request;
|
const { method, url } = request;
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
@@ -39,6 +41,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
|||||||
responseTime,
|
responseTime,
|
||||||
responseBody: jsonBodyString,
|
responseBody: jsonBodyString,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH";
|
export type AllowedAuthMethod =
|
||||||
|
| "OAUTH_CLIENT_CREDENTIALS"
|
||||||
|
| "API_KEY"
|
||||||
|
| "ACCESS_TOKEN"
|
||||||
|
| "NEXT_AUTH"
|
||||||
|
| "THIRD_PARTY_ACCESS_TOKEN";
|
||||||
|
|
||||||
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
|
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Or } from "./or.guard";
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { ExecutionContext, CanActivate } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { Or } from "./or.guard";
|
||||||
|
|
||||||
|
// Mock guards for testing
|
||||||
|
class MockGuard1 implements CanActivate {
|
||||||
|
constructor(private shouldPass: boolean) {}
|
||||||
|
|
||||||
|
async canActivate(): Promise<boolean> {
|
||||||
|
return this.shouldPass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockGuard2 implements CanActivate {
|
||||||
|
constructor(private shouldPass: boolean) {}
|
||||||
|
|
||||||
|
async canActivate(): Promise<boolean> {
|
||||||
|
return this.shouldPass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockGuard3 implements CanActivate {
|
||||||
|
constructor(private shouldThrow: boolean = false) {}
|
||||||
|
|
||||||
|
async canActivate(): Promise<boolean> {
|
||||||
|
if (this.shouldThrow) {
|
||||||
|
throw new Error("Guard failed");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OrGuard", () => {
|
||||||
|
let guard: InstanceType<ReturnType<typeof Or>>;
|
||||||
|
let mockExecutionContext: ExecutionContext;
|
||||||
|
let mockModuleRef: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockModuleRef = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrGuardClass = Or([MockGuard1, MockGuard2]);
|
||||||
|
guard = new OrGuardClass(mockModuleRef);
|
||||||
|
mockExecutionContext = {} as ExecutionContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(guard).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should grant access when first guard passes", async () => {
|
||||||
|
const mockGuard1 = new MockGuard1(true);
|
||||||
|
const mockGuard2 = new MockGuard2(false);
|
||||||
|
|
||||||
|
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockExecutionContext);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should grant access when second guard passes", async () => {
|
||||||
|
const mockGuard1 = new MockGuard1(false);
|
||||||
|
const mockGuard2 = new MockGuard2(true);
|
||||||
|
|
||||||
|
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockExecutionContext);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deny access when all guards fail", async () => {
|
||||||
|
const mockGuard1 = new MockGuard1(false);
|
||||||
|
const mockGuard2 = new MockGuard2(false);
|
||||||
|
|
||||||
|
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockExecutionContext);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should continue checking other guards when one throws an error", async () => {
|
||||||
|
const mockGuard1 = new MockGuard3(true); // throws error
|
||||||
|
const mockGuard2 = new MockGuard2(true); // passes
|
||||||
|
|
||||||
|
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockExecutionContext);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Or decorator", () => {
|
||||||
|
it("should create a guard class with the specified guards", () => {
|
||||||
|
const OrGuardClass = Or([MockGuard1, MockGuard2]);
|
||||||
|
|
||||||
|
expect(OrGuardClass).toBeDefined();
|
||||||
|
expect(typeof OrGuardClass).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, Type, Logger } from "@nestjs/common";
|
||||||
|
import { ModuleRef } from "@nestjs/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator function that creates an Or guard with the specified guards
|
||||||
|
* @param guards Array of guard classes to evaluate with OR logic
|
||||||
|
* @returns A guard class that grants access if ANY of the provided guards return true
|
||||||
|
*/
|
||||||
|
export function Or(guards: Type<CanActivate>[]) {
|
||||||
|
@Injectable()
|
||||||
|
class OrGuard implements CanActivate {
|
||||||
|
public readonly logger = new Logger("OrGuard");
|
||||||
|
|
||||||
|
constructor(public readonly moduleRef: ModuleRef) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
let lastError: unknown | null = null;
|
||||||
|
for (const Guard of guards) {
|
||||||
|
try {
|
||||||
|
const guardInstance = this.moduleRef.get(Guard, { strict: false });
|
||||||
|
const result = await Promise.resolve(guardInstance.canActivate(context));
|
||||||
|
|
||||||
|
if (result === true) {
|
||||||
|
this.logger.log(`OrGuard - Guard ${Guard.name} granted access`);
|
||||||
|
return true; // Access granted if any guard returns true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
// If a guard throws an exception, it implies failure for that specific guard.
|
||||||
|
// We catch it and continue checking other guards in the OR chain.
|
||||||
|
// If an exception should stop the entire chain immediately, re-throw it here.
|
||||||
|
this.logger.log(
|
||||||
|
`OrGuard - Guard ${Guard.name} failed: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log("OrGuard - All guards failed, access denied");
|
||||||
|
if (lastError) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OrGuard;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IsUserRoutingForm implements CanActivate {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||||
|
const routingFormId: string = request.params.routingFormId;
|
||||||
|
const user = request.user as ApiAuthGuardUser;
|
||||||
|
if (!routingFormId) {
|
||||||
|
throw new ForbiddenException("IsUserRoutingForm - No routing form id found in request params.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoutingForm = await this.dbRead.prisma.app_RoutingForms_Form.findFirst({
|
||||||
|
where: {
|
||||||
|
id: routingFormId,
|
||||||
|
userId: Number(user.id),
|
||||||
|
teamId: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userRoutingForm) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Routing Form with id=${routingFormId} is not a user Routing Form owned by user with id=${user.id}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||||
import { createMock } from "@golevelup/ts-jest";
|
import { createMock } from "@golevelup/ts-jest";
|
||||||
import { ExecutionContext } from "@nestjs/common";
|
import { ExecutionContext } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
@@ -19,6 +20,7 @@ describe("PermissionsGuard", () => {
|
|||||||
guard = new PermissionsGuard(
|
guard = new PermissionsGuard(
|
||||||
reflector,
|
reflector,
|
||||||
createMock<TokensRepository>(),
|
createMock<TokensRepository>(),
|
||||||
|
createMock<TokensService>(),
|
||||||
createMock<ConfigService>({
|
createMock<ConfigService>({
|
||||||
get: jest.fn().mockImplementation((key: string) => {
|
get: jest.fn().mockImplementation((key: string) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
@@ -118,6 +120,7 @@ describe("PermissionsGuard", () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null);
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
||||||
@@ -133,11 +136,22 @@ describe("PermissionsGuard", () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null);
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return true for 3rd party access token", async () => {
|
||||||
|
const mockContext = createMockExecutionContext({ Authorization: "Bearer token" });
|
||||||
|
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({
|
||||||
|
scope: ["scope"],
|
||||||
|
token_type: "Bearer",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when OAuth id is provided", () => {
|
describe("when OAuth id is provided", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
|||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
@@ -17,6 +18,7 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
constructor(
|
constructor(
|
||||||
private reflector: Reflector,
|
private reflector: Reflector,
|
||||||
private tokensRepository: TokensRepository,
|
private tokensRepository: TokensRepository,
|
||||||
|
private tokensService: TokensService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
private readonly oAuthClientsOutputService: OAuthClientsOutputService
|
private readonly oAuthClientsOutputService: OAuthClientsOutputService
|
||||||
@@ -35,9 +37,10 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||||
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
||||||
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
|
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
|
||||||
|
const isThirdPartyBearerToken = bearerToken && this.getDecodedThirdPartyAccessToken(bearerToken);
|
||||||
|
|
||||||
// only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key
|
// only check permissions for accessTokens attached to platform oAuth Client or platform oAuth credentials, not for next token or api key or third party oauth client
|
||||||
if (nextAuthToken || apiKey) {
|
if (nextAuthToken || apiKey || isThirdPartyBearerToken) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +90,8 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
return oAuthClient;
|
return oAuthClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDecodedThirdPartyAccessToken(bearerToken: string) {
|
||||||
|
return this.tokensService.getDecodedThirdPartyAccessToken(bearerToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.se
|
|||||||
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 { ProfilesModule } from "@/modules/profiles/profiles.module";
|
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { ExecutionContext, HttpException } from "@nestjs/common";
|
import { ExecutionContext, HttpException } from "@nestjs/common";
|
||||||
@@ -64,6 +64,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
load: [appConfig],
|
load: [appConfig],
|
||||||
}),
|
}),
|
||||||
ProfilesModule,
|
ProfilesModule,
|
||||||
|
TokensModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
MockedRedisService,
|
MockedRedisService,
|
||||||
@@ -77,7 +78,6 @@ describe("ApiAuthStrategy", () => {
|
|||||||
OAuthClientRepository,
|
OAuthClientRepository,
|
||||||
PrismaReadService,
|
PrismaReadService,
|
||||||
PrismaWriteService,
|
PrismaWriteService,
|
||||||
TokensRepository,
|
|
||||||
JwtService,
|
JwtService,
|
||||||
DeploymentsRepository,
|
DeploymentsRepository,
|
||||||
NestJwtService,
|
NestJwtService,
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
|||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
@@ -29,13 +31,17 @@ export type ApiAuthGuardRequest = Request & {
|
|||||||
};
|
};
|
||||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
export const NO_AUTH_PROVIDED_MESSAGE =
|
||||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
||||||
|
private readonly logger = new Logger("ApiAuthStrategy");
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly deploymentsService: DeploymentsService,
|
private readonly deploymentsService: DeploymentsService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly oauthFlowService: OAuthFlowService,
|
private readonly oauthFlowService: OAuthFlowService,
|
||||||
private readonly tokensRepository: TokensRepository,
|
private readonly tokensRepository: TokensRepository,
|
||||||
|
private readonly tokensService: TokensService,
|
||||||
private readonly userRepository: UsersRepository,
|
private readonly userRepository: UsersRepository,
|
||||||
private readonly apiKeyRepository: ApiKeysRepository,
|
private readonly apiKeyRepository: ApiKeysRepository,
|
||||||
private readonly oauthRepository: OAuthClientRepository,
|
private readonly oauthRepository: OAuthClientRepository,
|
||||||
@@ -54,22 +60,53 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
|
|
||||||
const allowedMethods = request.allowedAuthMethods;
|
const allowedMethods = request.allowedAuthMethods;
|
||||||
const noSpecificAuthExpected = !allowedMethods || !allowedMethods.length;
|
const noSpecificAuthExpected = !allowedMethods || !allowedMethods.length;
|
||||||
|
|
||||||
const oAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("OAUTH_CLIENT_CREDENTIALS");
|
const oAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("OAUTH_CLIENT_CREDENTIALS");
|
||||||
const apiKeyAllowed = noSpecificAuthExpected || allowedMethods.includes("API_KEY");
|
const apiKeyAllowed = noSpecificAuthExpected || allowedMethods.includes("API_KEY");
|
||||||
const accessTokenAllowed = noSpecificAuthExpected || allowedMethods.includes("ACCESS_TOKEN");
|
const accessTokenAllowed = noSpecificAuthExpected || allowedMethods.includes("ACCESS_TOKEN");
|
||||||
const nextAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("NEXT_AUTH");
|
const nextAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("NEXT_AUTH");
|
||||||
|
const thirdPartyAccessTokenAllowed =
|
||||||
|
noSpecificAuthExpected || allowedMethods.includes("THIRD_PARTY_ACCESS_TOKEN");
|
||||||
|
|
||||||
if (oAuthClientId && oAuthClientSecret && oAuthAllowed) {
|
if (oAuthClientId && oAuthClientSecret && oAuthAllowed) {
|
||||||
request.authMethod = AuthMethods["OAUTH_CLIENT"];
|
request.authMethod = AuthMethods["OAUTH_CLIENT"];
|
||||||
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
|
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bearerToken && (apiKeyAllowed || accessTokenAllowed)) {
|
if (bearerToken) {
|
||||||
const requestOrigin = request.get("Origin");
|
if (!apiKeyAllowed && !accessTokenAllowed && thirdPartyAccessTokenAllowed) {
|
||||||
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
request.authMethod = AuthMethods["THIRD_PARTY_ACCESS_TOKEN"];
|
||||||
? AuthMethods["API_KEY"]
|
const result = await this.validateThirdPartyAccessToken(bearerToken, request);
|
||||||
: AuthMethods["ACCESS_TOKEN"];
|
if (result.success) {
|
||||||
return await this.authenticateBearerToken(bearerToken, request, requestOrigin);
|
return this.success(this.getSuccessUser(result.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKeyAllowed || accessTokenAllowed) {
|
||||||
|
try {
|
||||||
|
const requestOrigin = request.get("Origin");
|
||||||
|
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||||
|
? AuthMethods["API_KEY"]
|
||||||
|
: AuthMethods["ACCESS_TOKEN"];
|
||||||
|
return await this.authenticateBearerToken(bearerToken, request, requestOrigin);
|
||||||
|
} catch (err) {
|
||||||
|
// failed to validate access token, try to validate third party token
|
||||||
|
if (thirdPartyAccessTokenAllowed && request.authMethod === AuthMethods["ACCESS_TOKEN"]) {
|
||||||
|
request.authMethod = AuthMethods["THIRD_PARTY_ACCESS_TOKEN"];
|
||||||
|
const result = await this.validateThirdPartyAccessToken(bearerToken, request);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return this.success(this.getSuccessUser(result.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// token was not third party token, rethrow error from authenticateBearerToken
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return this.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnauthorizedException(`ApiAuthStrategy - Invalid Bearer token`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||||
@@ -83,8 +120,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
if (noAuthProvided) {
|
if (noAuthProvided) {
|
||||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${allowedMethods}`
|
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
|
||||||
|
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
|
||||||
|
}`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
@@ -164,21 +204,17 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
: await this.accessTokenStrategy(authString, request, requestOrigin);
|
: await this.accessTokenStrategy(authString, request, requestOrigin);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return this.error(
|
throw new UnauthorizedException(
|
||||||
new UnauthorizedException(
|
"ApiAuthStrategy - bearer token - No user associated with the provided token"
|
||||||
"ApiAuthStrategy - bearer token - No user associated with the provided token"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.success(this.getSuccessUser(user));
|
return this.success(this.getSuccessUser(user));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return this.error(err);
|
throw err;
|
||||||
}
|
}
|
||||||
return this.error(
|
throw new InternalServerErrorException("An error occurred while authenticating the request");
|
||||||
new InternalServerErrorException("An error occurred while authenticating the request")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,4 +307,43 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validateThirdPartyAccessToken(
|
||||||
|
token: string,
|
||||||
|
request: ApiAuthGuardRequest
|
||||||
|
): Promise<{ success: true; data: UserWithProfile } | { success: false }> {
|
||||||
|
const decodedToken = this.tokensService.getDecodedThirdPartyAccessToken(token);
|
||||||
|
if (!decodedToken) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let user: UserWithProfile | null = null;
|
||||||
|
let organizationId: number | null = null;
|
||||||
|
|
||||||
|
if (decodedToken.userId) {
|
||||||
|
user = await this.userRepository.findByIdWithProfile(decodedToken.userId);
|
||||||
|
if (user) {
|
||||||
|
organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||||
|
}
|
||||||
|
} else if (decodedToken.teamId) {
|
||||||
|
const teamOwner = await this.userRepository.findOwnerByTeamIdWithProfile(decodedToken.teamId);
|
||||||
|
if (!teamOwner) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - third-party token - No owner found for the associated team."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
user = teamOwner;
|
||||||
|
organizationId =
|
||||||
|
teamOwner.profiles?.find((p) => p.organizationId === decodedToken.teamId)?.organizationId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - third-party token - No user or team owner associated with the token."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.organizationId = organizationId;
|
||||||
|
return { success: true, data: user };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingSeatRepository {
|
export class BookingSeatRepository {
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
|
|||||||
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 { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [TokensModule],
|
||||||
providers: [
|
providers: [
|
||||||
GCalService,
|
GCalService,
|
||||||
GoogleCalendarService,
|
GoogleCalendarService,
|
||||||
@@ -22,7 +22,6 @@ import { Module } from "@nestjs/common";
|
|||||||
BookingReferencesRepository_2024_08_13,
|
BookingReferencesRepository_2024_08_13,
|
||||||
CredentialsRepository,
|
CredentialsRepository,
|
||||||
CalendarsService,
|
CalendarsService,
|
||||||
TokensRepository,
|
|
||||||
SelectedCalendarsRepository,
|
SelectedCalendarsRepository,
|
||||||
PrismaReadService,
|
PrismaReadService,
|
||||||
PrismaWriteService,
|
PrismaWriteService,
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ export class CalUnifiedCalendarsController {
|
|||||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get meeting Details from calendar",
|
summary: "Get meeting details from calendar",
|
||||||
description: "Returns detailed information about a meeting including attendance metrics",
|
description: "Returns detailed information about a meeting including attendance metrics",
|
||||||
})
|
})
|
||||||
async getCalendarEventDetails(
|
async getCalendarEventDetails(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { calendar_v3 } from "@googleapis/calendar";
|
|||||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
import { NotFoundException } from "@nestjs/common";
|
import { NotFoundException } from "@nestjs/common";
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { JWT } from "googleapis-common";
|
import { JWT } from "googleapis-common";
|
||||||
|
|
||||||
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class ConferencingController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({ summary: "Get OAuth conferencing app auth url" })
|
@ApiOperation({ summary: "Get OAuth conferencing app auth URL" })
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "app",
|
name: "app",
|
||||||
description: "Conferencing application type",
|
description: "Conferencing application type",
|
||||||
@@ -130,7 +130,7 @@ export class ConferencingController {
|
|||||||
@Get("/:app/oauth/callback")
|
@Get("/:app/oauth/callback")
|
||||||
@UseGuards()
|
@UseGuards()
|
||||||
@Redirect(undefined, 301)
|
@Redirect(undefined, 301)
|
||||||
@ApiOperation({ summary: "conferencing apps oauths callback" })
|
@ApiOperation({ summary: "Conferencing app OAuth callback" })
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "app",
|
name: "app",
|
||||||
description: "Conferencing application type",
|
description: "Conferencing application type",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
|||||||
export class ConferencingRepository {
|
export class ConferencingRepository {
|
||||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||||
|
|
||||||
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
|
private readonly credentialSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
type: true,
|
type: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
@@ -20,7 +20,7 @@ export class ConferencingRepository {
|
|||||||
billingCycleStart: true,
|
billingCycleStart: true,
|
||||||
invalid: true,
|
invalid: true,
|
||||||
delegationCredentialId: true,
|
delegationCredentialId: true,
|
||||||
});
|
} satisfies Prisma.CredentialSelect;
|
||||||
|
|
||||||
async findConferencingApps(userId: number) {
|
async findConferencingApps(userId: number) {
|
||||||
return this.dbRead.prisma.credential.findMany({
|
return this.dbRead.prisma.credential.findMany({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||||
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||||
|
|||||||
+4
@@ -92,6 +92,8 @@ describe("Platform Destination Calendar Endpoints", () => {
|
|||||||
publisher: "",
|
publisher: "",
|
||||||
url: "",
|
url: "",
|
||||||
email: "",
|
email: "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
// calendars: {
|
// calendars: {
|
||||||
// externalId:
|
// externalId:
|
||||||
@@ -118,6 +120,8 @@ describe("Platform Destination Calendar Endpoints", () => {
|
|||||||
id: 0,
|
id: 0,
|
||||||
delegationCredentialId: null,
|
delegationCredentialId: null,
|
||||||
domainWideDelegationCredentialId: null,
|
domainWideDelegationCredentialId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
import { PrismaWriteService } from "../../prisma/prisma-write.service";
|
import { PrismaWriteService } from "../../prisma/prisma-write.service";
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -134,7 +134,7 @@ export class OrganizationsAttributesOptionsController {
|
|||||||
@Roles("ORG_MEMBER")
|
@Roles("ORG_MEMBER")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Get("/attributes/:attributeId/options/assigned")
|
@Get("/attributes/:attributeId/options/assigned")
|
||||||
@ApiOperation({ summary: "Get by attribute id all of the attribute options that are assigned to users" })
|
@ApiOperation({ summary: "Get all assigned attribute options by attribute ID" })
|
||||||
async getOrganizationAttributeAssignedOptions(
|
async getOrganizationAttributeAssignedOptions(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Param("attributeId") attributeId: string,
|
@Param("attributeId") attributeId: string,
|
||||||
@@ -158,7 +158,7 @@ export class OrganizationsAttributesOptionsController {
|
|||||||
@Roles("ORG_MEMBER")
|
@Roles("ORG_MEMBER")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Get("/attributes/slugs/:attributeSlug/options/assigned")
|
@Get("/attributes/slugs/:attributeSlug/options/assigned")
|
||||||
@ApiOperation({ summary: "Get by attribute slug all of the attribute options that are assigned to users" })
|
@ApiOperation({ summary: "Get all assigned attribute options by attribute slug" })
|
||||||
async getOrganizationAttributeAssignedOptionsBySlug(
|
async getOrganizationAttributeAssignedOptionsBySlug(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Param("attributeSlug") attributeSlug: string,
|
@Param("attributeSlug") attributeSlug: string,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class OrganizationsBookingsController {
|
|||||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@ApiOperation({ summary: "Get Organization Bookings" })
|
@ApiOperation({ summary: "Get organization bookings" })
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@ export class OrganizationsConferencingController {
|
|||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
|
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Get OAuth conferencing app's auth url for a team" })
|
@ApiOperation({ summary: "Get OAuth conferencing app's auth URL for a team" })
|
||||||
async getTeamOAuthUrl(
|
async getTeamOAuthUrl(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Headers("Authorization") authorization: string,
|
@Headers("Authorization") authorization: string,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user