Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1934c96353 | ||
|
|
a43437b1d6 | ||
|
|
d25094a152 | ||
|
|
70d29ab6fa | ||
|
|
65abf4edc6 | ||
|
|
ad4aeee45b | ||
|
|
40a2ec8ab3 | ||
|
|
2b7a87a34c | ||
|
|
b15060e306 | ||
|
|
bdf3935704 | ||
|
|
7952cfc9e9 | ||
|
|
4383d23d4c | ||
|
|
027cad2cfd | ||
|
|
b17b5a169f | ||
|
|
9eb1fe06e3 | ||
|
|
007349a180 | ||
|
|
46f979ba6d | ||
|
|
d4eb8e4b3a | ||
|
|
9d0afbd6f3 | ||
|
|
821bd37b37 | ||
|
|
2ab34004a2 | ||
|
|
3ee8032538 | ||
|
|
9838488068 | ||
|
|
1c365dee38 | ||
|
|
a77ee7aaa2 | ||
|
|
07d8a39829 | ||
|
|
10fa080b7f | ||
|
|
eb19edfa8f | ||
|
|
3719bb3762 | ||
|
|
c43c5a0604 | ||
|
|
1467b0b768 | ||
|
|
d3d4a90776 | ||
|
|
5ae50f8670 | ||
|
|
a3642a5594 | ||
|
|
96bfb19769 | ||
|
|
5935cf7f1b | ||
|
|
70eff63529 | ||
|
|
3d02df3b08 | ||
|
|
b9b12256fa | ||
|
|
1b051d102c | ||
|
|
76a820f3ca | ||
|
|
b3d5a6edca | ||
|
|
4753bd785a | ||
|
|
1f634b7ca0 | ||
|
|
2e0f4d3176 | ||
|
|
c2fd990504 | ||
|
|
ea3f4fedb0 | ||
|
|
2db4222ffe | ||
|
|
1dc5cd4d41 | ||
|
|
35d54ff223 | ||
|
|
e4c46e3387 | ||
|
|
77d1a873a9 | ||
|
|
54d98e73a2 | ||
|
|
134086f229 | ||
|
|
e9b5aa6f22 | ||
|
|
3908a6cd6f | ||
|
|
886028c400 | ||
|
|
703e20a43a | ||
|
|
b6aedb830b | ||
|
|
46c7afade6 | ||
|
|
5c7d7579ee | ||
|
|
d158a1e007 | ||
|
|
1fb4147940 | ||
|
|
9cf76cbb2f | ||
|
|
6b00167657 | ||
|
|
592f44eb32 | ||
|
|
00afafb550 | ||
|
|
089941553e | ||
|
|
2dc83a1c55 | ||
|
|
5bfb6fc148 | ||
|
|
31ccdba203 | ||
|
|
82fb5272c8 | ||
|
|
e86ad03cde | ||
|
|
1953402f81 | ||
|
|
d6a086c9c7 | ||
|
|
9a517a89b6 | ||
|
|
19f2197eef | ||
|
|
3d583678cf | ||
|
|
ddde762f9b | ||
|
|
9332eba5d7 | ||
|
|
46d1823ccc | ||
|
|
277da187d0 | ||
|
|
7c6b055cd1 | ||
|
|
97308ebf1c | ||
|
|
c2cf55f10c | ||
|
|
3867ff6874 | ||
|
|
0e1c4e53d8 | ||
|
|
3834e69100 | ||
|
|
a05935cdf6 | ||
|
|
19a0f968ca | ||
|
|
3b945ba439 | ||
|
|
7a47eacb34 | ||
|
|
da853958f7 | ||
|
|
d63d15b7b2 | ||
|
|
6bc040b7bb | ||
|
|
0465c639e6 | ||
|
|
8d59c2fc16 | ||
|
|
cb365117b3 | ||
|
|
7c9aae7eaf | ||
|
|
1e7453edcd | ||
|
|
f353fdf252 | ||
|
|
feff588603 | ||
|
|
8ae5d32fe5 | ||
|
|
9600ab6085 | ||
|
|
417a612aa3 | ||
|
|
4db3c592e5 | ||
|
|
d99db25dca | ||
|
|
ba423e1061 | ||
|
|
d5ca11b27b | ||
|
|
25534f721a | ||
|
|
fad5497363 | ||
|
|
563b31b7d0 | ||
|
|
8c9eb18463 | ||
|
|
0965fad72a | ||
|
|
e76bb37f78 | ||
|
|
b0a85a5637 | ||
|
|
82f374d84c | ||
|
|
9ac037c524 | ||
|
|
f5750f3933 | ||
|
|
8f2cb0522d | ||
|
|
4cd8dbbab4 | ||
|
|
1fb778ae78 | ||
|
|
bc9d76e7da | ||
|
|
af51ce1890 | ||
|
|
75c79f76c8 | ||
|
|
b5b4134b75 | ||
|
|
01ca2ca3de | ||
|
|
f94e80558a | ||
|
|
a5c15e6517 | ||
|
|
aaa39cccf8 | ||
|
|
d24a93962f | ||
|
|
1d9d52ecf8 | ||
|
|
f1a832921a | ||
|
|
af1633db28 | ||
|
|
7353b6a604 | ||
|
|
dab0b4a4d8 |
@@ -424,6 +424,8 @@ DIRECTORY_IDS_TO_LOG=
|
||||
# Read more about it in the README.md
|
||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
||||
|
||||
AKISMET_API_KEY=
|
||||
|
||||
## Env variables related to avoiding booking failures
|
||||
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
||||
NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS=
|
||||
@@ -439,3 +441,5 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
|
||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||
|
||||
DATABASE_CHUNK_SIZE=
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: Cal.com/platform request
|
||||
about: Suggest a feature or bug report for cal.com/platform
|
||||
title: ""
|
||||
labels: ["platform", "🚨 needs approval"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
### Is your proposal related to a problem?
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what the problem is.
|
||||
For example, "I'm always frustrated when..."
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Describe the solution you'd like
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what you want to happen.
|
||||
-->
|
||||
|
||||
(Describe your proposed solution here.)
|
||||
|
||||
### Describe alternatives you've considered
|
||||
|
||||
<!--
|
||||
Let us know about other solutions you've tried or researched.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Additional context
|
||||
|
||||
<!--
|
||||
Is there anything else you can add about the proposal?
|
||||
You might want to link to related issues here, if you haven't already.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Requirement/Document
|
||||
|
||||
<!--
|
||||
Is there any type of document that could support that feature?
|
||||
-->
|
||||
|
||||
(Share it here.)
|
||||
|
||||
---
|
||||
|
||||
##### House rules
|
||||
|
||||
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
|
||||
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
|
||||
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
|
||||
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||
@@ -1,3 +1,5 @@
|
||||
# deprecated - use smtp with tasker instead */
|
||||
|
||||
name: Cron - scheduleEmailReminders
|
||||
|
||||
on:
|
||||
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
+69
-156
@@ -1,43 +1,76 @@
|
||||
# Contributing to Cal.com
|
||||
|
||||
Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||
|
||||
## House rules
|
||||
## House Rules (for PRs and Issues)
|
||||
|
||||
- Before submitting a new issue or PR, check if it already exists in [issues](https://github.com/calcom/cal.com/issues) or [PRs](https://github.com/calcom/cal.com/pulls).
|
||||
- GitHub issues: take note of the `🚨 needs approval` label.
|
||||
- **For Contributors**:
|
||||
- Feature Requests: Wait for a core member to approve and remove the `🚨 needs approval` label before you start coding or submit a PR.
|
||||
- Bugs, Security, Performance, Documentation, etc.: You can start coding immediately, even if the `🚨 needs approval` label is present. This label mainly concerns feature requests.
|
||||
- **Our Process**:
|
||||
- Issues from non-core members automatically receive the `🚨 needs approval` label.
|
||||
- We greatly value new feature ideas. To ensure consistency in the product's direction, they undergo review and approval.
|
||||
### 👥 Prevent Work Duplication
|
||||
|
||||
Before submitting a new issue or PR, check if it already exists in the [Issues](https://github.com/calcom/cal.com/issues) or [Pull Requests](https://github.com/calcom/cal.com/pulls).
|
||||
|
||||
### ✅ Work Only on Approved Issues
|
||||
|
||||
For feature requests, please wait for a core team member to approve and remove the `🚨 needs approval` label before you start coding or submitting a PR.
|
||||
|
||||
For bugs, security, performance, documentation, etc., you can start coding immediately—even if the `🚨 needs approval` label is present.
|
||||
|
||||
We highly value new feature ideas, but to maintain consistency in the product direction, they must go through a review and approval process.
|
||||
|
||||
### 🚫 Don’t Just Drop a Link
|
||||
|
||||
Avoid posting third-party links (e.g., Slack threads or Linear tickets) without context. A GitHub issue or PR should stand on its own—reviewers shouldn’t have to chase information across multiple tools to understand the context.
|
||||
|
||||
### 👀 Think Like a Reviewer
|
||||
|
||||
Put yourself in the reviewer’s shoes. What would you want to know if reading this for the first time? Are there key decisions, goals, or constraints that need clarification? Does the PR assume knowledge that isn’t obvious? Are there related issues or previous PRs that should be linked?
|
||||
|
||||
### 🧵 Bring in Context from Private Channels
|
||||
|
||||
If the task originated from a private conversation (e.g., Slack), take a moment to extract the relevant details and include them in the GitHub issue or PR. Avoid sharing sensitive information, but make sure important reasoning is captured.
|
||||
|
||||
> Example:
|
||||
> “A user requested feature X to solve problem Y. I considered approaches A, B, and C, but chose C for the following reasons…”
|
||||
|
||||
### 📚 Treat It Like Documentation
|
||||
|
||||
GitHub is a shared source of truth. Every issue and PR contributes to the long-term understanding of the codebase. Write clearly enough that someone—possibly you—can revisit it months later and still understand what happened and why.
|
||||
|
||||
### ✅ Summarize Your PR at the Top
|
||||
|
||||
Even if the code changes are minor or self-explanatory, a short written summary helps reviewers quickly understand the intent. You can use GitHub Copilot’s auto-summarize feature, but make sure to verify it for accuracy and relevance.
|
||||
|
||||
### 🔗 Use GitHub Keywords to Auto-Link Issues
|
||||
|
||||
Use phrases like “Closes #123” or “Fixes #456” in your PR descriptions. This automatically links your PR to the related issue and closes it once merged—keeping everything traceable and organized.
|
||||
|
||||
### 🧪 Mention What Was Tested (and How)
|
||||
|
||||
Explain how you validated your changes. It doesn’t need to be exhaustive—just enough to give reviewers confidence that things were tested and work as expected.
|
||||
|
||||
> Example:
|
||||
> “Tested locally with mock data and confirmed the flow works on staging.”
|
||||
|
||||
### 🧠 Assume Future-You Won’t Remember
|
||||
|
||||
Write with the future in mind. If there are trade-offs, edge cases, or temporary workarounds, document them clearly so they don’t get lost or misinterpreted later.
|
||||
|
||||
## Priorities
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
Type of Issue
|
||||
</td>
|
||||
<td>
|
||||
Priority
|
||||
</td>
|
||||
<td><strong>Type of Issue</strong></td>
|
||||
<td><strong>Priority</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Minor improvements, non-core feature requests
|
||||
</td>
|
||||
<td>Minor improvements, non-core feature requests</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22Low+priority%22">
|
||||
<img src="https://img.shields.io/badge/-Low%20Priority-green">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Confusing UX (... but working)
|
||||
</td>
|
||||
<tr>
|
||||
<td>Confusing UX (but still functional)</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22Medium+priority%22">
|
||||
<img src="https://img.shields.io/badge/-Medium%20Priority-yellow">
|
||||
@@ -45,9 +78,7 @@ Contributions are what makes the open source community such an amazing place to
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Core Features (Booking page, availability, timezone calculation)
|
||||
</td>
|
||||
<td>Core Features (Booking page, availability, timezone calculation)</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
|
||||
<img src="https://img.shields.io/badge/-High%20Priority-orange">
|
||||
@@ -55,9 +86,7 @@ Contributions are what makes the open source community such an amazing place to
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Core Bugs (Login, Booking page, Emails are not working)
|
||||
</td>
|
||||
<td>Core Bugs (Login, Booking page, Emails not working)</td>
|
||||
<td>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:Urgent">
|
||||
<img src="https://img.shields.io/badge/-Urgent-red">
|
||||
@@ -68,59 +97,7 @@ Contributions are what makes the open source community such an amazing place to
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
requests should be made against. The changes on the `main`
|
||||
branch are tagged into a release monthly.
|
||||
|
||||
To develop locally:
|
||||
|
||||
1. [Fork](https://github.com/calcom/cal.com/fork/) this repository to your
|
||||
own GitHub account and then
|
||||
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
2. Create a new branch:
|
||||
|
||||
```sh
|
||||
git checkout -b MY_BRANCH_NAME
|
||||
```
|
||||
|
||||
3. Install yarn:
|
||||
|
||||
```sh
|
||||
npm install -g yarn
|
||||
```
|
||||
|
||||
4. Install the dependencies with:
|
||||
|
||||
```sh
|
||||
yarn
|
||||
```
|
||||
|
||||
5. Set up your `.env` file:
|
||||
|
||||
- Duplicate `.env.example` to `.env`.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||
- Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||
|
||||
6. Setup Node
|
||||
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
||||
|
||||
```sh
|
||||
nvm use
|
||||
```
|
||||
|
||||
You first might need to install the specific version:
|
||||
|
||||
```sh
|
||||
nvm install
|
||||
```
|
||||
|
||||
You can install nvm from [here](https://github.com/nvm-sh/nvm).
|
||||
|
||||
7. Start developing and watch for code changes:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
```
|
||||
[See README](https://github.com/calcom/cal.com#development)
|
||||
|
||||
## Building
|
||||
|
||||
@@ -130,23 +107,19 @@ You can build the project with:
|
||||
yarn build
|
||||
```
|
||||
|
||||
Please be sure that you can make a full production build before pushing code.
|
||||
Please ensure that you can make a full production build before pushing code.
|
||||
|
||||
## Testing
|
||||
|
||||
More info on how to add new tests coming soon.
|
||||
|
||||
### Running tests
|
||||
### Running Tests
|
||||
|
||||
This will run and test all flows in multiple Chromium windows to verify that no critical flow breaks:
|
||||
[See README](https://github.com/calcom/cal.com#e2e-testing)
|
||||
|
||||
```sh
|
||||
yarn test-e2e
|
||||
```
|
||||
#### Resolving Issues
|
||||
|
||||
#### Resolving issues
|
||||
|
||||
##### E2E test browsers not installed
|
||||
##### E2E Test Browsers Not Installed
|
||||
|
||||
Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
|
||||
|
||||
@@ -166,68 +139,8 @@ If you get errors, be sure to fix them before committing.
|
||||
|
||||
## Making a Pull Request
|
||||
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Be sure to fill the PR Template accordingly.
|
||||
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
|
||||
|
||||
## Guidelines for committing yarn lockfile
|
||||
|
||||
Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo:
|
||||
|
||||
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
|
||||
|
||||
```bash
|
||||
git checkout HEAD~1 yarn.lock
|
||||
git commit -m "Revert yarn.lock changes"
|
||||
```
|
||||
|
||||
_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit
|
||||
If you've pushed the commit with the `yarn.lock`:
|
||||
|
||||
1. Correct the commit locally using the above method.
|
||||
2. Carefully force push:
|
||||
|
||||
```bash
|
||||
git push origin <your-branch-name> --force
|
||||
```
|
||||
|
||||
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
|
||||
|
||||
1. **Checkout a Previous Version**:
|
||||
|
||||
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
|
||||
|
||||
```bash
|
||||
git log yarn.lock
|
||||
```
|
||||
|
||||
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
|
||||
|
||||
```bash
|
||||
git checkout <commit_hash> yarn.lock
|
||||
```
|
||||
|
||||
2. **Commit the Reverted Version**:
|
||||
|
||||
- After checking out the previous version of the `yarn.lock`, commit this change:
|
||||
|
||||
```bash
|
||||
git commit -m "Revert yarn.lock to its state before unintended changes"
|
||||
```
|
||||
|
||||
3. **Proceed with Caution**:
|
||||
|
||||
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
|
||||
|
||||
```bash
|
||||
git pull origin <your-branch-name>
|
||||
```
|
||||
|
||||
- Then push the updated branch:
|
||||
|
||||
```bash
|
||||
git push origin <your-branch-name>
|
||||
```
|
||||
|
||||
Lastly, make sure to keep the branches updated (e.g. click the `Update branch` button on GitHub PR).
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) when creating your PR. (This option isn't available if you're [contributing from a fork belonging to an organization](https://github.com/orgs/community/discussions/5634))
|
||||
- If your PR refers to or fixes an issue, add `refs #XXX` or `fixes #XXX` to the PR description. Replace `XXX` with the respective issue number. See more about [linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Fill out the PR template accordingly.
|
||||
- Review the [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations.
|
||||
- Lastly, make sure to keep your branches updated (e.g., click the `Update branch` button on the GitHub PR page).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/lib/constants";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
|
||||
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
@@ -103,7 +104,7 @@ const schemaEventTypeEditParams = z
|
||||
.transform((s) => slugify(s))
|
||||
.optional(),
|
||||
length: z.number().int().optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsPerTimeSlot: z.number().min(1).max(MAX_SEATS_PER_TIME_SLOT).nullable().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
|
||||
@@ -79,9 +79,10 @@ async function handler(req: NextApiRequest) {
|
||||
allRemainingBookings: req.query.allRemainingBookings === "true",
|
||||
});
|
||||
|
||||
// Normalizing for universal handler
|
||||
req.body = { id, allRemainingBookings, cancellationReason, cancelledBy };
|
||||
return await handleCancelBooking(req);
|
||||
return await handleCancelBooking({
|
||||
bookingData: { id, allRemainingBookings, cancellationReason, cancelledBy },
|
||||
userId: req.userId,
|
||||
});
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
|
||||
@@ -213,12 +213,14 @@ import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
* description: Authorization information is missing or invalid.
|
||||
*/
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { userId, isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req;
|
||||
const { isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req;
|
||||
let userId = req.userId;
|
||||
|
||||
req.body = {
|
||||
...req.body,
|
||||
creationSource: CreationSource.API_V1,
|
||||
};
|
||||
if (isSystemWideAdmin) req.userId = req.body.userId || userId;
|
||||
if (isSystemWideAdmin) userId = req.body.userId || userId;
|
||||
|
||||
if (isOrganizationOwnerOrAdmin) {
|
||||
const accessibleUsersIds = await getAccessibleUsers({
|
||||
@@ -226,11 +228,19 @@ async function handler(req: NextApiRequest) {
|
||||
memberUserIds: [req.body.userId || userId],
|
||||
});
|
||||
const [requestedUserId] = accessibleUsersIds;
|
||||
req.userId = requestedUserId || userId;
|
||||
userId = requestedUserId || userId;
|
||||
}
|
||||
|
||||
try {
|
||||
return await handleNewBooking(req, getBookingDataSchemaForApi);
|
||||
return await handleNewBooking(
|
||||
{
|
||||
bookingData: req.body,
|
||||
userId,
|
||||
hostname: req.headers.host || "",
|
||||
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
|
||||
},
|
||||
getBookingDataSchemaForApi
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const knownError = error as Error;
|
||||
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import type { TInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
|
||||
import { ZInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -38,7 +39,8 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
try {
|
||||
const caller = viewerTeamsRouter.createCaller(ctx);
|
||||
const createCaller = createCallerFactory(viewerTeamsRouter);
|
||||
const caller = createCaller(ctx);
|
||||
await caller.inviteMember({
|
||||
role: data.role,
|
||||
language: data.language,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -39,7 +40,8 @@ const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
try {
|
||||
const caller = viewerTeamsRouter.createCaller(ctx);
|
||||
const createCaller = createCallerFactory(viewerTeamsRouter);
|
||||
const caller = createCaller(ctx);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return await caller.publish(req.query as any /* Let tRPC handle this */);
|
||||
} catch (cause) {
|
||||
|
||||
@@ -32,3 +32,9 @@ DOCS_URL=
|
||||
# Axiom logging
|
||||
AXIOM_DATASET=
|
||||
AXIOM_TOKEN=
|
||||
|
||||
# DEBUG: 0
|
||||
# INFO: 1
|
||||
# WARN: 2
|
||||
# ERROR: 3
|
||||
LOGGER_BRIDGE_LOG_LEVEL="1"
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.160",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.172",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -17,7 +17,8 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
@@ -36,7 +37,6 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { CreationSource } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { NextApiRequest } from "next/types";
|
||||
@@ -104,7 +104,8 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly config: ConfigService,
|
||||
private readonly apiKeyRepository: ApiKeysRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly usersService: UsersService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -114,18 +115,19 @@ export class BookingsController_2024_04_15 {
|
||||
@ApiQuery({ name: "limit", type: "number", required: false })
|
||||
@ApiQuery({ name: "cursor", type: "number", required: false })
|
||||
async getBookings(
|
||||
@GetUser() user: User,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Query() queryParams: GetBookingsInput_2024_04_15
|
||||
): Promise<GetBookingsOutput_2024_04_15> {
|
||||
const { filters, cursor, limit } = queryParams;
|
||||
const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"];
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
const bookings = await getAllUserBookings({
|
||||
bookingListingByStatus: [bookingListingByStatus],
|
||||
skip: cursor ?? 0,
|
||||
take: limit ?? 10,
|
||||
filters,
|
||||
ctx: {
|
||||
user: { email: user.email, id: user.id },
|
||||
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
},
|
||||
});
|
||||
@@ -174,11 +176,19 @@ export class BookingsController_2024_04_15 {
|
||||
const oAuthClientId =
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
const { orgSlug, locationUrl } = body;
|
||||
req.headers["x-cal-force-slug"] = orgSlug;
|
||||
try {
|
||||
const booking = await handleNewBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed)
|
||||
);
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
forcedSlug: orgSlug,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
uid: booking.uid,
|
||||
@@ -214,9 +224,16 @@ export class BookingsController_2024_04_15 {
|
||||
if (bookingUid) {
|
||||
try {
|
||||
req.body.uid = bookingUid;
|
||||
const res = await handleCancelBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed)
|
||||
);
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
const res = await handleCancelBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
});
|
||||
if (!res.onlyRemovedAttendee) {
|
||||
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
||||
}
|
||||
@@ -277,9 +294,18 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
}
|
||||
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
|
||||
await this.createNextApiRecurringBookingRequest(req, oAuthClientId, undefined, isEmbed)
|
||||
);
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
|
||||
createdBookings.forEach(async (booking) => {
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingsRepository_2024_08_13 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
ApiBody,
|
||||
ApiExtraModels,
|
||||
} from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
|
||||
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -80,7 +80,10 @@ import {
|
||||
export class BookingsController_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingsController_2024_08_13");
|
||||
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly usersService: UsersService
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({
|
||||
@@ -96,6 +99,10 @@ export class BookingsController_2024_08_13 {
|
||||
For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body.
|
||||
|
||||
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
||||
|
||||
Finally, there are 2 ways to book an event type:
|
||||
1. Provide \`eventTypeId\` in the request body.
|
||||
2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization.
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
@@ -161,9 +168,15 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({ summary: "Get all bookings" })
|
||||
async getBookings(
|
||||
@Query() queryParams: GetBookingsInput_2024_08_13,
|
||||
@GetUser() user: User
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const bookings = await this.bookingsService.getBookings(queryParams, user);
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
|
||||
const bookings = await this.bookingsService.getBookings(queryParams, {
|
||||
email: user.email,
|
||||
id: user.id,
|
||||
orgId: profile?.organizationId,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -243,7 +256,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Mark a booking absence - provided authorization header refers to owner of the booking.",
|
||||
summary: "Mark a booking absence",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -264,8 +278,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Automatically reassign booking to a new host automatically - provided authorization header refers to person who reassigned the booking.",
|
||||
summary: "Reassign a booking to auto-selected host",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -285,8 +299,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Reassign a booking to a specific user specified by the :userId - provided authorization header refers to person who reassigned the booking.",
|
||||
summary: "Reassign a booking to a specific host",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async reassignBookingToUser(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -313,8 +327,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm booking that requires a confirmation - provided authorization header refers to owner of the booking.",
|
||||
summary: "Confirm a booking",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async confirmBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@@ -334,8 +348,8 @@ export class BookingsController_2024_08_13 {
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Decline booking that requires a confirmation - provided authorization header refers to owner of the booking.",
|
||||
summary: "Decline a booking",
|
||||
description: "The provided authorization header refers to the owner of the booking.",
|
||||
})
|
||||
async declineBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
|
||||
@@ -122,7 +122,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -150,11 +149,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeDefined();
|
||||
|
||||
const googleCalendarLink = response.body.data.find((item: { id: string }) => item.id === 'googleCalendar').link;
|
||||
const microsoftOfficeLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOffice').link;
|
||||
const microsoftOutlookLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOutlook').link;
|
||||
const icsLink = response.body.data.find((item: { id: string }) => item.id === 'ics').link;
|
||||
|
||||
const googleCalendarLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "googleCalendar"
|
||||
).link;
|
||||
const microsoftOfficeLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "microsoftOffice"
|
||||
).link;
|
||||
const microsoftOutlookLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "microsoftOutlook"
|
||||
).link;
|
||||
const icsLink = response.body.data.find((item: { id: string }) => item.id === "ics").link;
|
||||
|
||||
expect(googleCalendarLink).toMatch(/^https:\/\/calendar\.google\.com\//);
|
||||
expect(microsoftOfficeLink).toMatch(/^https:\/\/outlook\.office\.com\//);
|
||||
|
||||
+70
-4
@@ -301,8 +301,8 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
attendee: {
|
||||
name: thirdManagedUser.user.name!,
|
||||
email: thirdManagedUserEmail,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
language: secondManagedUser.user.locale,
|
||||
timeZone: thirdManagedUser.user.timeZone,
|
||||
language: thirdManagedUser.user.locale,
|
||||
},
|
||||
guests: [secondManagedUserEmail],
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
@@ -418,8 +418,8 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
attendee: {
|
||||
name: thirdManagedUser.user.name!,
|
||||
email: thirdManagedUser.user.email,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
language: secondManagedUser.user.locale,
|
||||
timeZone: thirdManagedUser.user.timeZone,
|
||||
language: thirdManagedUser.user.locale,
|
||||
},
|
||||
guests: [secondManagedUser.user.email],
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
@@ -477,6 +477,72 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch bookings by attendeeEmail", () => {
|
||||
it("should return bookings for the original email when original email is attendee only", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${thirdManagedUserEmail}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const thirdManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const thirdManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
thirdManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(thirdManagedUserBookingsCount).toEqual(2);
|
||||
expect(thirdManagedUserAttendeeBookings.length).toEqual(thirdManagedUserBookingsCount);
|
||||
});
|
||||
|
||||
it("should return bookings for the oAuth email when original oAuth email is attendee only", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${thirdManagedUser.user.email}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const thirdManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const thirdManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
thirdManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(thirdManagedUserBookingsCount).toEqual(2);
|
||||
expect(thirdManagedUserAttendeeBookings.length).toEqual(thirdManagedUserBookingsCount);
|
||||
});
|
||||
|
||||
it("should return bookings for the original email when original email is attendee or guest", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${secondManagedUserEmail}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const secondManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const secondManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
secondManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(secondManagedUserBookingsCount).toEqual(4);
|
||||
expect(secondManagedUserAttendeeBookings.length).toEqual(secondManagedUserBookingsCount);
|
||||
});
|
||||
|
||||
it("should return bookings for the oAuth email when original oAuth email is attendee or guest", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${secondManagedUser.user.email}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const secondManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const secondManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
secondManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(secondManagedUserBookingsCount).toEqual(4);
|
||||
expect(secondManagedUserAttendeeBookings.length).toEqual(secondManagedUserBookingsCount);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
||||
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
||||
|
||||
@@ -591,18 +591,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?teamId=${team2.id}&eventTypeId=90909`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(0);
|
||||
});
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should get bookings by teamIds", async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import { EventType, User } from "@prisma/client";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
@@ -66,6 +66,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let user: User;
|
||||
|
||||
let eventTypeId: number;
|
||||
let eventType: EventType;
|
||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let recurringEventTypeId: number;
|
||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
@@ -128,6 +129,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
user.id
|
||||
);
|
||||
eventTypeId = event.id;
|
||||
eventType = event;
|
||||
|
||||
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||
@@ -586,6 +588,65 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a booking with icsUid", async () => {
|
||||
const mockBooking = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2020, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2020, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `booking-in-the-past-${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",
|
||||
},
|
||||
},
|
||||
rating: 10,
|
||||
iCalUID: "ics-uid",
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${mockBooking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toEqual(mockBooking.id);
|
||||
expect(data.uid).toEqual(mockBooking.uid);
|
||||
expect(data.icsUid).toEqual(mockBooking.iCalUID);
|
||||
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should get 1 recurrence of a recurring booking", async () => {
|
||||
const recurrenceUid = createdRecurringBooking[0].uid;
|
||||
return request(app.getHttpServer())
|
||||
@@ -1604,6 +1665,135 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("book by username and event type slug", () => {
|
||||
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
const body = {
|
||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
guests: ["bob@gmail.com"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(
|
||||
response.body.error.message.includes(
|
||||
"Either eventTypeId OR (eventTypeSlug + username) must be provided"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should create a booking by username and event type slug", async () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||
eventTypeSlug: eventType.slug,
|
||||
username: user.username || "",
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
guests: ["bob@gmail.com"],
|
||||
};
|
||||
|
||||
const beforeCreate = new Date();
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const afterCreate = new Date();
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.hosts[0].username).toEqual(user.username);
|
||||
expect(data.hosts[0].email).toEqual(user.email);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2036, 0, 8, 16, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(eventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
});
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.location).toEqual(body.location);
|
||||
expect(data.meetingUrl).toEqual(body.location);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
expect(data.bookingFieldsResponses).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
...body.bookingFieldsResponses,
|
||||
guests: body.guests,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
});
|
||||
expect(data.guests).toEqual(body.guests);
|
||||
|
||||
// Check createdAt date is between the time of the request and after the request
|
||||
const createdAtDate = new Date(data.createdAt);
|
||||
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
|
||||
// Check updatedAt date is between the time of the request and after the request
|
||||
expect(data.updatedAt).toBeDefined();
|
||||
const updatedAtDate = data.updatedAt ? new Date(data.updatedAt) : null;
|
||||
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
|
||||
expect(data.metadata).toEqual(body.metadata);
|
||||
createdBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("booking location", () => {
|
||||
const address = "123 Main St";
|
||||
const link = "https://cal.com/join/123456";
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
@@ -43,7 +45,7 @@ import {
|
||||
CancelBookingInput,
|
||||
} from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
import { EventType, User, Team } from "@calcom/prisma/client";
|
||||
|
||||
type CreatedBooking = {
|
||||
hosts: { id: number }[];
|
||||
@@ -59,6 +61,8 @@ const eventTypeBookingFieldSchema = z.object({
|
||||
|
||||
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
||||
|
||||
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingsService");
|
||||
@@ -72,32 +76,49 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly billingService: BillingService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput) {
|
||||
try {
|
||||
if ("instant" in body && body.instant) {
|
||||
return await this.createInstantBooking(request, body);
|
||||
const eventType = await this.getBookedEventType(body);
|
||||
if (!eventType) {
|
||||
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
||||
);
|
||||
}
|
||||
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||
}
|
||||
|
||||
body.eventTypeId = eventType.id;
|
||||
|
||||
if ("instant" in body && body.instant) {
|
||||
return await this.createInstantBooking(request, body, eventType);
|
||||
}
|
||||
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
|
||||
const isRecurring = !!eventType?.recurringEvent;
|
||||
const isSeated = !!eventType?.seatsPerTimeSlot;
|
||||
|
||||
await this.hasRequiredBookingFieldsResponses(body, eventType);
|
||||
|
||||
if (isRecurring && isSeated) {
|
||||
return await this.createRecurringSeatedBooking(request, body);
|
||||
return await this.createRecurringSeatedBooking(request, body, eventType);
|
||||
}
|
||||
if (isRecurring && !isSeated) {
|
||||
return await this.createRecurringBooking(request, body);
|
||||
return await this.createRecurringBooking(request, body, eventType);
|
||||
}
|
||||
if (isSeated) {
|
||||
return await this.createSeatedBooking(request, body);
|
||||
return await this.createSeatedBooking(request, body, eventType);
|
||||
}
|
||||
|
||||
return await this.createRegularBooking(request, body);
|
||||
return await this.createRegularBooking(request, body, eventType);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
@@ -108,6 +129,22 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
async getBookedEventType(body: CreateBookingInput) {
|
||||
if (body.eventTypeId) {
|
||||
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
|
||||
} else if (body.username && body.eventTypeSlug) {
|
||||
const user = await this.usersRepository.findByUsername(body.username, body.organizationSlug);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with username ${body.username} not found`);
|
||||
}
|
||||
return await this.eventTypesRepository.getUserEventTypeBySlugWithOwnerAndTeam(
|
||||
user.id,
|
||||
body.eventTypeSlug
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||
const bookingFields = body.bookingFieldsResponses;
|
||||
if (!bookingFields || !eventType || !eventType.bookingFields) {
|
||||
@@ -130,8 +167,12 @@ export class BookingsService_2024_08_13 {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
async createInstantBooking(
|
||||
request: Request,
|
||||
body: CreateInstantBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleInstantMeeting(bookingRequest);
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
|
||||
@@ -144,24 +185,52 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||
const bookings = await handleNewRecurringBooking(bookingRequest);
|
||||
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
||||
const bookings = await handleNewRecurringBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
noEmail: bookingRequest.noEmail,
|
||||
});
|
||||
const ids = bookings.map((booking) => booking.id || 0);
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
}
|
||||
|
||||
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||
const bookings = await handleNewRecurringBooking(bookingRequest);
|
||||
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
||||
const bookings = await handleNewRecurringBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
|
||||
);
|
||||
}
|
||||
|
||||
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
@@ -175,9 +244,18 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
@@ -225,12 +303,23 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
}
|
||||
|
||||
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
|
||||
async getBookings(
|
||||
queryParams: GetBookingsInput_2024_08_13,
|
||||
user: { email: string; id: number; orgId?: number },
|
||||
userIds?: number[]
|
||||
) {
|
||||
if (queryParams.attendeeEmail) {
|
||||
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
|
||||
}
|
||||
|
||||
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
|
||||
bookingListingByStatus: queryParams.status || [],
|
||||
skip: queryParams.skip ?? 0,
|
||||
take: queryParams.take ?? 100,
|
||||
filters: this.inputService.transformGetBookingsFilters(queryParams),
|
||||
filters: {
|
||||
...this.inputService.transformGetBookingsFilters(queryParams),
|
||||
...(userIds?.length ? { userIds } : {}),
|
||||
},
|
||||
ctx: {
|
||||
user,
|
||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
@@ -281,6 +370,35 @@ export class BookingsService_2024_08_13 {
|
||||
return formattedBookings;
|
||||
}
|
||||
|
||||
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
|
||||
// note(Lauris): this is to handle attendees that are managed users - in attendee table their email is one of managed users e.g
|
||||
// urdasdqinm+clxyyy21o0003sbk7yw5z6tzg@example.com but if attendeeEmail is passed as urdasdqinm@example.com then we check if user whose
|
||||
// access token is used is a managed user and if attendee with passed email has managed user email composed of passed email without oAuth client id +
|
||||
// authenticated user oAuth client id.
|
||||
const oAuthClient = await this.oAuthClientRepository.getByUserId(user.id);
|
||||
if (!oAuthClient) {
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
// note(Lauris): query param already contains oAuth client id in the attendeeEmail
|
||||
if (queryParamsAttendeeEmail.includes(oAuthClient.id)) {
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
|
||||
const managedAttendeeEmail = OAuthClientUsersService.getOAuthUserEmail(
|
||||
oAuthClient.id,
|
||||
queryParamsAttendeeEmail
|
||||
);
|
||||
const [attendee, managedAttendee] = await Promise.all([
|
||||
this.usersRepository.findByEmail(queryParamsAttendeeEmail),
|
||||
this.usersRepository.findByEmail(managedAttendeeEmail),
|
||||
]);
|
||||
if (!attendee && managedAttendee) {
|
||||
return managedAttendeeEmail;
|
||||
}
|
||||
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
|
||||
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
|
||||
try {
|
||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||
@@ -288,7 +406,16 @@ export class BookingsService_2024_08_13 {
|
||||
bookingUid,
|
||||
body
|
||||
);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
}
|
||||
@@ -344,7 +471,16 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
|
||||
const res = await handleCancelBooking(bookingRequest);
|
||||
const res = await handleCancelBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
});
|
||||
|
||||
if (!res.onlyRemovedAttendee) {
|
||||
await this.billingService.cancelUsageByBookingUid(res.bookingUid);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import {
|
||||
bookingResponsesSchema,
|
||||
seatedBookingDataSchema,
|
||||
@@ -45,7 +46,10 @@ import {
|
||||
import { BookingInputLocation_2024_08_13 } from "@calcom/platform-types/bookings/2024-08-13/inputs/location.input";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
|
||||
type BookingRequest = NextApiRequest & {
|
||||
userId: number | undefined;
|
||||
noEmail: boolean | undefined;
|
||||
} & OAuthRequestParams;
|
||||
|
||||
type OAuthRequestParams = {
|
||||
platformClientId: string;
|
||||
@@ -92,10 +96,15 @@ export class InputBookingsService_2024_08_13 {
|
||||
|
||||
async createBookingRequest(
|
||||
request: Request,
|
||||
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13
|
||||
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
): Promise<BookingRequest> {
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId);
|
||||
const bodyTransformed = await this.transformInputCreateBooking(body, oAuthClientParams?.platformClientId);
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType);
|
||||
const bodyTransformed = await this.transformInputCreateBooking(
|
||||
body,
|
||||
eventType,
|
||||
oAuthClientParams?.platformClientId
|
||||
);
|
||||
|
||||
const newRequest = { ...request };
|
||||
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||
@@ -121,15 +130,11 @@ export class InputBookingsService_2024_08_13 {
|
||||
return newRequest as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13, platformClientId?: string) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
|
||||
inputBooking.eventTypeId
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
|
||||
}
|
||||
|
||||
async transformInputCreateBooking(
|
||||
inputBooking: CreateBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam,
|
||||
platformClientId?: string
|
||||
) {
|
||||
this.validateBookingLengthInMinutes(inputBooking, eventType);
|
||||
|
||||
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
|
||||
@@ -196,12 +201,14 @@ export class InputBookingsService_2024_08_13 {
|
||||
|
||||
async createRecurringBookingRequest(
|
||||
request: Request,
|
||||
body: CreateRecurringBookingInput_2024_08_13
|
||||
body: CreateRecurringBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
): Promise<BookingRequest> {
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId);
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType);
|
||||
// note(Lauris): update to this.transformInputCreate when rescheduling is implemented
|
||||
const bodyTransformed = await this.transformInputCreateRecurringBooking(
|
||||
body,
|
||||
eventType,
|
||||
oAuthClientParams?.platformClientId
|
||||
);
|
||||
|
||||
@@ -223,7 +230,13 @@ export class InputBookingsService_2024_08_13 {
|
||||
creationSource: CreationSource.API_V2,
|
||||
}));
|
||||
|
||||
return newRequest as unknown as BookingRequest;
|
||||
return {
|
||||
...newRequest,
|
||||
headers: {
|
||||
hostname: request.headers["host"] || "",
|
||||
forcedSlug: request.headers["x-cal-force-slug"] as string | undefined,
|
||||
},
|
||||
} as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
transformLocation(location: string | BookingInputLocation_2024_08_13): {
|
||||
@@ -366,14 +379,9 @@ export class InputBookingsService_2024_08_13 {
|
||||
|
||||
async transformInputCreateRecurringBooking(
|
||||
inputBooking: CreateRecurringBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam,
|
||||
platformClientId?: string
|
||||
) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
|
||||
inputBooking.eventTypeId
|
||||
);
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
|
||||
}
|
||||
if (!eventType.recurringEvent) {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -231,6 +232,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, {
|
||||
@@ -279,6 +281,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -388,6 +391,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
|
||||
|
||||
@@ -59,4 +59,28 @@ export class PlatformBookingsService {
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getOAuthClientParamsForEventType(eventType: {
|
||||
userId: number | null | undefined;
|
||||
teamId: number | null | undefined;
|
||||
}) {
|
||||
let oAuthClient: PlatformOAuthClient | null = null;
|
||||
if (eventType.userId) {
|
||||
oAuthClient = await this.oAuthClientRepository.getByUserId(eventType.userId);
|
||||
} else if (eventType.teamId) {
|
||||
oAuthClient = await this.oAuthClientRepository.getByTeamId(eventType.teamId);
|
||||
}
|
||||
|
||||
if (oAuthClient) {
|
||||
return {
|
||||
platformClientId: oAuthClient.id,
|
||||
platformCancelUrl: oAuthClient.bookingCancelRedirectUri,
|
||||
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
|
||||
platformBookingUrl: oAuthClient.bookingRedirectUri,
|
||||
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
const appleCalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
@@ -94,7 +94,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
|
||||
await this.credentialRepository.upsertAppCredential(
|
||||
await this.credentialRepository.upsertUserAppCredential(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
|
||||
userId,
|
||||
@@ -126,7 +126,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
user: { email: userEmail },
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
await this.credentialRepository.upsertUserAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
} catch (reason) {
|
||||
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export class CalendarsService {
|
||||
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
const credential = await this.credentialsRepository.upsertAppCredential(
|
||||
const credential = await this.credentialsRepository.upsertUserAppCredential(
|
||||
calendarType,
|
||||
key,
|
||||
userId,
|
||||
|
||||
@@ -84,7 +84,10 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"google_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
|
||||
@@ -52,7 +52,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
);
|
||||
}
|
||||
|
||||
const credential = await this.credentialRepository.upsertAppCredential(
|
||||
const credential = await this.credentialRepository.upsertUserAppCredential(
|
||||
ICS_CALENDAR_TYPE,
|
||||
data.key,
|
||||
userId
|
||||
@@ -75,7 +75,10 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId);
|
||||
const icsFeedCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
ICS_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!icsFeedCredentials) {
|
||||
throw new BadRequestException("Credentials for Ics Feed calendar not found.");
|
||||
|
||||
@@ -71,7 +71,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
const office365CalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"office365_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
+9
-3
@@ -10,6 +10,7 @@ import {
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
Min,
|
||||
Max,
|
||||
IsInt,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
IsNumber,
|
||||
} from "class-validator";
|
||||
|
||||
import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/platform-constants";
|
||||
|
||||
// note(Lauris): We will gradually expose more properties if any customer needs them.
|
||||
// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput.
|
||||
|
||||
@@ -414,9 +417,12 @@ export class UpdateEventTypeInput_2024_04_15 {
|
||||
@ApiPropertyOptional()
|
||||
afterEventBuffer?: number;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// seatsPerTimeSlot?: number;
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_SEATS_PER_TIME_SLOT)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
seatsPerTimeSlot?: number;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
|
||||
+38
@@ -377,6 +377,27 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with integration not installed on user", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class",
|
||||
slug: "coding-class",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "zoom",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create an event type", async () => {
|
||||
const nameBookingField: NameDefaultFieldInput_2024_06_14 = {
|
||||
type: "name",
|
||||
@@ -1090,6 +1111,23 @@ describe("Event types Endpoints", () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not allow to update event type with integration not installed on user", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "office365-video",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ export class EventTypesController_2024_06_14 {
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<CreateEventTypeOutput_2024_06_14> {
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput(
|
||||
user.id,
|
||||
user,
|
||||
body
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ export class EventTypesController_2024_06_14 {
|
||||
): Promise<UpdateEventTypeOutput_2024_06_14> {
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput(
|
||||
body,
|
||||
user.id,
|
||||
user,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -18,7 +19,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
|
||||
providers: [
|
||||
EventTypesRepository_2024_06_14,
|
||||
EventTypesService_2024_06_14,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -110,6 +111,18 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeBySlugWithOwnerAndTeam(userId: number, slug: string) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: {
|
||||
userId_slug: {
|
||||
userId: userId,
|
||||
slug: slug,
|
||||
},
|
||||
},
|
||||
include: { owner: true, team: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEventType(eventTypeId: number) {
|
||||
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
|
||||
}
|
||||
|
||||
+28
-7
@@ -1,6 +1,7 @@
|
||||
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -49,13 +50,15 @@ interface ValidationContext {
|
||||
export class InputEventTypesService_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly calendarsService: CalendarsService
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly conferencingService: ConferencingService
|
||||
) {}
|
||||
|
||||
async transformAndValidateCreateEventTypeInput(
|
||||
userId: UserWithProfile["id"],
|
||||
user: UserWithProfile,
|
||||
inputEventType: CreateEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(user, inputEventType.locations);
|
||||
const transformedBody = this.transformInputCreateEventType(inputEventType);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
@@ -66,19 +69,21 @@ export class InputEventTypesService_2024_06_14 {
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
|
||||
async transformAndValidateUpdateEventTypeInput(
|
||||
inputEventType: UpdateEventTypeInput_2024_06_14,
|
||||
userId: UserWithProfile["id"],
|
||||
user: UserWithProfile,
|
||||
eventTypeId: number
|
||||
) {
|
||||
await this.validateInputLocations(user, inputEventType.locations);
|
||||
|
||||
const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
@@ -90,10 +95,10 @@ export class InputEventTypesService_2024_06_14 {
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -448,4 +453,20 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async validateInputLocations(
|
||||
user: UserWithProfile,
|
||||
inputLocations: CreateEventTypeInput_2024_06_14["locations"] | undefined
|
||||
) {
|
||||
await Promise.all(
|
||||
inputLocations?.map(async (location) => {
|
||||
if (location.type === "integration") {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (location.integration !== "cal-video") {
|
||||
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,10 @@ export class GcalController {
|
||||
@Permissions([APPS_READ])
|
||||
@ApiOperation({ summary: "Check a calendar connection status" })
|
||||
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"google_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
|
||||
@@ -36,7 +36,8 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
|
||||
@@ -14,8 +14,8 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const statusCode = exception.getStatus();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
|
||||
@@ -40,7 +40,8 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`PrismaError: ${error.message}`, {
|
||||
error,
|
||||
|
||||
@@ -87,7 +87,8 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
break;
|
||||
}
|
||||
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
|
||||
@@ -15,7 +15,8 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`ZodError: ${error.message}`, {
|
||||
error,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Injectable, Logger as NestLogger, Scope } from "@nestjs/common";
|
||||
|
||||
// 1. Define an interface for the settings
|
||||
interface IMyLoggerSettings {
|
||||
minLevel: number; // Example: 0=debug, 1=info, 2=warn, 3=error
|
||||
displayTimestamp: boolean;
|
||||
logFormat: "pretty" | "json" | "simple";
|
||||
// Add any other settings you need, mimicking tslog or your own requirements
|
||||
// e.g., name?: string; displayFunctionName?: boolean; etc.
|
||||
}
|
||||
|
||||
// Define log level constants (optional but recommended for readability)
|
||||
const LogLevel = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* This logger acts as a bridge between Nest.js Logger and log calls originating
|
||||
* from platform libraries. It forwards logs to NestLogger, allowing centralization
|
||||
* (e.g., sending to Axiom) and adds an optional prefix for context.
|
||||
*/
|
||||
@Injectable({ scope: Scope.TRANSIENT }) // TRANSIENT ensures getSubLogger provides truly independent instances if needed elsewhere
|
||||
export class Logger {
|
||||
// Use NestLogger for the actual logging output
|
||||
private readonly nestLogger = new NestLogger("LoggerBridge");
|
||||
// Prefix to add to messages for this instance
|
||||
private prefix = "";
|
||||
|
||||
// Add a public `settings` property, typed with the interface
|
||||
public settings: IMyLoggerSettings;
|
||||
|
||||
// Define default settings
|
||||
private static readonly defaultSettings: IMyLoggerSettings = {
|
||||
minLevel: process?.env?.LOGGER_BRIDGE_LOG_LEVEL
|
||||
? Number(process.env.LOGGER_BRIDGE_LOG_LEVEL)
|
||||
: LogLevel.INFO, // Default to INFO level
|
||||
displayTimestamp: true,
|
||||
logFormat: "pretty",
|
||||
};
|
||||
|
||||
constructor(userSettings?: Partial<IMyLoggerSettings>) {
|
||||
// Merge default settings with user-provided settings
|
||||
// User settings override defaults
|
||||
this.settings = {
|
||||
...Logger.defaultSettings,
|
||||
...userSettings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new LoggerBridge instance with a specific prefix.
|
||||
* Useful for providing context from different parts of a library.
|
||||
* @param options - Configuration for the sub-logger.
|
||||
* @param options.prefix - An array of strings to join as the log prefix.
|
||||
* @returns A new LoggerBridge instance with the specified prefix.
|
||||
*/
|
||||
getSubLogger(options: { prefix?: string[] }): Logger {
|
||||
const newLogger = new Logger();
|
||||
// Set the prefix for the *new* instance
|
||||
newLogger.prefix = options?.prefix?.join(" ") ?? "";
|
||||
return newLogger;
|
||||
}
|
||||
|
||||
// --- Public logging methods ---
|
||||
|
||||
info(...args: any[]) {
|
||||
this.settings.minLevel <= 1 && this.logInternal("log", ...args);
|
||||
}
|
||||
|
||||
warn(...args: any[]) {
|
||||
this.settings.minLevel <= 2 && this.logInternal("warn", ...args);
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
this.settings.minLevel <= 3 && this.logInternal("error", ...args);
|
||||
}
|
||||
|
||||
debug(...args: any[]) {
|
||||
this.settings.minLevel === 0 && this.logInternal("debug", ...args);
|
||||
}
|
||||
|
||||
trace(...args: any[]) {
|
||||
this.settings.minLevel === 0 && this.logInternal("verbose", ...args);
|
||||
}
|
||||
|
||||
fatal(...args: any[]) {
|
||||
// Prepend FATAL: to the message and log as error
|
||||
const fatalMessage = `fatal: ${this.formatArgsAsString(args)}`;
|
||||
this.settings.minLevel <= 3 && this.logInternal("error", fatalMessage);
|
||||
}
|
||||
|
||||
silly(...args: any[]) {
|
||||
const sillyMessage = `silly: ${this.formatArgsAsString(args)}`;
|
||||
this.settings.minLevel === 0 && this.logInternal("verbose", sillyMessage);
|
||||
}
|
||||
|
||||
// --- Internal logging implementation ---
|
||||
|
||||
/** Formats arguments into a single string */
|
||||
private formatArgsAsString(args: any[]): string {
|
||||
return args
|
||||
.map((arg) => {
|
||||
if (typeof arg === "string") {
|
||||
return arg;
|
||||
}
|
||||
// Attempt to stringify non-string arguments
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
return "[Unserializable Object]";
|
||||
}
|
||||
})
|
||||
.join(" "); // Use space as separator, adjust if needed
|
||||
}
|
||||
|
||||
/** Central method to forward logs to NestLogger */
|
||||
private logInternal(level: "log" | "warn" | "error" | "debug" | "verbose", ...args: any[]) {
|
||||
try {
|
||||
// Format message from potentially multiple arguments
|
||||
const formattedMessage = this.formatArgsAsString(args);
|
||||
|
||||
// Prepend the prefix if it exists
|
||||
const message = this.prefix ? `${this.prefix} ${formattedMessage}` : formattedMessage;
|
||||
|
||||
// Call the corresponding NestLogger method
|
||||
switch (level) {
|
||||
case "log":
|
||||
this.nestLogger.log(message);
|
||||
break;
|
||||
case "warn":
|
||||
this.nestLogger.warn(message);
|
||||
break;
|
||||
case "error":
|
||||
this.nestLogger.error(message);
|
||||
break;
|
||||
case "debug":
|
||||
this.nestLogger.debug(message);
|
||||
break;
|
||||
case "verbose":
|
||||
this.nestLogger.verbose(message);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
this.nestLogger.error(
|
||||
`Could not bridge log message. Error: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,13 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
const { context } = requestProps;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const tracker = await this.getTracker(request);
|
||||
this.logger.verbose(
|
||||
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
|
||||
"Authorization"
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${request.ip}"`
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||
);
|
||||
|
||||
if (tracker.startsWith("api_key_")) {
|
||||
@@ -209,6 +210,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
protected async getTracker(request: Request): Promise<string> {
|
||||
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
|
||||
if (authorizationHeader) {
|
||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||
@@ -223,8 +225,8 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
return `oauth_client_${oauthClientId}`;
|
||||
}
|
||||
|
||||
if (request.ip) {
|
||||
return `ip_${request.ip}`;
|
||||
if (IP) {
|
||||
return `ip_${IP}`;
|
||||
}
|
||||
|
||||
this.logger.verbose(`no tracker found: ${request.url}`);
|
||||
|
||||
@@ -31,6 +31,15 @@ export class AppsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTeamAppCredentials(credentialIdsToDelete: number[], teamId: number) {
|
||||
return this.dbWrite.prisma.credential.deleteMany({
|
||||
where: {
|
||||
id: { in: credentialIdsToDelete },
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
|
||||
return this.dbWrite.prisma.credential.create({
|
||||
data: {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_0
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -18,12 +20,14 @@ import { Module } from "@nestjs/common";
|
||||
providers: [
|
||||
EventTypesAtomService,
|
||||
ConferencingAtomsService,
|
||||
AttributesAtomsService,
|
||||
MembershipsRepository,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
AtomsRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
RedisService,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
controllers: [AtomsController],
|
||||
|
||||
@@ -3,10 +3,19 @@ import {
|
||||
BulkUpdateEventTypeToDefaultLocationDto,
|
||||
EventTypesAppInput,
|
||||
} from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import { FindTeamMembersMatchingAttributeQueryDto } from "@/modules/atoms/inputs/find-team-members-matching-attribute.input";
|
||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -27,6 +36,8 @@ import { ConnectedApps } from "@calcom/platform-libraries/app-store";
|
||||
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
import { FindTeamMembersMatchingAttributeResponseDto } from "../outputs/find-team-members-matching-attribute.output";
|
||||
|
||||
/*
|
||||
|
||||
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
|
||||
@@ -43,7 +54,8 @@ these endpoints should not be recommended for use by third party and are exclude
|
||||
export class AtomsController {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesAtomService,
|
||||
private readonly conferencingService: ConferencingAtomsService
|
||||
private readonly conferencingService: ConferencingAtomsService,
|
||||
private readonly attributesService: AttributesAtomsService
|
||||
) {}
|
||||
|
||||
@Get("event-types/:eventTypeId")
|
||||
@@ -60,14 +72,27 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/organizations/:orgId/teams/:teamId/event-types")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/event-types")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventTypes(userId);
|
||||
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +141,22 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async bulkUpdateAtomTeamEventTypes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
|
||||
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -135,9 +176,11 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async updateAtomTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@@ -155,13 +198,54 @@ export class AtomsController {
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
@Get("/organizations/:orgId/teams/:teamId/conferencing")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/conferencing")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(user);
|
||||
async listUserInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
@Get("/organizations/:orgId/teams/:teamId/members-matching-attribute")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async findTeamMembersMatchingAttributes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: FindTeamMembersMatchingAttributeQueryDto
|
||||
): Promise<FindTeamMembersMatchingAttributeResponseDto> {
|
||||
const result = await this.attributesService.findTeamMembersMatchingAttribute(teamId, orgId, {
|
||||
attributesQueryValue: query.attributesQueryValue,
|
||||
isPreview: query.isPreview,
|
||||
enablePerf: query.enablePerf,
|
||||
concurrency: query.concurrency,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: conferencingApps };
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/platform-libraries";
|
||||
|
||||
export class FindTeamMembersMatchingAttributeQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
})
|
||||
attributesQueryValue: TFindTeamMembersMatchingAttributeLogicInputSchema["attributesQueryValue"];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
})
|
||||
isPreview?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
})
|
||||
enablePerf?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
})
|
||||
concurrency?: number;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { FindTeamMembersMatchingAttributeOutputDto } from "@calcom/platform-types";
|
||||
|
||||
export class FindTeamMembersMatchingAttributeResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => FindTeamMembersMatchingAttributeOutputDto)
|
||||
@Expose()
|
||||
@ApiProperty({ type: FindTeamMembersMatchingAttributeOutputDto })
|
||||
readonly data!: FindTeamMembersMatchingAttributeOutputDto;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { findTeamMembersMatchingAttributeLogic } from "@calcom/platform-libraries";
|
||||
|
||||
import { FindTeamMembersMatchingAttributeQueryDto } from "../inputs/find-team-members-matching-attribute.input";
|
||||
|
||||
@Injectable()
|
||||
export class AttributesAtomsService {
|
||||
private logger = new Logger("AttributesAtomService");
|
||||
|
||||
constructor(private readonly usersRepository: UsersRepository) {}
|
||||
|
||||
async findTeamMembersMatchingAttribute(
|
||||
teamId: number,
|
||||
orgId: number,
|
||||
input: FindTeamMembersMatchingAttributeQueryDto
|
||||
) {
|
||||
const {
|
||||
teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult,
|
||||
mainAttributeLogicBuildingWarnings: mainWarnings,
|
||||
fallbackAttributeLogicBuildingWarnings: fallbackWarnings,
|
||||
troubleshooter,
|
||||
} = await findTeamMembersMatchingAttributeLogic(
|
||||
{
|
||||
teamId,
|
||||
orgId,
|
||||
attributesQueryValue: input.attributesQueryValue,
|
||||
},
|
||||
{
|
||||
enablePerf: input.enablePerf,
|
||||
concurrency: input.concurrency,
|
||||
enableTroubleshooter: input.enablePerf,
|
||||
}
|
||||
);
|
||||
|
||||
if (!matchingTeamMembersWithResult) {
|
||||
return {
|
||||
troubleshooter,
|
||||
mainWarnings,
|
||||
fallbackWarnings,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
const matchingTeamMembersIds = matchingTeamMembersWithResult.map(
|
||||
(member: { userId: number }) => member.userId
|
||||
);
|
||||
|
||||
const matchingTeamMembers = await this.usersRepository.findByIds(matchingTeamMembersIds);
|
||||
|
||||
return {
|
||||
mainWarnings,
|
||||
fallbackWarnings,
|
||||
troubleshooter: troubleshooter,
|
||||
result: matchingTeamMembers.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class ConferencingAtomsService {
|
||||
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
|
||||
async getUserConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
|
||||
return getConnectedApps({
|
||||
user,
|
||||
input: {
|
||||
@@ -22,4 +22,17 @@ export class ConferencingAtomsService {
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamConferencingApps(user: UserWithProfile, teamId: number): Promise<ConnectedApps> {
|
||||
return getConnectedApps({
|
||||
user,
|
||||
input: {
|
||||
variant: "conferencing",
|
||||
onlyInstalled: true,
|
||||
teamId,
|
||||
includeTeamInstalledApps: true,
|
||||
},
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ import type {
|
||||
LocationOption,
|
||||
} from "@calcom/platform-libraries/app-store";
|
||||
import {
|
||||
getBulkEventTypes,
|
||||
getEventTypeById,
|
||||
bulkUpdateEventsToDefaultLocation,
|
||||
bulkUpdateTeamEventsToDefaultLocation,
|
||||
getBulkUserEventTypes,
|
||||
getBulkTeamEventTypes,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
updateEventType,
|
||||
@@ -83,7 +85,11 @@ export class EventTypesAtomService {
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
return getBulkEventTypes(userId);
|
||||
return getBulkUserEventTypes(userId);
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number) {
|
||||
return getBulkTeamEventTypes(teamId);
|
||||
}
|
||||
|
||||
async updateTeamEventType(
|
||||
@@ -345,4 +351,12 @@ export class EventTypesAtomService {
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateTeamEventTypesDefaultLocation(eventTypeIds: number[], teamId: number) {
|
||||
return bulkUpdateTeamEventsToDefaultLocation({
|
||||
eventTypeIds,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
@Module({
|
||||
@@ -23,8 +29,28 @@ import { PassportModule } from "@nestjs/passport";
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
forwardRef(() => OrganizationsModule),
|
||||
],
|
||||
providers: [
|
||||
NextAuthGuard,
|
||||
NextAuthStrategy,
|
||||
ApiAuthGuard,
|
||||
ApiAuthStrategy,
|
||||
OAuthFlowService,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
exports: [
|
||||
NextAuthGuard,
|
||||
ApiAuthGuard,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -21,7 +21,21 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||
const { canAccess } = await this.checkPlatformPlanAccess({ teamId, orgId, user, minimumPlan });
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkPlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
}: {
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
user: ApiAuthGuardUser;
|
||||
minimumPlan: PlatformPlanType;
|
||||
}): Promise<{ canAccess: boolean }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||
teamId ?? "none"
|
||||
}:guard:platformbilling:${minimumPlan}`;
|
||||
@@ -29,7 +43,7 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
return { canAccess: cachedAccess };
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
@@ -56,7 +70,7 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
if (canAccess) {
|
||||
return canAccess;
|
||||
return { canAccess };
|
||||
}
|
||||
throw new ForbiddenException(
|
||||
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
|
||||
|
||||
@@ -18,22 +18,34 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { organization?: Team }>();
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
||||
if (organization) {
|
||||
request.organization = organization;
|
||||
}
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkAdminAPIEnabled(
|
||||
organizationId: string
|
||||
): Promise<{ canAccess: boolean; organization?: Team | null }> {
|
||||
let canAccess = false;
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
return {
|
||||
canAccess: cachedCanAccess,
|
||||
organization: cachedOrg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +69,6 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
"EX",
|
||||
300
|
||||
));
|
||||
return canAccess;
|
||||
return { canAccess, organization: org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export class IsOrgGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
@@ -26,33 +25,45 @@ export class IsOrgGuard implements CanActivate {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, org } = await this.checkOrgAccess(organizationId);
|
||||
|
||||
if (canAccess && org) {
|
||||
request.organization = org;
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
|
||||
let canAccess = false;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
return {
|
||||
org: cachedOrg,
|
||||
canAccess: cachedCanAccess,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
|
||||
if (org?.isOrganization) {
|
||||
request.organization = org;
|
||||
canAccess = true;
|
||||
}
|
||||
|
||||
if (org) {
|
||||
await this.redisService.redis.set(
|
||||
REDIS_CACHE_KEY,
|
||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||
JSON.stringify({ org, canAccess } satisfies CachedData),
|
||||
"EX",
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
return { canAccess, org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,22 @@ export class RolesGuard implements CanActivate {
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
||||
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
|
||||
return canAccess;
|
||||
}
|
||||
async checkUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
teamId: string,
|
||||
allowedRole: string
|
||||
): Promise<{ canAccess: boolean }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||
teamId ?? "none"
|
||||
}:guard:roles:${allowedRole}`;
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
return { canAccess: cachedAccess };
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
@@ -130,7 +139,7 @@ export class RolesGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
return canAccess;
|
||||
return { canAccess };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export class IsTeamInOrg implements CanActivate {
|
||||
constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { team?: Team }>();
|
||||
const teamId: string = request.params.teamId;
|
||||
const orgId: string = request.params.orgId;
|
||||
|
||||
@@ -27,17 +27,25 @@ export class IsTeamInOrg implements CanActivate {
|
||||
throw new ForbiddenException("No team id found in request params.");
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
|
||||
|
||||
if (team && !team.isOrganization && team.parentId === Number(orgId)) {
|
||||
if (canAccess && team) {
|
||||
request.team = team;
|
||||
return true;
|
||||
}
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
|
||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team (${teamId}) not found.`);
|
||||
}
|
||||
|
||||
return false;
|
||||
if (!team.isOrganization && team.parentId === Number(orgId)) {
|
||||
return { canAccess: true, team };
|
||||
}
|
||||
|
||||
return { canAccess: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule],
|
||||
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
@@ -25,7 +26,13 @@ import { ConfigModule } from "@nestjs/config";
|
||||
Office365VideoService,
|
||||
AppsRepository,
|
||||
],
|
||||
exports: [],
|
||||
exports: [
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
GoogleMeetService,
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
],
|
||||
controllers: [ConferencingController],
|
||||
})
|
||||
export class ConferencingModule {}
|
||||
|
||||
@@ -15,18 +15,16 @@ import {
|
||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
UseGuards,
|
||||
Post,
|
||||
Param,
|
||||
@@ -46,7 +44,8 @@ import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: number;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
@@ -63,9 +62,7 @@ export class ConferencingController {
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
|
||||
@Post("/:app/connect")
|
||||
@@ -80,21 +77,11 @@ export class ConferencingController {
|
||||
required: true,
|
||||
})
|
||||
async connect(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("app") app: string
|
||||
): Promise<ConferencingAppOutputResponseDto> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
const credential = await this.googleMeetService.connectGoogleMeetApp(userId);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[GOOGLE_MEET].join(", ")
|
||||
);
|
||||
}
|
||||
const credential = await this.conferencingService.connectUserNonOauthApp(app, user.id);
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/auth-url")
|
||||
@@ -115,7 +102,6 @@ export class ConferencingController {
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<GetConferencingAppsOauthUrlResponseDto> {
|
||||
let credential;
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
@@ -126,27 +112,12 @@ export class ConferencingController {
|
||||
accessToken,
|
||||
};
|
||||
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
const credential = await this.conferencingService.generateOAuthUrl(app, state);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
credential = await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/callback")
|
||||
@@ -176,23 +147,18 @@ export class ConferencingController {
|
||||
if (error) {
|
||||
throw new BadRequestException(error_description);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.connectOffice365App(decodedCallbackState, code, userId);
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
if (decodedCallbackState.orgId) {
|
||||
return this.organizationsConferencingService.connectTeamOauthApps({
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
decodedCallbackState,
|
||||
});
|
||||
} else {
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException || error instanceof Error) {
|
||||
@@ -210,15 +176,13 @@ export class ConferencingController {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "List your conferencing applications" })
|
||||
async listInstalledConferencingApps(
|
||||
@GetUser("id") userId: number
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ConferencingAppsOutputResponseDto> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(userId);
|
||||
|
||||
const data = conferencingApps.map((conferencingApps) =>
|
||||
plainToInstance(ConferencingAppsOutputDto, conferencingApps)
|
||||
);
|
||||
|
||||
return { status: SUCCESS_STATUS, data };
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(user.id);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)),
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:app/default")
|
||||
@@ -245,8 +209,8 @@ export class ConferencingController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get your default conferencing application" })
|
||||
async getDefault(@GetUser("id") userId: number): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId);
|
||||
async getDefault(@GetUser() user: UserWithProfile): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(user.id);
|
||||
return { status: SUCCESS_STATUS, data: defaultconferencingApp };
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,15 @@ export class ConferencingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApps(teamId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findGoogleMeet(userId: number) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, type: GOOGLE_MEET_TYPE },
|
||||
@@ -28,4 +37,10 @@ export class ConferencingRepository {
|
||||
where: { userId, appId: app },
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApp(teamId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { teamId, appId: app },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
import {
|
||||
CONFERENCING_APPS,
|
||||
CAL_VIDEO,
|
||||
GOOGLE_MEET,
|
||||
ZOOM,
|
||||
OFFICE_365_VIDEO,
|
||||
} from "@calcom/platform-constants";
|
||||
import { userMetadata, getUsersCredentials } from "@calcom/platform-libraries";
|
||||
import { getApps, handleDeleteCredential } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@@ -14,13 +24,53 @@ export class ConferencingService {
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
) {}
|
||||
|
||||
async getConferencingApps(userId: number) {
|
||||
return this.conferencingRepository.findConferencingApps(userId);
|
||||
}
|
||||
|
||||
async connectUserNonOauthApp(app: string, userId: number) {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
const credential = await this.googleMeetService.connectGoogleMeetToUser(userId);
|
||||
return credential;
|
||||
default:
|
||||
throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectOauthApps(
|
||||
app: string,
|
||||
code: string,
|
||||
userId: number,
|
||||
decodedCallbackState: OAuthCallbackState,
|
||||
teamId?: number
|
||||
) {
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.connectOffice365App(
|
||||
decodedCallbackState,
|
||||
code,
|
||||
userId,
|
||||
teamId
|
||||
);
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDefaultConferencingApp(userId: number) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
return userMetadata.parse(user?.metadata)?.defaultConferencingApp;
|
||||
@@ -63,4 +113,20 @@ export class ConferencingService {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async generateOAuthUrl(app: string, state: OAuthCallbackState) {
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE, GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleMeetService {
|
||||
@@ -12,35 +11,51 @@ export class GoogleMeetService {
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async connectGoogleMeetApp(userId: number) {
|
||||
const googleCalendar = await this.credentialsRepository.getByTypeAndUserId(GOOGLE_CALENDAR_TYPE, userId);
|
||||
async connectGoogleMeetToUser(userId: number) {
|
||||
await this.validateGoogleCalendarConnection(userId, "user");
|
||||
|
||||
const googleMeetExists = await this.conferencingRepository.findGoogleMeet(userId);
|
||||
if (googleMeetExists) {
|
||||
throw new BadRequestException("Google Meet is already connected for this user.");
|
||||
}
|
||||
|
||||
return this.credentialsRepository.upsertUserAppCredential(GOOGLE_MEET_TYPE, {}, userId);
|
||||
}
|
||||
|
||||
async connectGoogleMeetToTeam(teamId: number) {
|
||||
await this.validateGoogleCalendarConnection(teamId, "team");
|
||||
|
||||
const googleMeetExists = await this.credentialsRepository.findCredentialByTypeAndTeamId(
|
||||
GOOGLE_MEET_TYPE,
|
||||
teamId
|
||||
);
|
||||
if (googleMeetExists) {
|
||||
throw new BadRequestException("Google Meet is already connected for this team.");
|
||||
}
|
||||
|
||||
return this.credentialsRepository.upsertTeamAppCredential(GOOGLE_MEET_TYPE, {}, teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that Google Calendar is connected and valid for either a user or a team.
|
||||
*/
|
||||
private async validateGoogleCalendarConnection(id: number, entity: "user" | "team") {
|
||||
const googleCalendar =
|
||||
entity === "user"
|
||||
? await this.credentialsRepository.findCredentialByTypeAndUserId(GOOGLE_CALENDAR_TYPE, id)
|
||||
: await this.credentialsRepository.findCredentialByTypeAndTeamId(GOOGLE_CALENDAR_TYPE, id);
|
||||
|
||||
if (!googleCalendar) {
|
||||
throw new BadRequestException("Google Meet app requires a Google Calendar connection");
|
||||
throw new BadRequestException("Google Meet requires a Google Calendar connection.");
|
||||
}
|
||||
|
||||
if (googleCalendar.invalid) {
|
||||
throw new BadRequestException(
|
||||
"Google Meet app requires a valid Google Calendar connection, please reconnect Google Calendar."
|
||||
"Google Meet requires a valid Google Calendar connection. Please reconnect Google Calendar."
|
||||
);
|
||||
}
|
||||
|
||||
const googleMeet = await this.conferencingRepository.findGoogleMeet(userId);
|
||||
|
||||
if (googleMeet) {
|
||||
throw new BadRequestException("Google Meet is already connected.");
|
||||
}
|
||||
|
||||
const googleMeetCredential = await this.credentialsRepository.upsertAppCredential(
|
||||
GOOGLE_MEET_TYPE,
|
||||
{},
|
||||
userId
|
||||
);
|
||||
|
||||
return googleMeetCredential;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -21,7 +22,11 @@ export class Office365VideoService {
|
||||
private redirectUri = `${this.config.get("api.url")}/conferencing/${OFFICE_365_VIDEO}/oauth/callback`;
|
||||
private scopes = ["OnlineMeetings.ReadWrite", "offline_access"];
|
||||
|
||||
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async getOffice365AppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO);
|
||||
@@ -56,7 +61,7 @@ export class Office365VideoService {
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) {
|
||||
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number, teamId?: number) {
|
||||
const { client_id, client_secret } = await this.getOffice365AppKeys();
|
||||
|
||||
const toUrlEncoded = (payload: Record<string, string>) =>
|
||||
@@ -98,23 +103,30 @@ export class Office365VideoService {
|
||||
responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds
|
||||
delete responseBody.expires_in;
|
||||
|
||||
const existingCredentialOffice365Video = await this.appsRepository.findAppCredential({
|
||||
type: OFFICE_365_VIDEO_TYPE,
|
||||
userId,
|
||||
appId: OFFICE_365_VIDEO,
|
||||
});
|
||||
const existingCredentialOffice365Video = teamId
|
||||
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(OFFICE_365_VIDEO_TYPE, teamId)
|
||||
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(OFFICE_365_VIDEO_TYPE, userId);
|
||||
|
||||
const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
teamId
|
||||
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
|
||||
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
OFFICE_365_VIDEO
|
||||
);
|
||||
teamId
|
||||
? await this.appsRepository.createTeamAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
OFFICE_365_VIDEO
|
||||
)
|
||||
: await this.appsRepository.createAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
OFFICE_365_VIDEO
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -20,7 +21,11 @@ export class ZoomVideoService {
|
||||
private logger = new Logger("ZoomVideoService");
|
||||
private redirectUri = `${this.config.get("api.url")}/conferencing/${ZOOM}/oauth/callback`;
|
||||
|
||||
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async getZoomAppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(ZOOM);
|
||||
@@ -53,7 +58,7 @@ export class ZoomVideoService {
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number) {
|
||||
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number, teamId?: number) {
|
||||
const { client_id, client_secret } = await this.getZoomAppKeys();
|
||||
const redirectUri = encodeURI(this.redirectUri);
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
@@ -90,23 +95,31 @@ export class ZoomVideoService {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({
|
||||
type: ZOOM_TYPE,
|
||||
userId,
|
||||
appId: ZOOM,
|
||||
});
|
||||
|
||||
const existingCredentialZoomVideo = teamId
|
||||
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(ZOOM_TYPE, teamId)
|
||||
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(ZOOM_TYPE, userId);
|
||||
|
||||
const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
teamId
|
||||
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
|
||||
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
ZOOM
|
||||
);
|
||||
teamId
|
||||
? await this.appsRepository.createTeamAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
ZOOM
|
||||
)
|
||||
: await this.appsRepository.createAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
ZOOM
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||
export class CredentialsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async upsertAppCredential(
|
||||
async upsertUserAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
userId: number,
|
||||
@@ -33,14 +33,45 @@ export class CredentialsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
getByTypeAndUserId(type: string, userId: number) {
|
||||
async upsertTeamAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
teamId: number,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
return this.dbWrite.prisma.credential.upsert({
|
||||
create: {
|
||||
type,
|
||||
key,
|
||||
teamId,
|
||||
appId: APPS_TYPE_ID_MAPPING[type],
|
||||
},
|
||||
update: {
|
||||
key,
|
||||
invalid: false,
|
||||
},
|
||||
where: {
|
||||
id: credentialId ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findCredentialByTypeAndUserId(type: string, userId: number) {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
|
||||
}
|
||||
|
||||
getByTypeAndTeamId(type: string, teamId: number) {
|
||||
findAllCredentialsByTypeAndUserId(type: string, userId: number) {
|
||||
return this.dbWrite.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
findCredentialByTypeAndTeamId(type: string, teamId: number) {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
|
||||
}
|
||||
|
||||
findAllCredentialsByTypeAndTeamId(type: string, teamId: number) {
|
||||
return this.dbWrite.prisma.credential.findMany({ where: { type, teamId } });
|
||||
}
|
||||
|
||||
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { OrganizationsBookingsModule } from "@/modules/organizations/bookings/organizations.bookings.module";
|
||||
import { OrganizationsRoutingFormsModule } from "@/modules/organizations/routing-forms/organizations-routing-forms.module";
|
||||
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
|
||||
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
|
||||
import { RouterModule } from "@/modules/router/router.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||
@@ -27,6 +30,9 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
StripeModule,
|
||||
ConferencingModule,
|
||||
OrganizationsTeamsBookingsModule,
|
||||
OrganizationsUsersBookingsModule,
|
||||
OrganizationsBookingsModule,
|
||||
OrganizationsRoutingFormsModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -26,6 +26,10 @@ export class JwtService {
|
||||
// divided by 1000 because iat (issued at time) is in seconds (not milliseconds) as informed by JWT specification
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
decode(token: string): Payload {
|
||||
return this.nestJwtService.decode(token) as Payload;
|
||||
}
|
||||
}
|
||||
|
||||
type Payload = Record<string | number | symbol, any>;
|
||||
|
||||
+140
-6
@@ -7,11 +7,13 @@ import { Locales } from "@/lib/enums/locales";
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client";
|
||||
@@ -206,6 +208,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
locale: Locales.FR,
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
bio: "I am a bio",
|
||||
metadata: {
|
||||
key: "value",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -229,11 +235,33 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.user.bio).toEqual(requestBody.bio);
|
||||
expect(responseBody.data.user.metadata).toEqual(requestBody.metadata);
|
||||
const [emailUser, emailDomain] = responseBody.data.user.email.split("@");
|
||||
const [domainName, TLD] = emailDomain.split(".");
|
||||
expect(responseBody.data.user.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`));
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBeDefined();
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBeDefined();
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 1);
|
||||
await userHasDefaultEventTypes(responseBody.data.user.id);
|
||||
@@ -250,6 +278,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
locale: Locales.FR,
|
||||
name: "Bob Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
bio: "I am a bio",
|
||||
metadata: {
|
||||
key: "value",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -273,8 +305,28 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBeDefined();
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBeDefined();
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 2);
|
||||
await userHasDefaultEventTypes(responseBody.data.user.id);
|
||||
@@ -291,6 +343,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
locale: Locales.FR,
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
bio: "I am a bio",
|
||||
metadata: {
|
||||
key: "value",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -314,8 +370,28 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClientEventTypesDisabled.id);
|
||||
expect(decodedAccessToken.ownerId).toBeDefined();
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClientEventTypesDisabled.id);
|
||||
expect(decodedRefreshToken.ownerId).toBeDefined();
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
await userConnectedToOAuth(oAuthClientEventTypesDisabled.id, responseBody.data.user.email, 1);
|
||||
await userDoesNotHaveDefaultEventTypes(responseBody.data.user.id);
|
||||
@@ -417,6 +493,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
|
||||
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
|
||||
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
|
||||
});
|
||||
|
||||
it(`/GET: managed user by original email`, async () => {
|
||||
@@ -434,6 +514,8 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
|
||||
expect(userOne?.email).toEqual(postResponseData.user.email);
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
});
|
||||
|
||||
it(`/GET: managed users by original emails`, async () => {
|
||||
@@ -456,6 +538,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
|
||||
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
|
||||
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
|
||||
});
|
||||
|
||||
it(`/GET: managed user by oAuth email`, async () => {
|
||||
@@ -478,6 +564,8 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
|
||||
expect(userOne?.email).toEqual(postResponseData.user.email);
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
});
|
||||
|
||||
it(`should error /GET if managed user email is invalid`, async () => {
|
||||
@@ -514,6 +602,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
|
||||
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
|
||||
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
@@ -530,6 +622,9 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userEmail)
|
||||
);
|
||||
expect(responseBody.data.name).toEqual(postResponseData.user.name);
|
||||
expect(responseBody.data.bio).toEqual(postResponseData.user.bio);
|
||||
expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata);
|
||||
});
|
||||
|
||||
it(`/PUT/:id`, async () => {
|
||||
@@ -550,12 +645,51 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userUpdatedEmail)
|
||||
);
|
||||
expect(responseBody.data.name).toEqual(postResponseData.user.name);
|
||||
expect(responseBody.data.bio).toEqual(postResponseData.user.bio);
|
||||
expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata);
|
||||
const [emailUser, emailDomain] = responseBody.data.email.split("@");
|
||||
const [domainName, TLD] = emailDomain.split(".");
|
||||
expect(responseBody.data.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`));
|
||||
expect(responseBody.data.locale).toEqual(Locales.PT_BR);
|
||||
});
|
||||
|
||||
it("should force refresh tokens", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}/force-refresh`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: KeysResponseDto = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.accessTokenExpiresAt).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(postResponseData.user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(postResponseData.user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it(`/DELETE/:id`, () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
|
||||
|
||||
+15
-29
@@ -8,9 +8,11 @@ import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oau
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { TOKENS_DOCS } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||
import { User, MembershipRole } from "@prisma/client";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
@@ -54,7 +57,8 @@ export class OAuthClientUsersController {
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly oAuthClientUsersService: OAuthClientUsersService,
|
||||
private readonly oauthRepository: OAuthClientRepository,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -69,7 +73,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: managedUsers.map((user) => this.getResponseUser(user)),
|
||||
data: managedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,10 +97,11 @@ export class OAuthClientUsersController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
user: this.getResponseUser(user),
|
||||
user: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
accessToken: tokens.accessToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: tokens.refreshToken,
|
||||
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt.valueOf(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -113,7 +118,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.getResponseUser(user),
|
||||
data: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +138,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.getResponseUser(user),
|
||||
data: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,7 +157,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.getResponseUser(user),
|
||||
data: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,8 +165,7 @@ export class OAuthClientUsersController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Force refresh tokens",
|
||||
description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials.
|
||||
Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`,
|
||||
description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. ${TOKENS_DOCS}`,
|
||||
})
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async forceRefresh(
|
||||
@@ -172,11 +176,8 @@ export class OAuthClientUsersController {
|
||||
|
||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
oAuthClientId,
|
||||
id,
|
||||
true
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -184,6 +185,7 @@ export class OAuthClientUsersController {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -196,20 +198,4 @@ export class OAuthClientUsersController {
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private getResponseUser(user: User): ManagedUserOutput {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
timeZone: user.timeZone,
|
||||
weekStart: user.weekStart,
|
||||
createdDate: user.createdDate,
|
||||
timeFormat: user.timeFormat,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
locale: user.locale as Locales,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+3
-11
@@ -1,26 +1,18 @@
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNumber, IsString, ValidateNested } from "class-validator";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateManagedUserData {
|
||||
export class CreateManagedUserData extends KeysDto {
|
||||
@ApiProperty({
|
||||
type: ManagedUserOutput,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => ManagedUserOutput)
|
||||
user!: ManagedUserOutput;
|
||||
|
||||
@IsString()
|
||||
accessToken!: string;
|
||||
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
|
||||
@IsNumber()
|
||||
accessTokenExpiresAt!: number;
|
||||
}
|
||||
|
||||
export class CreateManagedUserOutput {
|
||||
|
||||
+32
-2
@@ -1,40 +1,54 @@
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsEnum, IsOptional, IsUrl } from "class-validator";
|
||||
import { Expose, Transform } from "class-transformer";
|
||||
import { IsEnum, IsObject, IsOptional, IsUrl } from "class-validator";
|
||||
|
||||
export class ManagedUserOutput {
|
||||
@ApiProperty({ example: 1 })
|
||||
@Expose()
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ example: "alice+cluo37fwd0001khkzqqynkpj3@example.com" })
|
||||
@Expose()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: "alice", nullable: true })
|
||||
@Expose()
|
||||
username!: string | null;
|
||||
|
||||
@ApiProperty({ example: "alice", nullable: true })
|
||||
@Expose()
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({ example: "bio", nullable: true })
|
||||
@Expose()
|
||||
bio!: string | null;
|
||||
|
||||
@ApiProperty({ example: "America/New_York" })
|
||||
@Expose()
|
||||
timeZone!: string;
|
||||
|
||||
@ApiProperty({ example: "Sunday" })
|
||||
@Expose()
|
||||
weekStart!: string;
|
||||
|
||||
@ApiProperty({ type: String, example: "2024-04-01T00:00:00.000Z" })
|
||||
@Transform(({ value }) => value.toISOString())
|
||||
@Expose()
|
||||
createdDate!: Date;
|
||||
|
||||
@ApiProperty({ type: Number, example: 12, nullable: true })
|
||||
@Expose()
|
||||
timeFormat!: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, example: null, nullable: true })
|
||||
@Expose()
|
||||
defaultScheduleId!: number | null;
|
||||
|
||||
@IsEnum(Locales)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ type: String, example: Locales.EN, enum: Locales })
|
||||
@Expose()
|
||||
locale?: Locales;
|
||||
|
||||
@IsUrl()
|
||||
@@ -45,5 +59,21 @@ export class ManagedUserOutput {
|
||||
description: `URL of the user's avatar image`,
|
||||
nullable: true,
|
||||
})
|
||||
@Expose()
|
||||
avatarUrl?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
@Transform(
|
||||
// note(Lauris): added this transform because without it metadata is removed for some reason
|
||||
({ obj }: { obj: { metadata: Record<string, unknown> | null | undefined } }) => {
|
||||
return obj.metadata || undefined;
|
||||
}
|
||||
)
|
||||
metadata?: Record<string, string | boolean | number>;
|
||||
}
|
||||
|
||||
+7
-15
@@ -10,6 +10,8 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller
|
||||
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
|
||||
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
|
||||
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
|
||||
import { OAuthClientsService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -51,6 +53,7 @@ export class OAuthClientsController {
|
||||
private readonly logger = new Logger("OAuthClientController");
|
||||
|
||||
constructor(
|
||||
private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService,
|
||||
private readonly oAuthClientsService: OAuthClientsService,
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly teamsRepository: OrganizationsRepository
|
||||
@@ -124,7 +127,10 @@ export class OAuthClientsController {
|
||||
limit ?? 50
|
||||
);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) };
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: existingManagedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:clientId")
|
||||
@@ -151,18 +157,4 @@ export class OAuthClientsController {
|
||||
const client = await this.oAuthClientsService.deleteOAuthClient(clientId);
|
||||
return { status: SUCCESS_STATUS, data: client };
|
||||
}
|
||||
|
||||
private getResponseUser(user: User): ManagedUserOutput {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
timeZone: user.timeZone,
|
||||
weekStart: user.weekStart,
|
||||
createdDate: user.createdDate,
|
||||
timeFormat: user.timeFormat,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+54
-12
@@ -4,6 +4,7 @@ import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { JwtService } from "@/modules/jwt/jwt.service";
|
||||
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
|
||||
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
@@ -68,7 +69,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
|
||||
let authorizationCode: string | null;
|
||||
let refreshToken: string;
|
||||
let responseRefreshToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const userEmail = `oauth-flow-user-${randomString()}@api.com`;
|
||||
@@ -153,29 +154,70 @@ describe("OAuthFlow Endpoints", () => {
|
||||
.send(body)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body?.data?.accessToken).toBeDefined();
|
||||
expect(response.body?.data?.refreshToken).toBeDefined();
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
refreshToken = response.body.data.refreshToken;
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
responseRefreshToken = refreshToken;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refresh Token Endpoint", () => {
|
||||
it("POST /oauth/:clientId/refresh", () => {
|
||||
it("POST /oauth/:clientId/refresh", async () => {
|
||||
const secretKey = oAuthClient.secret;
|
||||
const body = {
|
||||
refreshToken,
|
||||
refreshToken: responseRefreshToken,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/oauth/${oAuthClient.id}/refresh`)
|
||||
.set("x-cal-secret-key", secretKey)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body?.data?.accessToken).toBeDefined();
|
||||
expect(response.body?.data?.refreshToken).toBeDefined();
|
||||
});
|
||||
.expect(200);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(user.id);
|
||||
expect(decodedAccessToken.userId).toBe(user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(user.id);
|
||||
expect(decodedRefreshToken.userId).toBe(user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
responseRefreshToken = refreshToken;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+13
-28
@@ -1,4 +1,3 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -25,11 +24,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags as DocsTags,
|
||||
ApiExcludeController as DocsExcludeController,
|
||||
ApiOperation as DocsOperation,
|
||||
ApiOkResponse as DocsOkResponse,
|
||||
ApiExcludeEndpoint as DocsExcludeEndpoint,
|
||||
ApiBadRequestResponse as DocsBadRequestResponse,
|
||||
ApiHeader as DocsHeader,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
@@ -37,6 +32,9 @@ import { Response as ExpressResponse } from "express";
|
||||
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
export const TOKENS_DOCS = `Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model \`calAccessToken\` and \`calRefreshToken\` fields.
|
||||
Response also contains \`accessTokenExpiresAt\` and \`refreshTokenExpiresAt\` fields, but if you decode the jwt token the payload will contain \`clientId\` (OAuth client ID), \`ownerId\` (user to whom token belongs ID), \`iat\` (issued at time) and \`expiresAt\` (when does the token expire) fields.`;
|
||||
|
||||
@Controller({
|
||||
path: "/v2/oauth/:clientId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
@@ -96,20 +94,15 @@ export class OAuthFlowController {
|
||||
throw new BadRequestException("Missing 'Bearer' Authorization header.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } =
|
||||
await this.oAuthFlowService.exchangeAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
);
|
||||
const tokens = await this.oAuthFlowService.exchangeAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken,
|
||||
},
|
||||
data: tokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,27 +117,19 @@ export class OAuthFlowController {
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Refresh managed user tokens",
|
||||
description: `If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and
|
||||
each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`,
|
||||
description: `If managed user access token is expired then get a new one using this endpoint - it will also refresh the refresh token, because we use
|
||||
"refresh token rotation" mechanism. ${TOKENS_DOCS}`,
|
||||
})
|
||||
async refreshTokens(
|
||||
@Param("clientId") clientId: string,
|
||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||
@Body() body: RefreshTokenInput
|
||||
): Promise<KeysResponseDto> {
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.oAuthFlowService.refreshToken(
|
||||
clientId,
|
||||
secretKey,
|
||||
body.refreshToken
|
||||
);
|
||||
const tokens = await this.oAuthFlowService.refreshToken(clientId, secretKey, body.refreshToken);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
accessToken: accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
data: tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ import { ValidateNested, IsEnum, IsString, IsNotEmptyObject, IsNumber } from "cl
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class KeysDto {
|
||||
export class KeysDto {
|
||||
@ApiProperty({
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
})
|
||||
@@ -19,6 +19,9 @@ class KeysDto {
|
||||
|
||||
@IsNumber()
|
||||
accessTokenExpiresAt!: number;
|
||||
|
||||
@IsNumber()
|
||||
refreshTokenExpiresAt!: number;
|
||||
}
|
||||
|
||||
export class KeysResponseDto {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/
|
||||
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
|
||||
import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthClientsInputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-input.service";
|
||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||
@@ -58,8 +59,9 @@ import { JwtService } from "@nestjs/jwt";
|
||||
OAuthClientsInputService,
|
||||
OAuthClientsOutputService,
|
||||
JwtService,
|
||||
OAuthClientUsersOutputService,
|
||||
],
|
||||
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
||||
exports: [OAuthClientRepository],
|
||||
exports: [OAuthClientRepository, OAuthClientUsersOutputService],
|
||||
})
|
||||
export class OAuthClientModule {}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersOutputService {
|
||||
getResponseUser(user: User) {
|
||||
return plainToInstance(ManagedUserOutput, user, { strategy: "excludeAll" });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
@@ -41,7 +42,7 @@ export class OAuthClientUsersService {
|
||||
);
|
||||
} else {
|
||||
const email = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email);
|
||||
user = (
|
||||
const createdUser = (
|
||||
await createNewUsersConnectToOrgIfExists({
|
||||
invitations: [
|
||||
{
|
||||
@@ -64,23 +65,21 @@ export class OAuthClientUsersService {
|
||||
timeFormat: body.timeFormat,
|
||||
weekStart: body.weekStart,
|
||||
timeZone: body.timeZone,
|
||||
language: body.locale ?? Locales.EN,
|
||||
})
|
||||
)[0];
|
||||
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
|
||||
const updatedUser = await this.userRepository.update(user.id, {
|
||||
await this.userRepository.addToOAuthClient(createdUser.id, oAuthClientId);
|
||||
user = await this.userRepository.update(createdUser.id, {
|
||||
name: body.name,
|
||||
locale: body.locale,
|
||||
avatarUrl: body.avatarUrl,
|
||||
bio: body.bio,
|
||||
metadata: body.metadata,
|
||||
});
|
||||
user.locale = updatedUser.locale;
|
||||
user.name = updatedUser.name;
|
||||
user.avatarUrl = updatedUser.avatarUrl;
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
oAuthClientId,
|
||||
user.id
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, user.id);
|
||||
|
||||
if (oAuthClient.areDefaultEventTypesEnabled) {
|
||||
await this.eventTypesService.createUserDefaultEventTypes(user.id);
|
||||
@@ -104,6 +103,7 @@ export class OAuthClientUsersService {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception";
|
||||
import {
|
||||
KeysDto,
|
||||
KeysResponseDto,
|
||||
} from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
@@ -103,7 +107,7 @@ export class OAuthFlowService {
|
||||
tokenId: string,
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
): Promise<{ accessToken: string; refreshToken: string; accessTokenExpiresAt: Date }> {
|
||||
): Promise<KeysDto> {
|
||||
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens(
|
||||
tokenId,
|
||||
clientId,
|
||||
@@ -120,21 +124,20 @@ export class OAuthFlowService {
|
||||
throw new BadRequestException("Invalid Authorization Token.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
clientId,
|
||||
authorizationToken.owner.id
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(clientId, authorizationToken.owner.id);
|
||||
await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id);
|
||||
void this.propagateAccessToken(accessToken); // void result, ignored.
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(clientId: string, clientSecret: string, tokenSecret: string) {
|
||||
async refreshToken(clientId: string, clientSecret: string, tokenSecret: string): Promise<KeysDto> {
|
||||
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret(
|
||||
clientId,
|
||||
clientSecret,
|
||||
@@ -159,8 +162,9 @@ export class OAuthFlowService {
|
||||
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
|
||||
refreshToken: refreshToken.secret,
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { ArrayMinSize, IsArray, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import { GetBookingsInput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
export class GetOrganizationsBookingsInput extends GetBookingsInput_2024_08_13 {
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((userId: string) => parseInt(userId));
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@IsNumber({}, { each: true })
|
||||
@ArrayMinSize(1, { message: "userIds must contain at least 1 user id" })
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: "Filter bookings by ids of users within your organization.",
|
||||
example: "?userIds=100,200",
|
||||
})
|
||||
userIds?: number[];
|
||||
}
|
||||
+703
@@ -0,0 +1,703 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import {
|
||||
CAL_API_VERSION_HEADER,
|
||||
SUCCESS_STATUS,
|
||||
VERSION_2024_08_13,
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
} from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Organization bookings", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const orgUserEmail = "org-user-1-bookings@api.com";
|
||||
const orgUserEmail2 = "org-user-2-bookings@api.com";
|
||||
const nonOrgUserEmail1 = "non-org-user-1-bookings@api.com";
|
||||
let orgUser: User;
|
||||
let orgUser2: User;
|
||||
let nonOrgUser1: User;
|
||||
let team1: Team;
|
||||
|
||||
let orgEventTypeId: number;
|
||||
let orgEventTypeId2: number;
|
||||
let nonOrgEventTypeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
orgUserEmail2,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, OrganizationsTeamsBookingsModule],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({ name: "organization bookings" });
|
||||
team1 = await teamRepositoryFixture.create({
|
||||
name: "team orgs booking 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
nonOrgUser1 = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmail1,
|
||||
locale: "it",
|
||||
name: "NonOrgUser1Bookings",
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
orgUser = await userRepositoryFixture.create({
|
||||
email: orgUserEmail,
|
||||
locale: "it",
|
||||
name: "orgUser1Bookings",
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
orgUser2 = await userRepositoryFixture.create({
|
||||
email: orgUserEmail2,
|
||||
locale: "es",
|
||||
name: "orgUser2Bookings",
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(orgUser.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(orgUser2.id, userSchedule);
|
||||
await schedulesService.createUserSchedule(nonOrgUser1.id, userSchedule);
|
||||
|
||||
const orgEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: "org-bookings-collective-event-type",
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
const orgEventType2 = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: "org-bookings-round-robin-event-type",
|
||||
length: 60,
|
||||
assignAllTeamMembers: false,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
orgEventTypeId2 = orgEventType2.id;
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${orgUser.id}`,
|
||||
username: orgUserEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUser.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${orgUser2.id}`,
|
||||
username: orgUserEmail2,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUser2.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: orgUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: orgUser2.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: orgUser.id } },
|
||||
team: { connect: { id: team1.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: orgUser2.id } },
|
||||
team: { connect: { id: team1.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const nonOrgEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: "Non Org Event Type",
|
||||
slug: "non-org-event-type",
|
||||
length: 60,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
},
|
||||
nonOrgUser1.id
|
||||
);
|
||||
|
||||
orgEventTypeId = orgEventType.id;
|
||||
nonOrgEventTypeId = nonOrgEventType.id;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUser.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: orgEventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: orgEventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: false,
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUser2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: orgEventType2.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("create organization bookings", () => {
|
||||
it("should create an collective organization booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: orgEventTypeId,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Madrid",
|
||||
language: "es",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(orgUser.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(orgEventTypeId);
|
||||
expect(data.attendees.length).toEqual(2);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a round robin organization booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 10, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: orgEventTypeId2,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Madrid",
|
||||
language: "es",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(orgUser2.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 10, 14, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(orgEventTypeId2);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a non organization booking for org-user-1", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: nonOrgEventTypeId,
|
||||
attendee: {
|
||||
name: orgUser.name ?? "",
|
||||
email: orgUserEmail,
|
||||
timeZone: orgUser.timeZone ?? "Europe/Madrid",
|
||||
language: "en",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(nonOrgUser1.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(nonOrgEventTypeId);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: orgUserEmail,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a non organization booking for org-user-2", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: nonOrgEventTypeId,
|
||||
attendee: {
|
||||
name: orgUser2.name ?? "",
|
||||
email: orgUserEmail2,
|
||||
timeZone: orgUser2.timeZone ?? "Europe/Madrid",
|
||||
language: "en",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(nonOrgUser1.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 11, 14, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(nonOrgEventTypeId);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: orgUserEmail2,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get organization bookings", () => {
|
||||
it("should get bookings by organizationId", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(4);
|
||||
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
|
||||
[orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get bookings by organizationId", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings?userIds=${orgUser.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
|
||||
[orgEventTypeId, nonOrgEventTypeId].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get bookings by organizationId and userIds", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${organization.id}/bookings?userIds=${orgUser.id},${orgUser2.id}&skip=0&take=250`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(4);
|
||||
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
|
||||
[orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get bookings by organizationId and userId", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings?userIds=${orgUser2.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
|
||||
[orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to get bookings by organizationId and Id of a user that does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings?userIds=972930`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should fail to get bookings by organizationId and Id of a user that does not belong to the org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings?userIds=${nonOrgUser1.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should get bookings by organizationId and non org event-type id", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings?eventTypeIds=${nonOrgEventTypeId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(2);
|
||||
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
|
||||
[nonOrgEventTypeId, nonOrgEventTypeId].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get bookings by organizationId and org event-type id", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings?eventTypeIds=${orgEventTypeId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual([orgEventTypeId].sort());
|
||||
});
|
||||
});
|
||||
|
||||
it("should get bookings by organizationId and org + non org event-type ids", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${organization.id}/bookings?eventTypeIds=${orgEventTypeId2},${nonOrgEventTypeId}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
|
||||
[orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(orgUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(orgUser2.email);
|
||||
await userRepositoryFixture.deleteByEmail(nonOrgUser1.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(orgUser.id, orgUser.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(orgUser2.id, orgUser2.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(nonOrgUser1.id, nonOrgUser1.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
import { GetOrganizationsBookingsInput } from "./inputs/get-org-bookings.input";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/bookings",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Bookings")
|
||||
export class OrganizationsBookingsController {
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly orgUsersService: OrganizationsUsersService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get Organization Bookings" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getAllOrgTeamBookings(
|
||||
@Query() queryParams: GetOrganizationsBookingsInput,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const { userIds, ...restParams } = queryParams;
|
||||
|
||||
const bookings = await this.bookingsService.getBookings(
|
||||
{ ...restParams },
|
||||
{ email: user.email, id: user.id, orgId },
|
||||
userIds
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations//users/index/organizations-users.repository";
|
||||
import { OrganizationsBookingsController } from "@/modules/organizations/bookings/organizations-bookings.controller";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
|
||||
providers: [
|
||||
OrganizationsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsUsersService,
|
||||
OrganizationsUsersRepository,
|
||||
],
|
||||
controllers: [OrganizationsBookingsController],
|
||||
})
|
||||
export class OrganizationsBookingsModule {}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import {
|
||||
ConferencingAppsOauthUrlOutputDto,
|
||||
GetConferencingAppsOauthUrlResponseDto,
|
||||
} from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url";
|
||||
import {
|
||||
ConferencingAppsOutputResponseDto,
|
||||
ConferencingAppOutputResponseDto,
|
||||
ConferencingAppsOutputDto,
|
||||
DisconnectConferencingAppOutputResponseDto,
|
||||
} from "@/modules/conferencing/outputs/get-conferencing-apps.output";
|
||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Post,
|
||||
Param,
|
||||
Delete,
|
||||
Headers,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
|
||||
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Orgs / Teams / Conferencing")
|
||||
export class OrganizationsConferencingController {
|
||||
constructor(
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET],
|
||||
required: true,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Post("/teams/:teamId/conferencing/:app/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Connect your conferencing application to a team" })
|
||||
async connectTeamApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<ConferencingAppOutputResponseDto> {
|
||||
const credential = await this.organizationsConferencingService.connectTeamNonOauthApps({
|
||||
teamId,
|
||||
app,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [ZOOM, OFFICE_365_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app's auth url for a team" })
|
||||
async getTeamOAuthUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("teamId") teamId: string,
|
||||
@Param("orgId") orgId: string,
|
||||
@Param("app") app: string,
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<GetConferencingAppsOauthUrlResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state: OAuthCallbackState = {
|
||||
returnTo: returnTo ?? origin,
|
||||
onErrorReturnTo: onErrorReturnTo ?? origin,
|
||||
fromApp: false,
|
||||
accessToken,
|
||||
teamId,
|
||||
orgId,
|
||||
};
|
||||
|
||||
const credential = await this.conferencingService.generateOAuthUrl(app, state);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "List team conferencing applications" })
|
||||
async listTeamConferencingApps(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ConferencingAppsOutputResponseDto> {
|
||||
const conferencingApps = await this.organizationsConferencingService.getConferencingApps({
|
||||
teamId,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Post("/teams/:teamId/conferencing/:app/default")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Set team default conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
async setTeamDefaultApp(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<SetDefaultConferencingAppOutputResponseDto> {
|
||||
await this.organizationsConferencingService.setDefaultConferencingApp({
|
||||
teamId,
|
||||
app,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/default")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get team default conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
async getTeamDefaultApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultConferencingApp = await this.organizationsConferencingService.getDefaultConferencingApp({
|
||||
teamId,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: defaultConferencingApp };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Delete("/teams/:teamId/conferencing/:app/disconnect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Disconnect team conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
async disconnectTeamApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<DisconnectConferencingAppOutputResponseDto> {
|
||||
await this.organizationsConferencingService.disconnectConferencingApp({
|
||||
teamId,
|
||||
user,
|
||||
app,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PlatformPlanType } from "@/modules/billing/types";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
import { teamMetadataSchema } from "@calcom/platform-libraries";
|
||||
import { handleDeleteCredential } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsConferencingService {
|
||||
private logger = new Logger("OrganizationsConferencingService");
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private teamsRepository: TeamsRepository,
|
||||
private usersRepository: UsersRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly platformPlanGuard: PlatformPlanGuard,
|
||||
private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard,
|
||||
private readonly isOrgGuard: IsOrgGuard,
|
||||
private readonly isTeamInOrg: IsTeamInOrg,
|
||||
private readonly rolesGuard: RolesGuard
|
||||
) {}
|
||||
|
||||
async verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan,
|
||||
}: {
|
||||
user: UserWithProfile;
|
||||
orgId: string;
|
||||
teamId?: string;
|
||||
requiredRole?: string;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<{ orgId: number; teamId: number | null }> {
|
||||
const userWithAdminFlag = {
|
||||
...user,
|
||||
isSystemAdmin: user.role === "ADMIN",
|
||||
};
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("Organization ID is required");
|
||||
}
|
||||
|
||||
await this.ensureOrganizationAccess(orgId);
|
||||
await this.ensureAdminAPIEnabled(orgId);
|
||||
|
||||
// Only validate team if a teamId is provided
|
||||
if (teamId && teamId.trim() !== "") {
|
||||
await this.ensureTeamBelongsToOrg(orgId, teamId);
|
||||
}
|
||||
|
||||
await this.ensureUserRoleAccess(userWithAdminFlag, orgId, teamId, requiredRole);
|
||||
await this.ensurePlatformPlanAccess({ teamId, orgId, user: userWithAdminFlag, minimumPlan });
|
||||
|
||||
const parsedOrgId = parseInt(orgId, 10);
|
||||
// For organization-level operations, teamId might be empty or null
|
||||
const parsedTeamId = teamId && teamId.trim() !== "" ? parseInt(teamId, 10) : null;
|
||||
|
||||
return {
|
||||
orgId: parsedOrgId,
|
||||
teamId: parsedTeamId,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureOrganizationAccess(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Organization validation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminAPIEnabled(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isAdminAPIEnabledGuard.checkAdminAPIEnabled(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Admin API is not enabled for this organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTeamBelongsToOrg(orgId: string, teamId: string): Promise<void> {
|
||||
const { canAccess } = await this.isTeamInOrg.checkIfTeamIsInOrg(orgId, teamId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Team is not part of the organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
teamId?: string,
|
||||
requiredRole?: string
|
||||
): Promise<void> {
|
||||
if (!requiredRole) return;
|
||||
|
||||
// For team-level roles, we need both orgId and teamId
|
||||
if (requiredRole.startsWith("TEAM_") && (!teamId || teamId.trim() === "")) {
|
||||
throw new ForbiddenException("Team ID is required for team-level role access");
|
||||
}
|
||||
|
||||
// Use empty string for teamId if it's an org-level operation
|
||||
const effectiveTeamId = teamId && teamId.trim() !== "" ? teamId : "";
|
||||
|
||||
const { canAccess } = await this.rolesGuard.checkUserRoleAccess(
|
||||
user,
|
||||
orgId,
|
||||
effectiveTeamId,
|
||||
requiredRole
|
||||
);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User does not have the required role.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
}: {
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
user: ApiAuthGuardUser;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<void> {
|
||||
if (!minimumPlan) return;
|
||||
|
||||
const { canAccess } = await this.platformPlanGuard.checkPlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
});
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User's organization does not meet the required subscription plan.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise<any> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
return this.googleMeetService.connectGoogleMeetToTeam(teamId);
|
||||
default:
|
||||
throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectTeamOauthApps({
|
||||
decodedCallbackState,
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
}: {
|
||||
userId: number;
|
||||
app: string;
|
||||
decodedCallbackState: OAuthCallbackState;
|
||||
code: string;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByIdWithProfile(userId);
|
||||
const { orgId, teamId } = decodedCallbackState;
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("orgId required");
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException("user not found");
|
||||
}
|
||||
|
||||
// Determine if this is a team-level or organization-level operation
|
||||
const isTeamLevel = !!teamId;
|
||||
const requiredRole = isTeamLevel ? "TEAM_ADMIN" : "ORG_ADMIN";
|
||||
|
||||
// Verify access with appropriate parameters
|
||||
const { orgId: validatedOrgId, teamId: validatedTeamId } = await this.verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan: "ESSENTIALS",
|
||||
});
|
||||
|
||||
const entityId = isTeamLevel && validatedTeamId !== null ? validatedTeamId : validatedOrgId;
|
||||
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState, entityId);
|
||||
}
|
||||
|
||||
async getConferencingApps({ teamId }: { teamId: number }) {
|
||||
return this.conferencingRepository.findTeamConferencingApps(teamId);
|
||||
}
|
||||
|
||||
async getDefaultConferencingApp({
|
||||
teamId,
|
||||
}: {
|
||||
teamId: number;
|
||||
}): Promise<DefaultConferencingAppsOutputDto | undefined> {
|
||||
const team = await this.teamsRepository.getById(teamId);
|
||||
return teamMetadataSchema.parse(team?.metadata)?.defaultConferencingApp;
|
||||
}
|
||||
|
||||
async checkAppIsValidAndConnected(teamId: number, app: string) {
|
||||
if (!CONFERENCING_APPS.includes(app)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", "));
|
||||
}
|
||||
const credential = await this.conferencingRepository.findTeamConferencingApp(teamId, app);
|
||||
|
||||
if (!credential) {
|
||||
throw new BadRequestException(`${app} not connected.`);
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
async disconnectConferencingApp({
|
||||
teamId,
|
||||
user,
|
||||
app,
|
||||
}: {
|
||||
teamId: number;
|
||||
user: UserWithProfile;
|
||||
app: string;
|
||||
}) {
|
||||
const credential = await this.checkAppIsValidAndConnected(teamId, app);
|
||||
return handleDeleteCredential({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
userMetadata: user?.metadata,
|
||||
credentialId: credential.id,
|
||||
});
|
||||
}
|
||||
|
||||
async setDefaultConferencingApp({ teamId, app }: { teamId: number; app: string }) {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (app !== CAL_VIDEO) {
|
||||
await this.checkAppIsValidAndConnected(teamId, app);
|
||||
}
|
||||
const team = await this.teamsRepository.setDefaultConferencingApp(teamId, app);
|
||||
const metadata = team.metadata as { defaultConferencingApp?: { appSlug?: string } };
|
||||
if (metadata?.defaultConferencingApp?.appSlug !== app) {
|
||||
throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+16
-19
@@ -1,10 +1,5 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
|
||||
import {
|
||||
GetBookingsDataEntry,
|
||||
GetBookingsOutput_2024_04_15,
|
||||
} from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
@@ -15,7 +10,7 @@ import {
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
|
||||
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
|
||||
import { OrgTeamMembershipOutputResponseDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
@@ -32,18 +27,10 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import {
|
||||
CAL_API_VERSION_HEADER,
|
||||
SUCCESS_STATUS,
|
||||
VERSION_2024_06_14,
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
} from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
Host,
|
||||
OrgTeamOutputDto,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
@@ -238,7 +225,7 @@ describe("Assign all team members", () => {
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
const responseBody: OrgTeamMembershipOutputResponseDto = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
@@ -257,14 +244,14 @@ describe("Assign all team members", () => {
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
const responseBody: OrgTeamMembershipOutputResponseDto = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("should setup event types using assignAllTeamMembers true", () => {
|
||||
it("should not be able to setup team event type if no hosts nor assignAllTeamMembers provided", async () => {
|
||||
it("should be able to setup team event type if no hosts nor assignAllTeamMembers provided", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation round robin",
|
||||
slug: `organizations-event-types-round-robin-${randomString()}`,
|
||||
@@ -279,7 +266,17 @@ describe("Assign all team members", () => {
|
||||
.send(body)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(400);
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts).toEqual([]);
|
||||
expect(data.schedulingType).toEqual("collective");
|
||||
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
|
||||
expect(eventTypeHosts.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should setup collective event type assignAllTeamMembers true", async () => {
|
||||
|
||||
+74
-2
@@ -17,7 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
|
||||
import {
|
||||
BookingWindowPeriodInputTypeEnum_2024_06_14,
|
||||
BookerLayoutsInputEnum_2024_06_14,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
Host,
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
@@ -62,6 +63,8 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
|
||||
let managedEventType: TeamEventTypeOutput_2024_06_14;
|
||||
|
||||
const managedEventTypeSlug = `organizations-event-types-managed-${randomString()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
@@ -101,6 +104,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `organizations-event-types-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
slug: `organizations-event-types-organization-${randomString()}`,
|
||||
});
|
||||
|
||||
falseTestOrg = await organizationsRepositoryFixture.create({
|
||||
@@ -135,6 +139,36 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teammate1.id}`,
|
||||
username: teammate1Email,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teammate1.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teammate2.id}`,
|
||||
username: teammate2Email,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teammate2.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: userAdmin.id } },
|
||||
@@ -347,7 +381,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
it("should create a managed team event-type", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation managed",
|
||||
slug: `organizations-event-types-managed-${randomString()}`,
|
||||
slug: managedEventTypeSlug,
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
@@ -387,7 +421,9 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
|
||||
|
||||
expect(teammate1EventTypes.length).toEqual(1);
|
||||
expect(teammate1EventTypes[0].slug).toEqual(managedEventTypeSlug);
|
||||
expect(teammate2EventTypes.length).toEqual(1);
|
||||
expect(teammate2EventTypes[0].slug).toEqual(managedEventTypeSlug);
|
||||
expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual(
|
||||
1
|
||||
);
|
||||
@@ -410,6 +446,42 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("managed team event types should be returned when fetching event types of users", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/event-types?username=${teammate1.username}&orgSlug=${org.slug}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].slug).toEqual(managedEventTypeSlug);
|
||||
expect(data[0].ownerId).toEqual(teammate1.id);
|
||||
expect(data[0].id).not.toEqual(managedEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("managed team event type should be returned when fetching event types of users", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/event-types?username=${teammate1.username}&orgSlug=${org.slug}&eventSlug=${managedEventTypeSlug}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].slug).toEqual(managedEventTypeSlug);
|
||||
expect(data[0].ownerId).toEqual(teammate1.id);
|
||||
expect(data[0].id).not.toEqual(managedEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not get an event-type of team outside org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -25,13 +26,15 @@ export class InputOrganizationsEventTypesService {
|
||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly conferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
async transformAndValidateCreateTeamEventTypeInput(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
|
||||
@@ -62,6 +65,7 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
if (inputEventType.slug) {
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
@@ -104,13 +108,6 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const hasHosts = !!inputEventType.hosts && !!inputEventType.hosts.length;
|
||||
const hasAssignAllTeamMembers = inputEventType.assignAllTeamMembers === true;
|
||||
|
||||
if (!hasHosts && !hasAssignAllTeamMembers) {
|
||||
throw new BadRequestException("Either hosts must be provided or assignAllTeamMembers must be true");
|
||||
}
|
||||
|
||||
const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType;
|
||||
|
||||
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
|
||||
@@ -293,6 +290,22 @@ export class InputOrganizationsEventTypesService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async validateInputLocations(
|
||||
teamId: number,
|
||||
inputLocations?: CreateTeamEventTypeInput_2024_06_14["locations"]
|
||||
) {
|
||||
await Promise.all(
|
||||
inputLocations?.map(async (location) => {
|
||||
if (location.type === "integration") {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (location.integration !== "cal-video") {
|
||||
await this.conferencingService.checkAppIsValidAndConnected(teamId, location.integration);
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityValue(priority: keyof typeof HostPriority): number {
|
||||
|
||||
+54
-7
@@ -2,7 +2,13 @@ import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
|
||||
import { UpdateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/update-organization-membership.input";
|
||||
import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/outputs/create-membership.output";
|
||||
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
|
||||
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
|
||||
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
|
||||
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
@@ -32,7 +38,7 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
let membership2: Membership;
|
||||
let membershipCreatedViaApi: Membership;
|
||||
let membershipCreatedViaApi: TeamMembershipOutput;
|
||||
|
||||
const userEmail = `organizations-memberships-admin-${randomString()}@api.com`;
|
||||
const userEmail2 = `organizations-memberships-member-${randomString()}@api.com`;
|
||||
@@ -43,6 +49,11 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
|
||||
let userToInviteViaApi: User;
|
||||
|
||||
const metadata = {
|
||||
some: "key",
|
||||
};
|
||||
const bio = "This is a bio";
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
@@ -58,15 +69,21 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
user2 = await userRepositoryFixture.create({
|
||||
email: userEmail2,
|
||||
username: userEmail2,
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
|
||||
userToInviteViaApi = await userRepositoryFixture.create({
|
||||
email: invitedUserEmail,
|
||||
username: invitedUserEmail,
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
@@ -104,10 +121,24 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
.get(`/v2/organizations/${org.id}/memberships`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
|
||||
const responseBody: GetAllOrgMemberships = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(membership.id);
|
||||
expect(responseBody.data[0].userId).toEqual(user.id);
|
||||
expect(responseBody.data[0].role).toEqual("ADMIN");
|
||||
expect(responseBody.data[0].user.bio).toEqual(bio);
|
||||
expect(responseBody.data[0].user.metadata).toEqual(metadata);
|
||||
expect(responseBody.data[0].user.email).toEqual(user.email);
|
||||
expect(responseBody.data[0].user.username).toEqual(user.username);
|
||||
expect(responseBody.data[0].teamId).toEqual(org.id);
|
||||
expect(responseBody.data[1].id).toEqual(membership2.id);
|
||||
expect(responseBody.data[1].userId).toEqual(user2.id);
|
||||
expect(responseBody.data[1].role).toEqual("MEMBER");
|
||||
expect(responseBody.data[1].user.bio).toEqual(bio);
|
||||
expect(responseBody.data[1].user.metadata).toEqual(metadata);
|
||||
expect(responseBody.data[1].user.email).toEqual(user2.email);
|
||||
expect(responseBody.data[1].user.username).toEqual(user2.username);
|
||||
expect(responseBody.data[1].teamId).toEqual(org.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,10 +147,16 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
|
||||
const responseBody: GetAllOrgMemberships = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(membership2.id);
|
||||
expect(responseBody.data[0].role).toEqual("MEMBER");
|
||||
expect(responseBody.data[0].user.bio).toEqual(bio);
|
||||
expect(responseBody.data[0].user.metadata).toEqual(metadata);
|
||||
expect(responseBody.data[0].user.email).toEqual(user2.email);
|
||||
expect(responseBody.data[0].user.username).toEqual(user2.username);
|
||||
expect(responseBody.data[0].userId).toEqual(user2.id);
|
||||
expect(responseBody.data[0].teamId).toEqual(org.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,10 +169,16 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
.get(`/v2/organizations/${org.id}/memberships/${membership.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
const responseBody: GetOrgMembership = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membership.id);
|
||||
expect(responseBody.data.userId).toEqual(user.id);
|
||||
expect(responseBody.data.role).toEqual("ADMIN");
|
||||
expect(responseBody.data.user.bio).toEqual(bio);
|
||||
expect(responseBody.data.user.metadata).toEqual(metadata);
|
||||
expect(responseBody.data.user.email).toEqual(user.email);
|
||||
expect(responseBody.data.user.username).toEqual(user.username);
|
||||
expect(responseBody.data.teamId).toEqual(org.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,12 +192,16 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
} satisfies CreateOrgMembershipDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
const responseBody: CreateOrgMembershipOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.teamId).toEqual(org.id);
|
||||
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
|
||||
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
|
||||
expect(membershipCreatedViaApi.user.bio).toEqual(bio);
|
||||
expect(membershipCreatedViaApi.user.metadata).toEqual(metadata);
|
||||
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
|
||||
expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +213,7 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
} satisfies UpdateOrgMembershipDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
const responseBody: UpdateOrgMembership = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.role).toEqual("OWNER");
|
||||
@@ -178,7 +225,7 @@ describe("Organizations Memberships Endpoints", () => {
|
||||
.delete(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<Membership> = response.body;
|
||||
const responseBody: DeleteOrgMembership = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
|
||||
});
|
||||
|
||||
+6
-6
@@ -18,9 +18,9 @@ import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/o
|
||||
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
|
||||
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
|
||||
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
|
||||
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
|
||||
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
@@ -71,7 +71,7 @@ export class OrganizationsMembershipsController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: memberships.map((membership) =>
|
||||
plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" })
|
||||
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export class OrganizationsMembershipsController {
|
||||
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export class OrganizationsMembershipsController {
|
||||
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class OrganizationsMembershipsController {
|
||||
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export class OrganizationsMembershipsController {
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -1,6 +1,7 @@
|
||||
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
|
||||
@@ -15,6 +16,7 @@ export class OrganizationsMembershipRepository {
|
||||
id: membershipId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +26,7 @@ export class OrganizationsMembershipRepository {
|
||||
teamId: organizationId,
|
||||
userId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,6 +36,7 @@ export class OrganizationsMembershipRepository {
|
||||
id: membershipId,
|
||||
teamId: organizationId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,12 +45,14 @@ export class OrganizationsMembershipRepository {
|
||||
create: { ...data, teamId: organizationId },
|
||||
update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation },
|
||||
where: { userId_teamId: { userId: data.userId, teamId: organizationId } },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
|
||||
return this.dbWrite.prisma.membership.update({
|
||||
data: { ...data },
|
||||
where: { id: membershipId, teamId: organizationId },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,6 +61,7 @@ export class OrganizationsMembershipRepository {
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
@@ -11,10 +11,10 @@ export class CreateOrgMembershipOutput {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgMembershipOutputDto,
|
||||
type: TeamMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgMembershipOutputDto)
|
||||
data!: OrgMembershipOutputDto;
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
@@ -9,5 +9,5 @@ export class DeleteOrgMembership {
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
data!: OrgMembershipOutputDto;
|
||||
data!: TeamMembershipOutput;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user