Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95c48ac805 | ||
|
|
59ab38db98 | ||
|
|
11a41e0e39 | ||
|
|
880fe151c0 | ||
|
|
603f106389 | ||
|
|
5c7f1aebb2 | ||
|
|
a8fe3a6466 | ||
|
|
8974f8f9de | ||
|
|
a11c2cc52c | ||
|
|
14d319f992 | ||
|
|
49f0759f31 | ||
|
|
cae8470350 | ||
|
|
a82d616b79 | ||
|
|
515f3263e0 | ||
|
|
aebbc50ba6 | ||
|
|
7cdc78d02d | ||
|
|
df55dec553 | ||
|
|
398ad22868 | ||
|
|
7ef1050946 | ||
|
|
7b8bbaaa86 | ||
|
|
ddfd3fda70 | ||
|
|
5972a6f77f | ||
|
|
9bc703a437 | ||
|
|
15aaf7f51b | ||
|
|
c89a1877ae | ||
|
|
15d2c0f858 | ||
|
|
43a6663230 | ||
|
|
58315e96c9 | ||
|
|
3e466022b8 | ||
|
|
68c036cd0a | ||
|
|
77b656024a | ||
|
|
89772a0dac | ||
|
|
381c462660 | ||
|
|
417acdaacb | ||
|
|
1584dd3179 | ||
|
|
d66f983f56 | ||
|
|
b1f50fe22d | ||
|
|
ce995c39f2 | ||
|
|
b99ea16b31 | ||
|
|
574d8882f4 | ||
|
|
b73f7f4a28 | ||
|
|
e4ebe25626 | ||
|
|
7b6999831a | ||
|
|
0386723ae8 | ||
|
|
6615b885d1 | ||
|
|
254a2e310b | ||
|
|
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 |
@@ -441,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
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,8 +3,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
@@ -18,15 +17,8 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
let isColdStart = true;
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (isColdStart && IS_PRODUCTION) {
|
||||
console.log("Cold start of /v1/slots detected");
|
||||
isColdStart = false;
|
||||
}
|
||||
|
||||
const { usernameList, isTeamEvent, ...rest } = req.query;
|
||||
const parsedIsTeamEvent = String(isTeamEvent).toLowerCase() === "true";
|
||||
let slugs = usernameList;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.171",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.173",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
@@ -46,6 +46,7 @@
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||
"@nestjs/axios": "^4.0.0",
|
||||
"@nestjs/bull": "^10.1.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
|
||||
@@ -19,9 +19,14 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -38,6 +43,9 @@ import { Module } from "@nestjs/common";
|
||||
SchedulesModule_2024_04_15,
|
||||
EventTypesModule_2024_04_15,
|
||||
EventTypesModule_2024_06_14,
|
||||
StripeModule,
|
||||
TeamsModule,
|
||||
TeamsEventTypesModule,
|
||||
],
|
||||
providers: [
|
||||
TokensRepository,
|
||||
@@ -57,6 +65,8 @@ import { Module } from "@nestjs/common";
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
SelectedCalendarsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsRepository,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
|
||||
@@ -99,6 +99,14 @@ 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 belonging to an individual user:
|
||||
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.
|
||||
|
||||
And 2 ways to book and event type belonging to a team:
|
||||
1. Provide \`eventTypeId\` in the request body.
|
||||
2. Provide \`eventTypeSlug\` and \`teamSlug\` and optionally \`organizationSlug\` if the team with the teamSlug is within an organization.
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
@@ -252,7 +260,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,
|
||||
@@ -273,8 +282,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,
|
||||
@@ -294,8 +303,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,
|
||||
@@ -322,8 +331,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,
|
||||
@@ -343,8 +352,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,
|
||||
|
||||
@@ -39,7 +39,10 @@ import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Team bookings", () => {
|
||||
let app: INestApplication;
|
||||
const organizationSlug = `team-bookings-organization-${randomString()}`;
|
||||
let organization: Team;
|
||||
const team1Slug = `team-bookings-team1-${randomString()}`;
|
||||
const team2Slug = `team-bookings-team2-${randomString()}`;
|
||||
let team1: Team;
|
||||
let team2: Team;
|
||||
|
||||
@@ -95,12 +98,15 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `team-bookings-organization-${randomString()}`,
|
||||
name: organizationSlug,
|
||||
slug: organizationSlug,
|
||||
isOrganization: true,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
team1 = await teamRepositoryFixture.create({
|
||||
name: `team-bookings-team1-${randomString()}`,
|
||||
name: team1Slug,
|
||||
slug: team1Slug,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
createdByOAuthClient: {
|
||||
@@ -111,7 +117,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
|
||||
team2 = await teamRepositoryFixture.create({
|
||||
name: `team-bookings-team2-${randomString()}`,
|
||||
name: team2Slug,
|
||||
slug: team2Slug,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
createdByOAuthClient: {
|
||||
@@ -668,6 +675,84 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("book using teamSlug, eventTypeSlug and organizationSlug", () => {
|
||||
it("should not be able to book if missing organizationSlug", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
|
||||
teamSlug: team1Slug,
|
||||
eventTypeSlug: team1EventTypeSlug,
|
||||
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(404);
|
||||
});
|
||||
|
||||
it("should book using teamSlug and eventTypeSlug and organizationSlug", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
|
||||
teamSlug: team1Slug,
|
||||
eventTypeSlug: team1EventTypeSlug,
|
||||
organizationSlug: organizationSlug,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Madrid",
|
||||
language: "es",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(team1EventTypeId);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
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"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
|
||||
@@ -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";
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
@@ -66,6 +67,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 +130,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
|
||||
@@ -1663,6 +1666,133 @@ 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(FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE)).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";
|
||||
|
||||
@@ -8,7 +8,11 @@ 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 { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
@@ -45,7 +49,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 }[];
|
||||
@@ -61,6 +65,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");
|
||||
@@ -75,32 +81,62 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
) {}
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} 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") {
|
||||
@@ -111,9 +147,49 @@ 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
|
||||
);
|
||||
} else if (body.teamSlug && body.eventTypeSlug) {
|
||||
const team = await this.getBookedEventTypeTeam(body.teamSlug, body.organizationSlug);
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team with slug ${body.teamSlug} not found`);
|
||||
}
|
||||
return await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlugWithOwnerAndTeam(
|
||||
team.id,
|
||||
body.eventTypeSlug
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getBookedEventTypeTeam(teamSlug: string, organizationSlug: string | undefined) {
|
||||
if (!organizationSlug) {
|
||||
return await this.teamsRepository.findTeamBySlug(teamSlug);
|
||||
}
|
||||
|
||||
const organization = await this.organizationsRepository.findOrgBySlug(organizationSlug);
|
||||
if (!organization) {
|
||||
throw new NotFoundException(
|
||||
`slots-input.service.ts: Organization with slug ${organizationSlug} not found`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.organizationsTeamsRepository.findOrgTeamBySlug(organization.id, teamSlug);
|
||||
}
|
||||
|
||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||
const bookingFields = body.bookingFieldsResponses;
|
||||
if (!bookingFields || !eventType || !eventType.bookingFields) {
|
||||
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
|
||||
if (!eventType?.bookingFields) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -122,19 +198,32 @@ export class BookingsService_2024_08_13 {
|
||||
.parse(eventType.bookingFields)
|
||||
.filter((field) => !field.editable.startsWith("system"));
|
||||
|
||||
if (!eventTypeBookingFields.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const field of eventTypeBookingFields) {
|
||||
if (field.required && !(field.name in bookingFields)) {
|
||||
throw new BadRequestException(`
|
||||
Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses.
|
||||
You can fetch the event type with ID ${eventType.id} to see the required fields.`);
|
||||
if (field.name === "attendeePhoneNumber") {
|
||||
throw new BadRequestException(
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" in the request.`
|
||||
);
|
||||
}
|
||||
throw new BadRequestException(
|
||||
`Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -147,8 +236,12 @@ 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);
|
||||
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,
|
||||
@@ -164,8 +257,12 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
}
|
||||
|
||||
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||
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,
|
||||
@@ -181,8 +278,12 @@ export class BookingsService_2024_08_13 {
|
||||
);
|
||||
}
|
||||
|
||||
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
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,
|
||||
@@ -206,8 +307,12 @@ 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);
|
||||
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,
|
||||
@@ -541,13 +646,24 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||
|
||||
await roundRobinReassignment({
|
||||
bookingId: booking.id,
|
||||
orgId: profile?.organizationId || null,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
reassignedById: requestUser.id,
|
||||
});
|
||||
try {
|
||||
await roundRobinReassignment({
|
||||
bookingId: booking.id,
|
||||
orgId: profile?.organizationId || null,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
reassignedById: requestUser.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
throw new BadRequestException(
|
||||
"Can't reassign the booking because no other host is available at that time."
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||
if (!reassigned) {
|
||||
|
||||
@@ -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,
|
||||
@@ -95,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;
|
||||
@@ -124,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;
|
||||
@@ -199,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
|
||||
);
|
||||
|
||||
@@ -375,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`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("Platform Calendars Endpoints", () => {
|
||||
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect?redir=https://cal.com&isDryRun=false`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
BadRequestException,
|
||||
Post,
|
||||
Body,
|
||||
ParseBoolPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
@@ -148,13 +149,14 @@ export class CalendarsController {
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("calendar") calendar: string,
|
||||
@Query("redir") redir?: string | null
|
||||
@Query("redir") redir?: string | null,
|
||||
@Query("isDryRun", ParseBoolPipe) isDryRun?: boolean
|
||||
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.connect(authorization, req, redir ?? "");
|
||||
return await this.outlookService.connect(authorization, req, redir ?? "", isDryRun);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
|
||||
return await this.googleCalendarService.connect(authorization, req, redir ?? "", isDryRun);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
@@ -179,18 +181,30 @@ export class CalendarsController {
|
||||
): Promise<{ url: string }> {
|
||||
// state params contains our user access token
|
||||
const stateParams = new URLSearchParams(state);
|
||||
const { accessToken, origin, redir } = z
|
||||
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
|
||||
const { accessToken, origin, redir, isDryRun } = z
|
||||
.object({
|
||||
accessToken: z.string(),
|
||||
origin: z.string(),
|
||||
redir: z.string().nullish().optional(),
|
||||
isDryRun: z.string().nullish().optional(),
|
||||
})
|
||||
.parse({
|
||||
accessToken: stateParams.get("accessToken"),
|
||||
origin: stateParams.get("origin"),
|
||||
redir: stateParams.get("redir"),
|
||||
isDryRun: stateParams.get("isDryRun"),
|
||||
});
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
|
||||
return await this.googleCalendarService.save(
|
||||
code,
|
||||
accessToken,
|
||||
origin,
|
||||
redir ?? "",
|
||||
isDryRun === "true"
|
||||
);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
|
||||
@@ -39,31 +39,38 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request,
|
||||
redir?: string
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||
async save(
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: CALENDAR_SCOPES,
|
||||
prompt: "consent",
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
});
|
||||
|
||||
return authUrl;
|
||||
@@ -115,7 +122,8 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
) {
|
||||
// User chose not to authorize your app or didn't authorize your app
|
||||
// redirect directly without oauth code
|
||||
@@ -123,6 +131,11 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
// if isDryRun is true we know its a dry run so we just redirect straight away
|
||||
if (isDryRun) {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
@@ -33,24 +33,31 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request,
|
||||
redir?: string
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||
async save(
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||
@@ -60,7 +67,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
client_id,
|
||||
prompt: "select_account",
|
||||
redirect_uri: this.redirectUri,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
@@ -148,13 +155,19 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
) {
|
||||
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
|
||||
if (!code || code === "undefined") {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
// if isDryRun is true we know its a dry run so we just redirect straight away
|
||||
if (isDryRun) {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
@@ -111,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 } });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MeModule } from "@/ee/me/me.module";
|
||||
import { ProviderModule } from "@/ee/provider/provider.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
@@ -33,6 +34,7 @@ import { Module } from "@nestjs/common";
|
||||
SlotsModule_2024_04_15,
|
||||
SlotsModule_2024_09_04,
|
||||
TeamsModule,
|
||||
RoutingFormsModule,
|
||||
],
|
||||
})
|
||||
export class PlatformEndpointsModule implements NestModule {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -19,6 +20,7 @@ import { Module } from "@nestjs/common";
|
||||
providers: [
|
||||
EventTypesAtomService,
|
||||
ConferencingAtomsService,
|
||||
AttributesAtomsService,
|
||||
MembershipsRepository,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
|
||||
@@ -3,6 +3,8 @@ 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";
|
||||
@@ -34,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
|
||||
@@ -50,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")
|
||||
@@ -222,4 +227,25 @@ export class AtomsController {
|
||||
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: 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
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 { forwardRef, Module } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
@Module({
|
||||
@@ -29,28 +23,8 @@ 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 {}
|
||||
|
||||
@@ -6,15 +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 { forwardRef, Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
|
||||
imports: [PrismaModule, ConfigModule, HttpModule],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
|
||||
@@ -15,9 +15,8 @@ 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 { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
@@ -32,10 +31,10 @@ import {
|
||||
Delete,
|
||||
Headers,
|
||||
Redirect,
|
||||
UnauthorizedException,
|
||||
Req,
|
||||
HttpException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
@@ -60,9 +59,9 @@ export class ConferencingController {
|
||||
private readonly logger = new Logger("ConferencingController");
|
||||
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
private readonly config: ConfigService,
|
||||
private readonly httpService: HttpService
|
||||
) {}
|
||||
|
||||
@Post("/:app/connect")
|
||||
@@ -120,6 +119,14 @@ export class ConferencingController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles saving conferencing app credentials.
|
||||
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
|
||||
* otherwise, credentials are saved at the user level.
|
||||
*
|
||||
* Proxying ensures that permission checks—such as whether the user is allowed to install conferencing app for a team or organization—
|
||||
* are enforced via controller route guards, avoiding duplication of this logic within the service layer.
|
||||
*/
|
||||
@Get("/:app/oauth/callback")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@@ -143,23 +150,28 @@ export class ConferencingController {
|
||||
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
if (error) {
|
||||
throw new BadRequestException(error_description);
|
||||
}
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
if (decodedCallbackState.orgId) {
|
||||
return this.organizationsConferencingService.connectTeamOauthApps({
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
decodedCallbackState,
|
||||
});
|
||||
} else {
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
|
||||
|
||||
if (decodedCallbackState.teamId && decodedCallbackState.orgId) {
|
||||
const apiUrl = this.config.get("api.url");
|
||||
const url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/conferencing/${app}/oauth/callback`;
|
||||
const params: Record<string, string | undefined> = { state, code, error, error_description };
|
||||
const headers = {
|
||||
Authorization: `Bearer ${decodedCallbackState.accessToken}`,
|
||||
};
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.get(url, { params, headers });
|
||||
const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: redirectUrl };
|
||||
} catch (err) {
|
||||
const fallbackUrl = decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: fallbackUrl };
|
||||
}
|
||||
}
|
||||
|
||||
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException || error instanceof Error) {
|
||||
this.logger.error(error.message);
|
||||
|
||||
@@ -3,9 +3,15 @@ import { ConferencingRepository } from "@/modules/conferencing/repositories/conf
|
||||
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 { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
@@ -25,6 +31,7 @@ export class ConferencingService {
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
@@ -47,10 +54,13 @@ export class ConferencingService {
|
||||
async connectOauthApps(
|
||||
app: string,
|
||||
code: string,
|
||||
userId: number,
|
||||
decodedCallbackState: OAuthCallbackState,
|
||||
teamId?: number
|
||||
) {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
|
||||
|
||||
-2
@@ -16,8 +16,6 @@ export class DestinationCalendarsService {
|
||||
userId: number,
|
||||
delegationCredentialId?: string
|
||||
) {
|
||||
// note(Lauris): todo remove the log but leaving this now to confirm delegationCredentialId is received
|
||||
console.log("debug: delegationCredentialId", delegationCredentialId);
|
||||
const userCalendars = await this.calendarsService.getCalendars(userId);
|
||||
const allCalendars: Calendar[] = userCalendars.connectedCalendars
|
||||
.map((cal: ConnectedCalendar) => cal.calendars ?? [])
|
||||
|
||||
@@ -5,7 +5,9 @@ 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";
|
||||
@@ -28,7 +30,9 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
StripeModule,
|
||||
ConferencingModule,
|
||||
OrganizationsTeamsBookingsModule,
|
||||
OrganizationsUsersBookingsModule,
|
||||
OrganizationsBookingsModule,
|
||||
OrganizationsRoutingFormsModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
|
||||
+36
@@ -208,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())
|
||||
@@ -231,6 +235,8 @@ 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}`));
|
||||
@@ -272,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())
|
||||
@@ -333,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())
|
||||
@@ -479,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 () => {
|
||||
@@ -496,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 () => {
|
||||
@@ -518,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 () => {
|
||||
@@ -540,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 () => {
|
||||
@@ -576,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 () => {
|
||||
@@ -592,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 () => {
|
||||
@@ -612,6 +645,9 @@ 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}`));
|
||||
|
||||
+22
-29
@@ -12,6 +12,7 @@ import { TOKENS_DOCS } from "@/modules/oauth-clients/controllers/oauth-flow/oaut
|
||||
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";
|
||||
@@ -34,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";
|
||||
|
||||
@@ -55,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("/")
|
||||
@@ -70,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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,8 +97,11 @@ export class OAuthClientUsersController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
user: this.getResponseUser(user),
|
||||
...tokens,
|
||||
user: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
accessToken: tokens.accessToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: tokens.refreshToken,
|
||||
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt.valueOf(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -112,7 +118,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.getResponseUser(user),
|
||||
data: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,7 +138,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.getResponseUser(user),
|
||||
data: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,7 +157,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.getResponseUser(user),
|
||||
data: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,16 +174,19 @@ export class OAuthClientUsersController {
|
||||
): Promise<KeysResponseDto> {
|
||||
this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`);
|
||||
|
||||
await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens(
|
||||
userId,
|
||||
oAuthClientId
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.oAuthClientUsersService.getResponseOAuthTokens(accessToken, refreshToken),
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -18,6 +18,7 @@ import * as request from "supertest";
|
||||
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 { randomString } from "test/utils/randomString";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
@@ -204,12 +205,14 @@ describe("OAuthFlow Endpoints", () => {
|
||||
|
||||
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);
|
||||
|
||||
+2
-2
@@ -94,7 +94,7 @@ export class OAuthFlowController {
|
||||
throw new BadRequestException("Missing 'Bearer' Authorization header.");
|
||||
}
|
||||
|
||||
const tokens = await this.oAuthFlowService.exchangeOAuthClientAuthorizationToken(
|
||||
const tokens = await this.oAuthFlowService.exchangeAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
@@ -125,7 +125,7 @@ export class OAuthFlowController {
|
||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||
@Body() body: RefreshTokenInput
|
||||
): Promise<KeysResponseDto> {
|
||||
const tokens = await this.oAuthFlowService.refreshUserTokens(clientId, secretKey, body.refreshToken);
|
||||
const tokens = await this.oAuthFlowService.refreshToken(clientId, secretKey, body.refreshToken);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,12 @@ import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_
|
||||
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 { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ConflictException, Injectable, Logger } from "@nestjs/common";
|
||||
import { User, CreationSource, PlatformOAuthClient, AccessToken, RefreshToken } from "@prisma/client";
|
||||
import { User, CreationSource, PlatformOAuthClient } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@@ -43,7 +42,7 @@ export class OAuthClientUsersService {
|
||||
);
|
||||
} else {
|
||||
const email = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email);
|
||||
user = (
|
||||
const createdUser = (
|
||||
await createNewUsersConnectToOrgIfExists({
|
||||
invitations: [
|
||||
{
|
||||
@@ -69,21 +68,18 @@ export class OAuthClientUsersService {
|
||||
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 } = await this.tokensRepository.createOAuthTokens(
|
||||
user.id,
|
||||
oAuthClientId
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, user.id);
|
||||
|
||||
if (oAuthClient.areDefaultEventTypesEnabled) {
|
||||
await this.eventTypesService.createUserDefaultEventTypes(user.id);
|
||||
@@ -103,16 +99,12 @@ export class OAuthClientUsersService {
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: this.getResponseOAuthTokens(accessToken, refreshToken),
|
||||
};
|
||||
}
|
||||
|
||||
getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto {
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
refreshToken: refreshToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
|
||||
tokens: {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception";
|
||||
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
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";
|
||||
@@ -7,7 +10,6 @@ import { BadRequestException, Injectable, Logger, UnauthorizedException } from "
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
|
||||
import { AccessToken, RefreshToken } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthFlowService {
|
||||
@@ -57,8 +59,8 @@ export class OAuthFlowService {
|
||||
|
||||
if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB");
|
||||
|
||||
// await in case of race conditions
|
||||
await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb); // expires in 1 hour
|
||||
// await in case of race conditions, but void it's return since cache writes shouldn't halt execution.
|
||||
void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour
|
||||
|
||||
return ownerIdFromDb;
|
||||
}
|
||||
@@ -83,8 +85,9 @@ export class OAuthFlowService {
|
||||
}
|
||||
|
||||
// we can't use a Promise#all or similar here because we care about execution order
|
||||
await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() });
|
||||
await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000));
|
||||
// however we can't allow caches to fail a validation hence the results are voided.
|
||||
void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() }));
|
||||
void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000)));
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -100,7 +103,7 @@ export class OAuthFlowService {
|
||||
return { status: "CACHE_MISS", cacheKey };
|
||||
}
|
||||
|
||||
async exchangeOAuthClientAuthorizationToken(
|
||||
async exchangeAuthorizationToken(
|
||||
tokenId: string,
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
@@ -121,50 +124,46 @@ export class OAuthFlowService {
|
||||
throw new BadRequestException("Invalid Authorization Token.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
|
||||
authorizationToken.owner.id,
|
||||
clientId
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(clientId, authorizationToken.owner.id);
|
||||
await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id);
|
||||
await this.propagateAccessToken(accessToken.secret);
|
||||
void this.propagateAccessToken(accessToken); // void result, ignored.
|
||||
|
||||
return this.getResponseOAuthTokens(accessToken, refreshToken);
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
async refreshUserTokens(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
userRefreshTokenSecret: string
|
||||
): Promise<KeysDto> {
|
||||
async refreshToken(clientId: string, clientSecret: string, tokenSecret: string): Promise<KeysDto> {
|
||||
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret(
|
||||
clientId,
|
||||
clientSecret,
|
||||
userRefreshTokenSecret
|
||||
tokenSecret
|
||||
);
|
||||
|
||||
if (!oauthClient) {
|
||||
throw new BadRequestException("Invalid OAuthClient credentials.");
|
||||
}
|
||||
|
||||
const currentUserRefreshToken = oauthClient.refreshToken[0];
|
||||
const currentRefreshToken = oauthClient.refreshToken[0];
|
||||
|
||||
if (!currentUserRefreshToken) {
|
||||
throw new BadRequestException("Invalid managed user refresh token");
|
||||
if (!currentRefreshToken) {
|
||||
throw new BadRequestException("Invalid refresh token");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens(
|
||||
currentUserRefreshToken.userId,
|
||||
clientId
|
||||
clientId,
|
||||
currentRefreshToken.secret,
|
||||
currentRefreshToken.userId
|
||||
);
|
||||
|
||||
return this.getResponseOAuthTokens(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto {
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
refreshToken: refreshToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
|
||||
refreshToken: refreshToken.secret,
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsOptional, IsArray, ArrayMinSize, IsString, IsNumber } from "class-validator";
|
||||
|
||||
export class GetAssignedAttributeOptions {
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of responses to skip" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
skip?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of responses to take" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
take?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((optId: string) => optId);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: "Filter by assigned attribute option ids. ids must be separated by a comma.",
|
||||
example: "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMinSize(1, { message: "assignedOptionIds must contain at least 1 attribute option id" })
|
||||
assignedOptionIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((teamId: string) => parseInt(teamId));
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@ApiPropertyOptional({
|
||||
type: [Number],
|
||||
description: "Filter by teamIds. Team ids must be separated by a comma.",
|
||||
example: "?teamIds=100,200",
|
||||
})
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
|
||||
teamIds?: number[];
|
||||
}
|
||||
+78
@@ -7,6 +7,8 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
import { GetOrganizationAttributeAssignedOptionsProp } from "./services/organization-attributes-option.service";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributeOptionRepository {
|
||||
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
|
||||
@@ -90,6 +92,82 @@ export class OrganizationAttributeOptionRepository {
|
||||
return options;
|
||||
}
|
||||
|
||||
async getOrganizationAttributeAssignedOptions({
|
||||
attributeId,
|
||||
attributeSlug,
|
||||
filters,
|
||||
organizationId,
|
||||
skip,
|
||||
take,
|
||||
}: GetOrganizationAttributeAssignedOptionsProp) {
|
||||
const options = await this.dbRead.prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: {
|
||||
...(attributeId && { id: attributeId }),
|
||||
...(attributeSlug && { slug: attributeSlug }),
|
||||
teamId: organizationId,
|
||||
},
|
||||
assignedUsers: {
|
||||
some: {}, // empty {} statement checks if option is assigned to at least one user
|
||||
...(filters?.teamIds && {
|
||||
some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: { assignedUsers: { include: { member: true } } },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
// only return options that are assigned to users alongside assignedOptionIds filter
|
||||
if (filters?.assignedOptionIds) {
|
||||
const filteredAssignedOptions = await this.dbRead.prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: { teamId: organizationId },
|
||||
assignedUsers: {
|
||||
every: {
|
||||
attributeOptionId: { in: filters?.assignedOptionIds },
|
||||
},
|
||||
...(filters?.teamIds && {
|
||||
some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: { assignedUsers: { include: { member: true } } },
|
||||
});
|
||||
|
||||
if (!filteredAssignedOptions?.length) {
|
||||
throw new NotFoundException(
|
||||
"Options provided in assignedOptionIds are not assigned to anyone, or the users are not part of the teams specified in teamIds filter."
|
||||
);
|
||||
}
|
||||
|
||||
const matchingUserIds = filteredAssignedOptions.flatMap((opt) =>
|
||||
opt.assignedUsers.flatMap((assignedUser) => assignedUser.member.userId)
|
||||
);
|
||||
// reduce remove options that are not assigned alongside assignedOptionIds filter
|
||||
return options.reduce((acc, opt) => {
|
||||
if (opt.assignedUsers.some((assignedUser) => matchingUserIds.includes(assignedUser.member.userId))) {
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
...opt,
|
||||
assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId),
|
||||
},
|
||||
];
|
||||
}
|
||||
return acc;
|
||||
}, [] as typeof options);
|
||||
}
|
||||
|
||||
return options.map((opt) => {
|
||||
return {
|
||||
...opt,
|
||||
assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async assignOrganizationAttributeOptionToUser({
|
||||
organizationId,
|
||||
membershipId,
|
||||
|
||||
+233
-8
@@ -3,6 +3,8 @@ import { AppModule } from "@/app.module";
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
||||
import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
|
||||
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -13,12 +15,13 @@ import * as request from "supertest";
|
||||
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
||||
import { User, Team, Membership, Attribute, AttributeOption } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Attributes Options Endpoints", () => {
|
||||
describe("User lacks required role", () => {
|
||||
@@ -95,19 +98,37 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixtures: TeamRepositoryFixture;
|
||||
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let attributeRepositoryFixture: AttributeRepositoryFixture;
|
||||
|
||||
const userEmail = `organization-attributes-options-admin-${randomString()}@api.com`;
|
||||
const userEmail2 = `organization-attributes-options-member-${randomString()}@api.com`;
|
||||
|
||||
let user: User;
|
||||
let user2: User;
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let membership: Membership;
|
||||
let membership2: Membership;
|
||||
|
||||
let attributeId: string;
|
||||
let attributeSlug: string;
|
||||
|
||||
let createdOption: any;
|
||||
let createdOption2: any;
|
||||
let attribute2: Attribute;
|
||||
let attribute2Option: AttributeOption;
|
||||
|
||||
const createOptionInput: CreateOrganizationAttributeOptionInput = {
|
||||
value: "option1",
|
||||
slug: "option1",
|
||||
slug: `option1-${randomString()}`,
|
||||
};
|
||||
|
||||
const createOption2Input: CreateOrganizationAttributeOptionInput = {
|
||||
value: "option2",
|
||||
slug: `option2${randomString()}`,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -120,6 +141,7 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||
|
||||
@@ -128,23 +150,50 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } });
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
user2 = await userRepositoryFixture.create({
|
||||
email: userEmail2,
|
||||
username: userEmail2,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
membership2 = await membershipFixtures.addUserToOrg(user2, org, "ADMIN", true);
|
||||
await membershipFixtures.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
team: { connect: { id: team.id } },
|
||||
user: { connect: { id: user.id } },
|
||||
});
|
||||
|
||||
// Create an attribute for testing
|
||||
const attribute = await attributeRepositoryFixture.create({
|
||||
name: "Test Attribute",
|
||||
team: { connect: { id: org.id } },
|
||||
type: "TEXT",
|
||||
slug: "test-attribute",
|
||||
slug: `test-attribute-${randomString()}`,
|
||||
});
|
||||
|
||||
attribute2 = await attributeRepositoryFixture.create({
|
||||
name: "Test Attribute 2",
|
||||
team: { connect: { id: org.id } },
|
||||
type: "TEXT",
|
||||
slug: `test-attribute-2-${randomString()}`,
|
||||
});
|
||||
attributeId = attribute.id;
|
||||
|
||||
attributeSlug = attribute.slug;
|
||||
attribute2Option = await attributeRepositoryFixture.createOption({
|
||||
slug: `optionA-${randomString()}`,
|
||||
value: "optionA",
|
||||
attribute: { connect: { id: attribute2.id } },
|
||||
});
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -164,16 +213,31 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.send(createOption2Input)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
createdOption2 = response.body.data;
|
||||
expect(createdOption2.value).toEqual(createOption2Input.value);
|
||||
expect(createdOption2.slug).toEqual(createOption2Input.slug);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const options = response.body.data;
|
||||
expect(options.length).toEqual(1);
|
||||
expect(options[0].value).toEqual(createOptionInput.value);
|
||||
expect(options[0].slug).toEqual(createOptionInput.slug);
|
||||
const options = response.body.data as OptionOutput[];
|
||||
expect(options.length).toEqual(2);
|
||||
expect(options.find((opt) => opt.value === createOptionInput.value)).toBeDefined();
|
||||
expect(options.find((opt) => opt.slug === createOptionInput.slug)).toBeDefined();
|
||||
expect(options.find((opt) => opt.value === createOption2Input.value)).toBeDefined();
|
||||
expect(options.find((opt) => opt.slug === createOption2Input.slug)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +273,38 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign attribute option to user2", async () => {
|
||||
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
|
||||
attributeId: attributeId,
|
||||
attributeOptionId: createdOption2.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
|
||||
.send(assignInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign attribute option to user2", async () => {
|
||||
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
|
||||
attributeId: attribute2.id,
|
||||
attributeOptionId: attribute2Option.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
|
||||
.send(assignInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options for user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
|
||||
@@ -221,6 +317,133 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options for user2", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const userOptions = response.body.data as AttributeOption[];
|
||||
expect(userOptions.length).toEqual(2);
|
||||
expect(userOptions.find((opt) => opt.id === createdOption2.id)).toBeDefined();
|
||||
expect(userOptions.find((opt) => opt.id === attribute2Option.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(2);
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user.id)
|
||||
).toBeDefined();
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption2.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user2.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options filtered by team id", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?teamIds=${team.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(1);
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options by attribute slug", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/slugs/${attributeSlug}/options/assigned`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(2);
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user.id)
|
||||
).toBeDefined();
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption2.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user2.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options filtered by other assigned options", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}`
|
||||
)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(1);
|
||||
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption2.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user2.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not get attribute all assigned options filtered by other assigned options and teamIds in which the user is not part of", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}&teamIds=${team.id}`
|
||||
)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options filtered by other assigned options and teamIds", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${createdOption.id}&teamIds=${team.id}`
|
||||
)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(1);
|
||||
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should unassign attribute option from user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
|
||||
@@ -243,7 +466,9 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await membershipFixtures.delete(membership2.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+62
-1
@@ -8,19 +8,32 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
|
||||
import { GetAssignedAttributeOptions } from "@/modules/organizations/attributes/options/inputs/get-assigned-attribute-options.input";
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
||||
import {
|
||||
AssignOptionUserOutput,
|
||||
UnassignOptionUserOutput,
|
||||
} from "@/modules/organizations/attributes/options/outputs/assign-option-user.output";
|
||||
import { GetAllAttributeAssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
|
||||
import { CreateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/create-option.output";
|
||||
import { DeleteAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/delete-option.output";
|
||||
import { GetOptionUserOutput } from "@/modules/organizations/attributes/options/outputs/get-option-user.output";
|
||||
import { GetAllAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/get-option.output";
|
||||
import { UpdateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/update-option.output";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -118,6 +131,54 @@ export class OrganizationsAttributesOptionsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/:attributeId/options/assigned")
|
||||
@ApiOperation({ summary: "Get by attribute id all of the attribute options that are assigned to users" })
|
||||
async getOrganizationAttributeAssignedOptions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Query() queryParams: GetAssignedAttributeOptions
|
||||
): Promise<GetAllAttributeAssignedOptionOutput> {
|
||||
const { skip, take, ...rest } = queryParams;
|
||||
const attributeOptions =
|
||||
await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({
|
||||
organizationId: orgId,
|
||||
attributeId,
|
||||
skip: skip ?? 0,
|
||||
take: take ?? 250,
|
||||
filters: rest,
|
||||
});
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/slugs/:attributeSlug/options/assigned")
|
||||
@ApiOperation({ summary: "Get by attribute slug all of the attribute options that are assigned to users" })
|
||||
async getOrganizationAttributeAssignedOptionsBySlug(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeSlug") attributeSlug: string,
|
||||
@Query() queryParams: GetAssignedAttributeOptions
|
||||
): Promise<GetAllAttributeAssignedOptionOutput> {
|
||||
const { skip, take, ...rest } = queryParams;
|
||||
const attributeOptions =
|
||||
await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({
|
||||
organizationId: orgId,
|
||||
attributeSlug,
|
||||
skip: skip ?? 0,
|
||||
take: take ?? 250,
|
||||
filters: rest,
|
||||
});
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/attributes/options/:userId")
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsArray, ValidateNested } from "class-validator";
|
||||
|
||||
export class AssignedOptionOutput extends OptionOutput {
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ApiProperty({
|
||||
type: Array,
|
||||
required: true,
|
||||
description: "Ids of the users assigned to the attribute option.",
|
||||
example: [124, 224],
|
||||
})
|
||||
assignedUserIds!: number[];
|
||||
}
|
||||
|
||||
export class GetAllAttributeAssignedOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: AssignedOptionOutput[];
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class OptionOutput {
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
@@ -12,10 +13,12 @@ export class OptionOutput {
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
|
||||
attributeId!: string;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
@@ -25,6 +28,7 @@ export class OptionOutput {
|
||||
})
|
||||
value!: string;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
|
||||
+43
@@ -3,8 +3,10 @@ import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
||||
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
|
||||
import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
|
||||
|
||||
@@ -111,4 +113,45 @@ export class OrganizationAttributeOptionService {
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async getOrganizationAttributeAssignedOptions({
|
||||
organizationId,
|
||||
attributeId,
|
||||
attributeSlug,
|
||||
skip,
|
||||
take,
|
||||
filters,
|
||||
}: GetOrganizationAttributeAssignedOptionsProp) {
|
||||
const options = await this.organizationAttributeOptionRepository.getOrganizationAttributeAssignedOptions({
|
||||
organizationId,
|
||||
...(attributeId ? { attributeId } : { attributeSlug }),
|
||||
skip,
|
||||
take,
|
||||
filters,
|
||||
});
|
||||
return options.map((opt) => plainToClass(AssignedOptionOutput, opt, { strategy: "excludeAll" }));
|
||||
}
|
||||
}
|
||||
|
||||
// Discriminative Union on attributeSlug / attributeId
|
||||
export type GetOrganizationAttributeAssignedOptionsProp =
|
||||
| GetOrganizationAttributeAssignedOptionsPropById
|
||||
| GetOrganizationAttributeAssignedOptionsPropBySlug;
|
||||
|
||||
type GetOrganizationAttributeAssignedOptionsPropById = {
|
||||
organizationId: number;
|
||||
attributeId: string;
|
||||
attributeSlug?: undefined;
|
||||
skip: number;
|
||||
take: number;
|
||||
filters?: { assignedOptionIds?: string[]; teamIds?: number[] };
|
||||
};
|
||||
|
||||
type GetOrganizationAttributeAssignedOptionsPropBySlug = {
|
||||
organizationId: number;
|
||||
attributeId?: undefined;
|
||||
attributeSlug?: string;
|
||||
skip: number;
|
||||
take: number;
|
||||
filters?: { assignedOptionIds?: string[]; teamIds?: number[] };
|
||||
};
|
||||
|
||||
+13
-10
@@ -1,5 +1,11 @@
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
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";
|
||||
@@ -8,15 +14,12 @@ import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.g
|
||||
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 { ApiHeader, 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";
|
||||
import { GetBookingsOutput_2024_08_13, GetOrganizationsBookingsInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/bookings",
|
||||
@@ -24,14 +27,14 @@ import { GetOrganizationsBookingsInput } from "./inputs/get-org-bookings.input";
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Bookings")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
export class OrganizationsBookingsController {
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly orgUsersService: OrganizationsUsersService
|
||||
) {}
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get organization team bookings" })
|
||||
@ApiOperation({ summary: "Get Organization Bookings" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
+44
-2
@@ -22,6 +22,7 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci
|
||||
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 { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -36,10 +37,13 @@ import {
|
||||
Headers,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
BadRequestException,
|
||||
Redirect,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
|
||||
@@ -56,11 +60,12 @@ export type OAuthCallbackState = {
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Organizations/Teams Conferencing")
|
||||
@DocsTags("Orgs / Teams / Conferencing")
|
||||
export class OrganizationsConferencingController {
|
||||
constructor(
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -221,4 +226,41 @@ export class OrganizationsConferencingController {
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/:app/oauth/callback")
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save conferencing app OAuth credentials" })
|
||||
async saveTeamOauthCredentials(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<{ url: string }> {
|
||||
if (!state) {
|
||||
throw new BadRequestException("Missing `state` query param");
|
||||
}
|
||||
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
return await this.organizationsConferencingService.connectTeamOauthApps({
|
||||
decodedCallbackState,
|
||||
code,
|
||||
app,
|
||||
teamId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return {
|
||||
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
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 { 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 { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule, ConferencingModule],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
GoogleMeetService,
|
||||
UsersRepository,
|
||||
TeamsRepository,
|
||||
OrganizationsConferencingService,
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
CredentialsRepository,
|
||||
TokensRepository,
|
||||
AppsRepository,
|
||||
OrganizationsRepository,
|
||||
StripeService,
|
||||
MembershipsRepository,
|
||||
RedisService,
|
||||
OrganizationsTeamsRepository,
|
||||
],
|
||||
exports: [OrganizationsConferencingService],
|
||||
controllers: [OrganizationsConferencingController],
|
||||
})
|
||||
export class OrganizationsConferencingModule {}
|
||||
+5
-163
@@ -1,10 +1,3 @@
|
||||
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";
|
||||
@@ -13,12 +6,7 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
||||
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 { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
@@ -35,129 +23,9 @@ export class OrganizationsConferencingService {
|
||||
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
|
||||
private readonly conferencingService: ConferencingService
|
||||
) {}
|
||||
|
||||
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:
|
||||
@@ -171,40 +39,14 @@ export class OrganizationsConferencingService {
|
||||
decodedCallbackState,
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
teamId,
|
||||
}: {
|
||||
userId: number;
|
||||
app: string;
|
||||
decodedCallbackState: OAuthCallbackState;
|
||||
code: string;
|
||||
teamId: number;
|
||||
}) {
|
||||
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);
|
||||
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState, teamId);
|
||||
}
|
||||
|
||||
async getConferencingApps({ teamId }: { teamId: number }) {
|
||||
|
||||
+4
-17
@@ -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,7 +244,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);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ export class OrganizationsEventTypesController {
|
||||
|
||||
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/event-types")
|
||||
@ApiOperation({ summary: "Get a team event type" })
|
||||
@ApiOperation({ summary: "Get team event types" })
|
||||
async getTeamEventTypes(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||
|
||||
+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}`)
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
+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, IsArray } from "class-validator";
|
||||
@@ -11,11 +11,11 @@ export class GetAllOrgMemberships {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgMembershipOutputDto,
|
||||
type: TeamMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrgMembershipOutputDto)
|
||||
@Type(() => TeamMembershipOutput)
|
||||
@IsArray()
|
||||
data!: OrgMembershipOutputDto[];
|
||||
data!: TeamMembershipOutput[];
|
||||
}
|
||||
|
||||
@@ -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 GetOrgMembership {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgMembershipOutputDto,
|
||||
type: TeamMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgMembershipOutputDto)
|
||||
data!: OrgMembershipOutputDto;
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class OrgMembershipOutputDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
readonly id!: number;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
readonly userId!: number;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
readonly teamId!: number;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
readonly accepted!: boolean;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
|
||||
@Expose()
|
||||
readonly role!: MembershipRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly disableImpersonation?: boolean;
|
||||
}
|
||||
+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 UpdateOrgMembership {
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: OrgMembershipOutputDto,
|
||||
type: TeamMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgMembershipOutputDto)
|
||||
data!: OrgMembershipOutputDto;
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
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 { 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 { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
@@ -14,6 +19,7 @@ import { OrganizationAttributeOptionRepository } from "@/modules/organizations/a
|
||||
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
|
||||
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
|
||||
@@ -31,13 +37,15 @@ import { OrganizationsOrganizationsModule } from "@/modules/organizations/organi
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
|
||||
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
||||
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
|
||||
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms-responses.module";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
|
||||
import { OrganizationsUsersController } from "@/modules/organizations/users/index/controllers/organizations-users.controller";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
|
||||
@@ -50,13 +58,15 @@ import { OrganizationsWebhooksRepository } from "@/modules/organizations/webhook
|
||||
import { OrganizationsWebhooksService } from "@/modules/organizations/webhooks/services/organizations-webhooks.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -69,11 +79,11 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_06_14,
|
||||
TeamsEventTypesModule,
|
||||
TeamsModule,
|
||||
forwardRef(() => ConferencingModule),
|
||||
forwardRef(() => AuthModule),
|
||||
OrganizationsDelegationCredentialModule,
|
||||
OrganizationsOrganizationsModule,
|
||||
OrganizationsStripeModule,
|
||||
OrganizationsTeamsRoutingFormsModule,
|
||||
OrganizationsConferencingModule,
|
||||
],
|
||||
providers: [
|
||||
OrganizationsRepository,
|
||||
@@ -108,6 +118,16 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
OrgUsersOOOService,
|
||||
OrgUsersOOORepository,
|
||||
OrganizationsConferencingService,
|
||||
OrganizationsStripeService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
RedisService,
|
||||
ConferencingRepository,
|
||||
GoogleMeetService,
|
||||
ConferencingService,
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
TokensRepository,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
@@ -129,6 +149,7 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
WebhooksService,
|
||||
OrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
OrganizationsStripeService,
|
||||
],
|
||||
controllers: [
|
||||
OrganizationsTeamsController,
|
||||
@@ -142,7 +163,6 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
OrganizationsWebhooksController,
|
||||
OrganizationsTeamsSchedulesController,
|
||||
OrganizationsUsersOOOController,
|
||||
OrganizationsConferencingController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ const SCALE = "SCALE";
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Orgs")
|
||||
@DocsTags("Managed Orgs")
|
||||
@ApiHeader(X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(X_CAL_SECRET_KEY_HEADER)
|
||||
export class OrganizationsOrganizationsController {
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { App_RoutingForms_Form, App_RoutingForms_FormResponse, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.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 { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
let app: INestApplication;
|
||||
let prismaWriteService: PrismaWriteService;
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let apiKeyString: string;
|
||||
let routingForm: App_RoutingForms_Form;
|
||||
let routingFormResponse: App_RoutingForms_FormResponse;
|
||||
let routingFormResponse2: App_RoutingForms_FormResponse;
|
||||
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
|
||||
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `OrganizationsRoutingFormsResponsesController-teams-memberships-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() + 1);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `${keyString}`;
|
||||
|
||||
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: JSON.stringify([]),
|
||||
fields: JSON.stringify([]),
|
||||
settings: JSON.stringify({}),
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
response: { question1: "answer1", question2: "answer2" },
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
|
||||
where: {
|
||||
formId: routingForm.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.apiKey.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
||||
it("should not get routing form responses for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not get routing form responses for non existing form", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not get routing form responses without authentication", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should get routing form responses", async () => {
|
||||
const createdAt = new Date(routingFormResponse.createdAt);
|
||||
createdAt.setHours(createdAt.getHours() - 1);
|
||||
const isoStringCreatedAt = createdAt.toISOString();
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc&afterCreatedAt=${isoStringCreatedAt}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responses = responseBody.data as App_RoutingForms_FormResponse[];
|
||||
expect(responses).toBeDefined();
|
||||
expect(responses.length).toBeGreaterThan(0);
|
||||
expect(responses.find((response) => response.id === routingFormResponse.id)).toBeDefined();
|
||||
expect(responses.find((response) => response.id === routingFormResponse.id)?.formId).toEqual(
|
||||
routingFormResponse.formId
|
||||
);
|
||||
expect(responses.find((response) => response.id === routingFormResponse2.id)).toBeDefined();
|
||||
expect(responses.find((response) => response.id === routingFormResponse2.id)?.formId).toEqual(
|
||||
routingFormResponse2.formId
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
|
||||
it("should not update routing form response for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not update routing form response for non existing form", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${routingFormResponse.id}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not update routing form response for non existing response", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/99999`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not update routing form response without authentication", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
|
||||
)
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should update routing form response", async () => {
|
||||
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: updatedResponse })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toEqual(routingFormResponse.id);
|
||||
expect(data.formId).toEqual(routingFormResponse.formId);
|
||||
expect(data.response).toEqual(updatedResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.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 { GetRoutingFormResponsesOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-form-responses.output";
|
||||
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
|
||||
import { Body, Controller, Get, Param, Patch, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
|
||||
import { UpdateRoutingFormResponseInput } from "../inputs/update-routing-form-response.input";
|
||||
import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-response.output";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/routing-forms/:routingFormId/responses",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@ApiTags("Orgs / Routing forms")
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OrganizationsRoutingFormsResponsesController {
|
||||
constructor(
|
||||
private readonly organizationsRoutingFormsResponsesService: OrganizationsRoutingFormsResponsesService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get routing form responses" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getRoutingFormResponses(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("routingFormId") routingFormId: string,
|
||||
@Query() queryParams: GetRoutingFormResponsesParams
|
||||
): Promise<GetRoutingFormResponsesOutput> {
|
||||
const { skip, take, ...filters } = queryParams;
|
||||
|
||||
const responses =
|
||||
await this.organizationsRoutingFormsResponsesService.getOrganizationRoutingFormResponses(
|
||||
orgId,
|
||||
routingFormId,
|
||||
skip ?? 0,
|
||||
take ?? 250,
|
||||
filters
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: responses,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:responseId")
|
||||
@ApiOperation({ summary: "Update routing form response" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async updateRoutingFormResponse(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("routingFormId") routingFormId: string,
|
||||
@Param("responseId", ParseIntPipe) responseId: number,
|
||||
@Body() updateRoutingFormResponseInput: UpdateRoutingFormResponseInput
|
||||
): Promise<UpdateRoutingFormResponseOutput> {
|
||||
const updatedResponse = await this.organizationsRoutingFormsResponsesService.updateRoutingFormResponse(
|
||||
orgId,
|
||||
routingFormId,
|
||||
responseId,
|
||||
updateRoutingFormResponseInput
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: updatedResponse,
|
||||
};
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { GetRoutingFormsOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { App_RoutingForms_Form, App_RoutingForms_FormResponse, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.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 { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
describe("OrganizationsRoutingFormController", () => {
|
||||
let app: INestApplication;
|
||||
let prismaWriteService: PrismaWriteService;
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let apiKeyString: string;
|
||||
let routingForm: App_RoutingForms_Form;
|
||||
let routingFormResponse: App_RoutingForms_FormResponse;
|
||||
let routingFormResponse2: App_RoutingForms_FormResponse;
|
||||
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
|
||||
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `OrganizationsRoutingFormsResponsesController-teams-memberships-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `${keyString}`;
|
||||
|
||||
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [{ redirect: "http://google.com" }],
|
||||
fields: [{ territory: "input" }],
|
||||
settings: { test: "true" },
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.apiKey.deleteMany({
|
||||
where: {
|
||||
teamId: org.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: org.id,
|
||||
},
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe(`GET /v2/organizations/:orgId/routing-forms`, () => {
|
||||
it("should not get routing forms for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/99999/routing-forms`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not get routing forms without authentication", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/routing-forms`).expect(401);
|
||||
});
|
||||
|
||||
it("should get organization routing forms", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms?skip=0&take=1`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const routingForms = responseBody.data;
|
||||
expect(routingForms).toBeDefined();
|
||||
expect(routingForms.length).toBeGreaterThan(0);
|
||||
expect(routingForms[0].id).toEqual(routingForm.id);
|
||||
expect(routingForms[0].name).toEqual(routingForm.name);
|
||||
expect(routingForms[0].description).toEqual(routingForm.description);
|
||||
expect(routingForms[0].disabled).toEqual(routingForm.disabled);
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter routing forms by name", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms?name=Test`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const routingForms = responseBody.data;
|
||||
expect(routingForms).toBeDefined();
|
||||
expect(routingForms.length).toBeGreaterThan(0);
|
||||
expect(routingForms[0].name).toContain("Test");
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter routing forms by disabled status", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms?disabled=false`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const routingForms = responseBody.data;
|
||||
expect(routingForms).toBeDefined();
|
||||
expect(routingForms.length).toBeGreaterThan(0);
|
||||
expect(routingForms[0].disabled).toEqual(false);
|
||||
expect(routingForms[0].fields?.[0]).toEqual({ territory: "input" });
|
||||
expect(routingForms[0].routes?.[0]).toEqual({ redirect: "http://google.com" });
|
||||
expect(routingForms[0].settings).toEqual({ test: "true" });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.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 { GetRoutingFormsParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
||||
import {
|
||||
GetRoutingFormsOutput,
|
||||
RoutingFormOutput,
|
||||
} from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
|
||||
import { OrganizationsRoutingFormsService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms.service";
|
||||
import { Controller, Get, Param, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/routing-forms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@ApiTags("Orgs / Routing forms")
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OrganizationsRoutingFormsController {
|
||||
constructor(private readonly organizationsRoutingFormsService: OrganizationsRoutingFormsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get organization routing forms" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getOrganizationRoutingForms(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: GetRoutingFormsParams
|
||||
): Promise<GetRoutingFormsOutput> {
|
||||
const { skip, take, ...filters } = queryParams;
|
||||
|
||||
const routingForms = await this.organizationsRoutingFormsService.getOrganizationRoutingForms(
|
||||
orgId,
|
||||
skip ?? 0,
|
||||
take ?? 250,
|
||||
filters
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: routingForms.map((form) => plainToClass(RoutingFormOutput, form)),
|
||||
};
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsOptional, IsString, IsEnum, IsISO8601, IsNumber, IsArray, ArrayMinSize } from "class-validator";
|
||||
|
||||
enum SortOrder {
|
||||
ASC = "asc",
|
||||
DESC = "desc",
|
||||
}
|
||||
|
||||
export class GetRoutingFormResponsesParams {
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of responses to skip" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
skip?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of responses to take" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
take?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by creation time" })
|
||||
@IsOptional()
|
||||
@IsEnum(SortOrder)
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by update time" })
|
||||
@IsOptional()
|
||||
@IsEnum(SortOrder)
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by responses created after this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
afterCreatedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by responses created before this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
beforeCreatedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by responses created after this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
afterUpdatedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by responses updated before this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
beforeUpdatedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: String, description: "Filter by responses routed to a specific booking" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
|
||||
export class GetRoutingFormsParams extends GetRoutingFormResponsesParams {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((teamId: string) => parseInt(teamId));
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@ApiPropertyOptional({
|
||||
type: [Number],
|
||||
description: "Filter by teamIds. Team ids must be separated by a comma.",
|
||||
example: "?teamIds=100,200",
|
||||
})
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
|
||||
teamIds?: number[];
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsOptional, IsBoolean, IsString, IsEnum, IsDate, IsISO8601 } from "class-validator";
|
||||
|
||||
enum SortOrder {
|
||||
ASC = "asc",
|
||||
DESC = "desc",
|
||||
}
|
||||
|
||||
export class GetRoutingFormsParams {
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of routing forms to skip" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
skip?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of routing forms to take" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
take?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by creation time" })
|
||||
@IsOptional()
|
||||
@IsEnum(SortOrder)
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by update time" })
|
||||
@IsOptional()
|
||||
@IsEnum(SortOrder)
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({ type: Boolean, description: "Filter by disabled status" })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
disabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: String, description: "Filter by name" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by forms created after this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
afterCreatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by forms created before this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
beforeCreatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by forms updated after this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
afterUpdatedAt?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
format: "date-time",
|
||||
description: "Filter by forms updated before this date",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
beforeUpdatedAt?: Date;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional } from "class-validator";
|
||||
|
||||
export class UpdateRoutingFormResponseInput {
|
||||
@ApiPropertyOptional({ type: Object, description: "The updated response data" })
|
||||
@IsOptional()
|
||||
response?: Record<string, any>;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsRoutingFormsResponsesController } from "./controllers/organizations-routing-forms-responses.controller";
|
||||
import { OrganizationsRoutingFormsController } from "./controllers/organizations-routing-forms.controller";
|
||||
import { OrganizationsRoutingFormsRepository } from "./organizations-routing-forms.repository";
|
||||
import { OrganizationsRoutingFormsResponsesService } from "./services/organizations-routing-forms-responses.service";
|
||||
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
|
||||
providers: [
|
||||
MembershipsRepository,
|
||||
OrganizationsRepository,
|
||||
OrganizationsRoutingFormsRepository,
|
||||
OrganizationsRoutingFormsService,
|
||||
OrganizationsRoutingFormsResponsesService,
|
||||
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
],
|
||||
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
|
||||
})
|
||||
export class OrganizationsRoutingFormsModule {}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRoutingFormsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getOrganizationRoutingForms(
|
||||
orgId: number,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
teamIds?: number[];
|
||||
}
|
||||
) {
|
||||
const {
|
||||
disabled,
|
||||
name,
|
||||
sortCreatedAt,
|
||||
sortUpdatedAt,
|
||||
afterCreatedAt,
|
||||
beforeCreatedAt,
|
||||
afterUpdatedAt,
|
||||
beforeUpdatedAt,
|
||||
teamIds,
|
||||
} = options || {};
|
||||
|
||||
return this.dbRead.prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
team: { parentId: orgId, ...(teamIds?.length ? { id: { in: teamIds } } : {}) },
|
||||
...(disabled !== undefined && { disabled }),
|
||||
...(name && { name: { contains: name, mode: "insensitive" } }),
|
||||
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
|
||||
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
|
||||
...(afterUpdatedAt && { updatedAt: { gte: afterUpdatedAt } }),
|
||||
...(beforeUpdatedAt && { updatedAt: { lte: beforeUpdatedAt } }),
|
||||
},
|
||||
orderBy: [
|
||||
...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : []),
|
||||
...(sortUpdatedAt ? [{ updatedAt: sortUpdatedAt }] : []),
|
||||
],
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async getOrganizationRoutingFormResponses(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
sortCreatedAt,
|
||||
sortUpdatedAt,
|
||||
afterCreatedAt,
|
||||
beforeCreatedAt,
|
||||
routedToBookingUid,
|
||||
afterUpdatedAt,
|
||||
beforeUpdatedAt,
|
||||
} = options || {};
|
||||
await this.dbRead.prisma.app_RoutingForms_Form.findFirstOrThrow({
|
||||
where: {
|
||||
team: { parentId: orgId },
|
||||
id: routingFormId,
|
||||
},
|
||||
});
|
||||
|
||||
return this.dbRead.prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
formId: routingFormId,
|
||||
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
|
||||
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
|
||||
...(afterUpdatedAt && { updatedAt: { gte: afterUpdatedAt } }),
|
||||
...(beforeUpdatedAt && { updatedAt: { lte: beforeUpdatedAt } }),
|
||||
...(routedToBookingUid && { routedToBookingUid }),
|
||||
},
|
||||
orderBy: [
|
||||
...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : []),
|
||||
...(sortUpdatedAt ? [{ updatedAt: sortUpdatedAt }] : []),
|
||||
],
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async updateRoutingFormResponse(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
responseId: number,
|
||||
data: {
|
||||
response?: Record<string, any>;
|
||||
}
|
||||
) {
|
||||
return this.dbWrite.prisma.app_RoutingForms_FormResponse.update({
|
||||
where: {
|
||||
id: responseId,
|
||||
formId: routingFormId,
|
||||
form: {
|
||||
team: {
|
||||
parentId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { RoutingFormResponseOutput } from "@calcom/platform-types";
|
||||
|
||||
export class GetRoutingFormResponsesOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({ type: RoutingFormResponseOutput })
|
||||
@Expose()
|
||||
@Type(() => RoutingFormResponseOutput)
|
||||
data!: RoutingFormResponseOutput[];
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class RoutingFormOutput {
|
||||
@Expose()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: "My Form" })
|
||||
@Expose()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "This is the description." })
|
||||
@Expose()
|
||||
description!: string | null;
|
||||
|
||||
@ApiProperty({ example: 0 })
|
||||
@Expose()
|
||||
position!: number;
|
||||
|
||||
@Expose()
|
||||
routes!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ example: "2024-03-28T10:00:00.000Z" })
|
||||
@Expose()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({ example: "2024-03-28T10:00:00.000Z" })
|
||||
@Expose()
|
||||
updatedAt!: string;
|
||||
|
||||
@Expose()
|
||||
fields!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ example: 2313 })
|
||||
@Expose()
|
||||
userId!: number;
|
||||
|
||||
@ApiProperty({ example: 4214321 })
|
||||
@Expose()
|
||||
teamId!: number | null;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
@Expose()
|
||||
disabled!: boolean;
|
||||
|
||||
@Expose()
|
||||
settings!: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export class GetRoutingFormsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({ type: [RoutingFormOutput] })
|
||||
@Expose()
|
||||
@Type(() => RoutingFormOutput)
|
||||
data!: RoutingFormOutput[];
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { RoutingFormResponseOutput } from "@calcom/platform-types";
|
||||
|
||||
export class UpdateRoutingFormResponseOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({ type: RoutingFormResponseOutput })
|
||||
@Expose()
|
||||
@Type(() => RoutingFormResponseOutput)
|
||||
data!: RoutingFormResponseOutput;
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRoutingFormsResponsesService {
|
||||
constructor(
|
||||
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
|
||||
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService
|
||||
) {}
|
||||
|
||||
async getOrganizationRoutingFormResponses(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
const responses = await this.organizationsRoutingFormsRepository.getOrganizationRoutingFormResponses(
|
||||
orgId,
|
||||
routingFormId,
|
||||
skip,
|
||||
take,
|
||||
options
|
||||
);
|
||||
|
||||
return this.outputService.getRoutingFormResponses(responses);
|
||||
}
|
||||
|
||||
async updateRoutingFormResponse(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
responseId: number,
|
||||
data: {
|
||||
response?: Record<string, any>;
|
||||
}
|
||||
) {
|
||||
const updatedResponse = await this.organizationsRoutingFormsRepository.updateRoutingFormResponse(
|
||||
orgId,
|
||||
routingFormId,
|
||||
responseId,
|
||||
data
|
||||
);
|
||||
|
||||
return this.outputService.getRoutingFormResponses([updatedResponse])[0];
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRoutingFormsService {
|
||||
constructor(private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository) {}
|
||||
|
||||
async getOrganizationRoutingForms(
|
||||
orgId: number,
|
||||
skip: number,
|
||||
take: number,
|
||||
options?: {
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
teamIds?: number[];
|
||||
}
|
||||
) {
|
||||
return this.organizationsRoutingFormsRepository.getOrganizationRoutingForms(orgId, skip, take, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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 { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
import {
|
||||
StripConnectOutputDto,
|
||||
StripConnectOutputResponseDto,
|
||||
StripCredentialsCheckOutputResponseDto,
|
||||
StripCredentialsSaveOutputResponseDto,
|
||||
} from "@/modules/stripe/outputs/stripe.output";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Param,
|
||||
Headers,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
Redirect,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/stripe",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Organizations/Teams Stripe")
|
||||
export class OrganizationsStripeController {
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly organizationsStripeService: OrganizationsStripeService,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get stripe connect URL for a team" })
|
||||
async getTeamStripeConnectUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId") teamId: string,
|
||||
@Param("orgId") orgId: string,
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<StripConnectOutputResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state: OAuthCallbackState = {
|
||||
onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin,
|
||||
fromApp: false,
|
||||
returnTo: !!returnTo ? returnTo : origin,
|
||||
accessToken,
|
||||
teamId,
|
||||
orgId,
|
||||
};
|
||||
|
||||
const stripeRedirectUrl = await this.organizationsStripeService.getStripeTeamRedirectUrl({
|
||||
state,
|
||||
userEmail: user.email,
|
||||
userName: user.name,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Check team stripe connection" })
|
||||
async checkTeamStripeConnection(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.organizationsStripeService.checkIfTeamStripeAccountConnected(teamId);
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/save")
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<StripCredentialsSaveOutputResponseDto> {
|
||||
if (!state) {
|
||||
throw new BadRequestException("Missing `state` query param");
|
||||
}
|
||||
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
|
||||
// user cancels flow
|
||||
if (error === "access_denied") {
|
||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(stringify({ error, error_description }));
|
||||
}
|
||||
|
||||
return await this.organizationsStripeService.saveStripeAccount(
|
||||
decodedCallbackState,
|
||||
code,
|
||||
teamId,
|
||||
userId
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return {
|
||||
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsStripeController } from "@/modules/organizations/stripe/organizations-stripe.controller";
|
||||
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule, PrismaModule],
|
||||
exports: [OrganizationsStripeService],
|
||||
providers: [
|
||||
OrganizationsStripeService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
RedisService,
|
||||
OrganizationsRepository,
|
||||
MembershipsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
TokensRepository,
|
||||
],
|
||||
controllers: [OrganizationsStripeController],
|
||||
})
|
||||
export class OrganizationsStripeModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OAuthCallbackState, StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||
import { Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { ApiResponseWithoutData } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsStripeService {
|
||||
private logger = new Logger("OrganizationsStripeService");
|
||||
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly appsRepository: AppsRepository
|
||||
) {}
|
||||
|
||||
async getStripeTeamRedirectUrl({
|
||||
state,
|
||||
userEmail,
|
||||
userName,
|
||||
}: {
|
||||
state: OAuthCallbackState;
|
||||
userEmail?: string;
|
||||
userName?: string | null;
|
||||
}): Promise<string> {
|
||||
return await this.stripeService.getStripeRedirectUrl(state, userEmail, userName);
|
||||
}
|
||||
|
||||
async saveStripeAccount(
|
||||
state: OAuthCallbackState,
|
||||
code: string,
|
||||
teamId: number,
|
||||
userId?: number
|
||||
): Promise<{ url: string }> {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const response = await stripeInstance.oauth.token({
|
||||
grant_type: "authorization_code",
|
||||
code: code?.toString(),
|
||||
});
|
||||
|
||||
const data: StripeData = { ...response, default_currency: "" };
|
||||
if (response["stripe_user_id"]) {
|
||||
const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]);
|
||||
data["default_currency"] = account.default_currency;
|
||||
}
|
||||
|
||||
const existingCredentials = await this.credentialRepository.findAllCredentialsByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
const credentialIdsToDelete = existingCredentials.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createTeamAppCredential(
|
||||
"stripe_payment",
|
||||
data as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
async checkIfTeamStripeAccountConnected(teamId: number): Promise<ApiResponseWithoutData> {
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
return await this.stripeService.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,18 @@ export class OrganizationsTeamsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgTeamBySlug(organizationId: number, teamSlug: string) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
where: {
|
||||
slug_parentId: {
|
||||
slug: teamSlug,
|
||||
parentId: organizationId,
|
||||
},
|
||||
isOrganization: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgTeams(organizationId: number) {
|
||||
return this.dbRead.prisma.team.findMany({
|
||||
where: {
|
||||
|
||||
+50
-8
@@ -2,8 +2,8 @@ import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
|
||||
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.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";
|
||||
@@ -43,7 +43,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
let managedEventType: EventType;
|
||||
let membership: Membership;
|
||||
let membership2: Membership;
|
||||
let membershipCreatedViaApi: OrgTeamMembershipOutputDto;
|
||||
let membershipCreatedViaApi: TeamMembershipOutput;
|
||||
|
||||
const userEmail = `organizations-teams-memberships-admin-${randomString()}@api.com`;
|
||||
const userEmail2 = `organizations-teams-memberships-member-${randomString()}@api.com`;
|
||||
@@ -56,6 +56,11 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
|
||||
let userToInviteViaApi: User;
|
||||
|
||||
const metadata = {
|
||||
some: "key",
|
||||
};
|
||||
const bio = "This is a bio";
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
@@ -75,10 +80,14 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
user2 = await userRepositoryFixture.create({
|
||||
email: userEmail2,
|
||||
username: userEmail2,
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
|
||||
nonOrgUser = await userRepositoryFixture.create({
|
||||
@@ -89,6 +98,8 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
userToInviteViaApi = await userRepositoryFixture.create({
|
||||
email: invitedUserEmail,
|
||||
username: invitedUserEmail,
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
@@ -232,11 +243,25 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput[]> = 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[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(user2.bio);
|
||||
expect(responseBody.data[1].user.metadata).toEqual(user2.metadata);
|
||||
expect(responseBody.data[1].user.email).toEqual(user2.email);
|
||||
expect(responseBody.data[1].user.username).toEqual(user2.username);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
expect(responseBody.data[0].teamId).toEqual(orgTeam.id);
|
||||
expect(responseBody.data[1].teamId).toEqual(orgTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,10 +276,15 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(membership2.id);
|
||||
expect(responseBody.data[0].userId).toEqual(user2.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.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -270,11 +300,16 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = 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.user.email).toEqual(user.email);
|
||||
expect(responseBody.data.user.username).toEqual(user.username);
|
||||
expect(responseBody.data.user.bio).toEqual(bio);
|
||||
expect(responseBody.data.user.metadata).toEqual(metadata);
|
||||
expect(responseBody.data.role).toEqual("ADMIN");
|
||||
expect(responseBody.data.teamId).toEqual(orgTeam.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,13 +340,16 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id);
|
||||
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
|
||||
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
|
||||
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
|
||||
expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username);
|
||||
expect(membershipCreatedViaApi.user.bio).toEqual(userToInviteViaApi.bio);
|
||||
expect(membershipCreatedViaApi.user.metadata).toEqual(userToInviteViaApi.metadata);
|
||||
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
|
||||
});
|
||||
});
|
||||
@@ -347,10 +385,14 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
} satisfies UpdateOrgTeamMembershipDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.role).toEqual("OWNER");
|
||||
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
|
||||
expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username);
|
||||
expect(membershipCreatedViaApi.user.bio).toEqual(userToInviteViaApi.bio);
|
||||
expect(membershipCreatedViaApi.user.metadata).toEqual(userToInviteViaApi.metadata);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,7 +401,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
|
||||
.delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
|
||||
});
|
||||
|
||||
+6
-6
@@ -16,12 +16,12 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
|
||||
import {
|
||||
OrgTeamMembershipOutputDto,
|
||||
OrgTeamMembershipsOutputResponseDto,
|
||||
OrgTeamMembershipOutputResponseDto,
|
||||
} from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
@@ -84,7 +84,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: orgTeamMemberships.map((membership) =>
|
||||
plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" })
|
||||
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, orgTeamMembership, { strategy: "excludeAll" }),
|
||||
data: plainToClass(TeamMembershipOutput, orgTeamMembership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }),
|
||||
data: plainToClass(TeamMembershipOutput, updatedMembership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -2,6 +2,7 @@ import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/member
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-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";
|
||||
|
||||
@Injectable()
|
||||
@@ -16,7 +17,7 @@ export class OrganizationsTeamsMembershipsRepository {
|
||||
parentId: organizationId,
|
||||
},
|
||||
},
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
@@ -31,7 +32,7 @@ export class OrganizationsTeamsMembershipsRepository {
|
||||
parentId: organizationId,
|
||||
},
|
||||
},
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) {
|
||||
@@ -43,6 +44,7 @@ export class OrganizationsTeamsMembershipsRepository {
|
||||
parentId: organizationId,
|
||||
},
|
||||
},
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,14 +63,14 @@ export class OrganizationsTeamsMembershipsRepository {
|
||||
parentId: organizationId,
|
||||
},
|
||||
},
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
||||
return this.dbWrite.prisma.membership.create({
|
||||
data: { ...data, teamId: teamId },
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+9
-73
@@ -1,74 +1,10 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
import { IsArray, IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class MembershipUserOutputDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly avatarUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly name?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
readonly email!: string;
|
||||
}
|
||||
|
||||
export class OrgTeamMembershipOutputDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
readonly id!: number;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
readonly userId!: number;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
readonly teamId!: number;
|
||||
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
readonly accepted!: boolean;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
|
||||
@Expose()
|
||||
readonly role!: MembershipRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly disableImpersonation?: boolean;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => MembershipUserOutputDto)
|
||||
@Expose()
|
||||
@ApiProperty({ type: MembershipUserOutputDto })
|
||||
user!: MembershipUserOutputDto;
|
||||
}
|
||||
|
||||
export class OrgTeamMembershipsOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
@@ -76,10 +12,10 @@ export class OrgTeamMembershipsOutputResponseDto {
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgTeamMembershipOutputDto)
|
||||
@Type(() => TeamMembershipOutput)
|
||||
@IsArray()
|
||||
@ApiProperty({ type: [OrgTeamMembershipOutputDto] })
|
||||
data!: OrgTeamMembershipOutputDto[];
|
||||
@ApiProperty({ type: [TeamMembershipOutput] })
|
||||
data!: TeamMembershipOutput[];
|
||||
}
|
||||
|
||||
export class OrgTeamMembershipOutputResponseDto {
|
||||
@@ -89,7 +25,7 @@ export class OrgTeamMembershipOutputResponseDto {
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OrgTeamMembershipOutputDto)
|
||||
@ApiProperty({ type: OrgTeamMembershipOutputDto })
|
||||
data!: OrgTeamMembershipOutputDto;
|
||||
@Type(() => TeamMembershipOutput)
|
||||
@ApiProperty({ type: TeamMembershipOutput })
|
||||
data!: TeamMembershipOutput;
|
||||
}
|
||||
|
||||
+60
-2
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
|
||||
let routingFormResponseId: string;
|
||||
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
@@ -173,7 +173,9 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
|
||||
it("should get routing form responses", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses`)
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?skip=0&take=1`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
@@ -186,9 +188,65 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
|
||||
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
|
||||
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
|
||||
routingFormResponseId = responseData[0].id;
|
||||
});
|
||||
});
|
||||
|
||||
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
|
||||
it("should not update routing form response for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/99999/routing-forms/${routingFormId}/responses/${routingFormResponseId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not update routing form response for non existing form", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${routingFormResponseId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not update routing form response for non existing response", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/routing-forms/${routingFormId}/responses/99999`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not update routing form response without authentication", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingFormId}/responses/${routingFormResponseId}`
|
||||
)
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should update routing form response", async () => {
|
||||
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingFormId}/responses/${routingFormResponseId}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ response: updatedResponse })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toEqual(routingFormResponseId);
|
||||
expect(data.formId).toEqual(routingFormId);
|
||||
expect(data.response).toEqual(updatedResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user