Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
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,
|
||||
|
||||
@@ -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.172",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -99,6 +99,10 @@ export class BookingsController_2024_08_13 {
|
||||
For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body.
|
||||
|
||||
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
||||
|
||||
Finally, there are 2 ways to book an event type:
|
||||
1. Provide \`eventTypeId\` in the request body.
|
||||
2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization.
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import { EventType, User } from "@prisma/client";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
@@ -66,6 +66,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let user: User;
|
||||
|
||||
let eventTypeId: number;
|
||||
let eventType: EventType;
|
||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let recurringEventTypeId: number;
|
||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
@@ -128,6 +129,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
user.id
|
||||
);
|
||||
eventTypeId = event.id;
|
||||
eventType = event;
|
||||
|
||||
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||
@@ -1663,6 +1665,135 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("book by username and event type slug", () => {
|
||||
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
const body = {
|
||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
guests: ["bob@gmail.com"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(
|
||||
response.body.error.message.includes(
|
||||
"Either eventTypeId OR (eventTypeSlug + username) must be provided"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should create a booking by username and event type slug", async () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||
eventTypeSlug: eventType.slug,
|
||||
username: user.username || "",
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
guests: ["bob@gmail.com"],
|
||||
};
|
||||
|
||||
const beforeCreate = new Date();
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const afterCreate = new Date();
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.hosts[0].username).toEqual(user.username);
|
||||
expect(data.hosts[0].email).toEqual(user.email);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2036, 0, 8, 16, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(eventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
});
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.location).toEqual(body.location);
|
||||
expect(data.meetingUrl).toEqual(body.location);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
expect(data.bookingFieldsResponses).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
...body.bookingFieldsResponses,
|
||||
guests: body.guests,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
});
|
||||
expect(data.guests).toEqual(body.guests);
|
||||
|
||||
// Check createdAt date is between the time of the request and after the request
|
||||
const createdAtDate = new Date(data.createdAt);
|
||||
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
|
||||
// Check updatedAt date is between the time of the request and after the request
|
||||
expect(data.updatedAt).toBeDefined();
|
||||
const updatedAtDate = data.updatedAt ? new Date(data.updatedAt) : null;
|
||||
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
|
||||
expect(data.metadata).toEqual(body.metadata);
|
||||
createdBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("booking location", () => {
|
||||
const address = "123 Main St";
|
||||
const link = "https://cal.com/join/123456";
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
CancelBookingInput,
|
||||
} from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
import { EventType, User, Team } from "@calcom/prisma/client";
|
||||
|
||||
type CreatedBooking = {
|
||||
hosts: { id: number }[];
|
||||
@@ -61,6 +61,8 @@ const eventTypeBookingFieldSchema = z.object({
|
||||
|
||||
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
||||
|
||||
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingsService");
|
||||
@@ -80,27 +82,43 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput) {
|
||||
try {
|
||||
if ("instant" in body && body.instant) {
|
||||
return await this.createInstantBooking(request, body);
|
||||
const eventType = await this.getBookedEventType(body);
|
||||
if (!eventType) {
|
||||
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
||||
);
|
||||
}
|
||||
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||
}
|
||||
|
||||
body.eventTypeId = eventType.id;
|
||||
|
||||
if ("instant" in body && body.instant) {
|
||||
return await this.createInstantBooking(request, body, eventType);
|
||||
}
|
||||
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
|
||||
const isRecurring = !!eventType?.recurringEvent;
|
||||
const isSeated = !!eventType?.seatsPerTimeSlot;
|
||||
|
||||
await this.hasRequiredBookingFieldsResponses(body, eventType);
|
||||
|
||||
if (isRecurring && isSeated) {
|
||||
return await this.createRecurringSeatedBooking(request, body);
|
||||
return await this.createRecurringSeatedBooking(request, body, eventType);
|
||||
}
|
||||
if (isRecurring && !isSeated) {
|
||||
return await this.createRecurringBooking(request, body);
|
||||
return await this.createRecurringBooking(request, body, eventType);
|
||||
}
|
||||
if (isSeated) {
|
||||
return await this.createSeatedBooking(request, body);
|
||||
return await this.createSeatedBooking(request, body, eventType);
|
||||
}
|
||||
|
||||
return await this.createRegularBooking(request, body);
|
||||
return await this.createRegularBooking(request, body, eventType);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
@@ -111,6 +129,22 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
async getBookedEventType(body: CreateBookingInput) {
|
||||
if (body.eventTypeId) {
|
||||
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
|
||||
} else if (body.username && body.eventTypeSlug) {
|
||||
const user = await this.usersRepository.findByUsername(body.username, body.organizationSlug);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with username ${body.username} not found`);
|
||||
}
|
||||
return await this.eventTypesRepository.getUserEventTypeBySlugWithOwnerAndTeam(
|
||||
user.id,
|
||||
body.eventTypeSlug
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||
const bookingFields = body.bookingFieldsResponses;
|
||||
if (!bookingFields || !eventType || !eventType.bookingFields) {
|
||||
@@ -133,8 +167,12 @@ export class BookingsService_2024_08_13 {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
async createInstantBooking(
|
||||
request: Request,
|
||||
body: CreateInstantBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleInstantMeeting(bookingRequest);
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
|
||||
@@ -147,8 +185,8 @@ 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 +202,8 @@ 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 +219,8 @@ 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 +244,8 @@ 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { DestinationCalendarsModule } from "@/modules/destination-calendars/dest
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { OrganizationsBookingsModule } from "@/modules/organizations/bookings/organizations.bookings.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,6 +29,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
StripeModule,
|
||||
ConferencingModule,
|
||||
OrganizationsTeamsBookingsModule,
|
||||
OrganizationsUsersBookingsModule,
|
||||
OrganizationsBookingsModule,
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class OrganizationsBookingsController {
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get organization team bookings" })
|
||||
@ApiOperation({ summary: "Get Organization Bookings" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
+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);
|
||||
});
|
||||
});
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
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 { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
||||
import { OrganizationUsersBookingsService } from "@/modules/organizations/users/bookings/services/organization-users-bookings.service";
|
||||
import { Controller, UseGuards, Get, Query, ParseIntPipe, Param } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetBookingsInput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/users/:userId/bookings",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsUserInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Users / Bookings")
|
||||
export class OrganizationsUsersBookingsController {
|
||||
constructor(private readonly organizationUsersBookingsService: OrganizationUsersBookingsService) {}
|
||||
|
||||
@Get("/")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiOperation({ summary: "Get all bookings of an organization user" })
|
||||
async getOrganizationUserBookings(
|
||||
@GetUser() user: User,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: GetBookingsInput_2024_08_13
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings(
|
||||
orgId,
|
||||
user,
|
||||
query
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
};
|
||||
}
|
||||
}
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations UsersBookings Endpoints 2024-08-13", () => {
|
||||
describe("Organization User bookings", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
let team1: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const teamUserEmail = `organizations-users-team-user-${randomString()}@api.com`;
|
||||
let teamUser: User;
|
||||
|
||||
let team1EventTypeId: number;
|
||||
|
||||
let personalEventTypeId: number;
|
||||
const personalEventTypeSlug = `organizations-users-bookings-personal-event-type-${randomString()}`;
|
||||
|
||||
let createdPersonalBooking: BookingOutput_2024_08_13;
|
||||
let createdPersonalBookingUsingUsername: BookingOutput_2024_08_13;
|
||||
let createdTeamBooking: BookingOutput_2024_08_13;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
teamUserEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, OrganizationsTeamsBookingsModule],
|
||||
})
|
||||
)
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({ name: "organizations user bookings" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
team1 = await teamRepositoryFixture.create({
|
||||
name: "team 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
createdByOAuthClient: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
teamUser = await userRepositoryFixture.create({
|
||||
email: teamUserEmail,
|
||||
username: teamUserEmail,
|
||||
locale: "it",
|
||||
name: "orgUser1team1",
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const personalEvent = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-bookings-2024-08-13-event-type-${randomString()}`,
|
||||
slug: personalEventTypeSlug,
|
||||
length: 15,
|
||||
},
|
||||
teamUser.id
|
||||
);
|
||||
personalEventTypeId = personalEvent.id;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: "working time",
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(teamUser.id, userSchedule);
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamUser.id}`,
|
||||
username: teamUserEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: teamUser.id } },
|
||||
team: { connect: { id: team1.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: teamUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team1.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: "collective-event-type",
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
team1EventTypeId = team1EventType.id;
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
isFixed: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: teamUser.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: team1EventType.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("create bookings", () => {
|
||||
it("should create a personal booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: personalEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "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[0].id).toEqual(teamUser.id);
|
||||
expect(data.hosts[0].username).toEqual(teamUser.username);
|
||||
expect(data.hosts[0].email).toEqual(teamUser.email);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 15, 0)).toISOString());
|
||||
expect(data.duration).toEqual(15);
|
||||
expect(data.eventTypeId).toEqual(personalEventTypeId);
|
||||
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.email).toEqual(body.attendee.email);
|
||||
createdPersonalBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a personal booking using username and event slug and organization slug", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
|
||||
eventTypeSlug: personalEventTypeSlug,
|
||||
username: teamUser.username!,
|
||||
organizationSlug: organization.slug!,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "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[0].id).toEqual(teamUser.id);
|
||||
expect(data.hosts[0].username).toEqual(teamUser.username);
|
||||
expect(data.hosts[0].email).toEqual(teamUser.email);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 15, 0)).toISOString());
|
||||
expect(data.duration).toEqual(15);
|
||||
expect(data.eventTypeId).toEqual(personalEventTypeId);
|
||||
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.email).toEqual(body.attendee.email);
|
||||
createdPersonalBookingUsingUsername = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a team booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
eventTypeId: team1EventTypeId,
|
||||
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(2030, 0, 8, 16, 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);
|
||||
createdTeamBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get bookings", () => {
|
||||
it("should get individual and team organization user bookings", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/users/${teamUser.id}/bookings`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(3);
|
||||
expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined();
|
||||
expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined();
|
||||
expect(
|
||||
data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(teamUser.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsUsersBookingsController } from "@/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller";
|
||||
import { OrganizationUsersBookingsService } from "@/modules/organizations/users/bookings/services/organization-users-bookings.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BookingsModule_2024_08_13,
|
||||
UsersModule,
|
||||
PrismaModule,
|
||||
StripeModule,
|
||||
RedisModule,
|
||||
MembershipsModule,
|
||||
],
|
||||
providers: [OrganizationUsersBookingsService, OrganizationsRepository],
|
||||
controllers: [OrganizationsUsersBookingsController],
|
||||
})
|
||||
export class OrganizationsUsersBookingsModule {}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GetBookingsInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationUsersBookingsService {
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
|
||||
async getOrganizationUserBookings(
|
||||
orgId: number,
|
||||
user: Pick<User, "id" | "email">,
|
||||
queryParams: GetBookingsInput_2024_08_13
|
||||
) {
|
||||
return await this.bookingsService.getBookings(queryParams, { orgId, id: user.id, email: user.email });
|
||||
}
|
||||
}
|
||||
-2
@@ -90,7 +90,6 @@ export class OrganizationsUsersController {
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiOperation({ summary: "Create a user" })
|
||||
async createOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetOrg() org: Team,
|
||||
@Body() input: CreateOrganizationUserInput,
|
||||
@GetUser() inviter: UserWithProfile
|
||||
@@ -118,7 +117,6 @@ export class OrganizationsUsersController {
|
||||
async updateOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@GetOrg() org: Team,
|
||||
@Body() input: UpdateOrganizationUserInput
|
||||
): Promise<GetOrganizationUserOutput> {
|
||||
const user = await this.organizationsUsersService.updateUser(orgId, userId, input);
|
||||
|
||||
+61
-13
@@ -4,6 +4,7 @@ import { EmailService } from "@/modules/email/email.service";
|
||||
import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/users/index/outputs/get-organization-users.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
@@ -22,6 +23,9 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, EventType } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Users Endpoints", () => {
|
||||
const bio = "I am a bio";
|
||||
const metadata = { foo: "bar" };
|
||||
|
||||
describe("Member role", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
@@ -56,6 +60,8 @@ describe("Organizations Users Endpoints", () => {
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
@@ -170,6 +176,8 @@ describe("Organizations Users Endpoints", () => {
|
||||
email: member.email,
|
||||
username: member.username,
|
||||
organization: { connect: { id: org.id } },
|
||||
bio,
|
||||
metadata,
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -197,6 +205,8 @@ describe("Organizations Users Endpoints", () => {
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
@@ -244,9 +254,31 @@ describe("Organizations Users Endpoints", () => {
|
||||
{ userData },
|
||||
userData.map((u) => u.profile)
|
||||
);
|
||||
expect(userData.find((u) => u.profile.username === orgMembersData[0].username)).toBeDefined();
|
||||
expect(userData.find((u) => u.profile.username === orgMembersData[1].username)).toBeDefined();
|
||||
expect(userData.find((u) => u.profile.username === orgMembersData[2].username)).toBeDefined();
|
||||
// Find and verify each member's data
|
||||
const member0 = userData.find((u) => u.profile.username === orgMembersData[0].username);
|
||||
const member1 = userData.find((u) => u.profile.username === orgMembersData[1].username);
|
||||
const member2 = userData.find((u) => u.profile.username === orgMembersData[2].username);
|
||||
|
||||
// Verify member 0
|
||||
expect(member0).toBeDefined();
|
||||
expect(member0?.email).toBe(orgMembersData[0].email);
|
||||
expect(member0?.profile.username).toBe(orgMembersData[0].username);
|
||||
expect(member0?.bio).toBe(bio);
|
||||
expect(member0?.metadata).toEqual(metadata);
|
||||
|
||||
// Verify member 1
|
||||
expect(member1).toBeDefined();
|
||||
expect(member1?.email).toBe(orgMembersData[1].email);
|
||||
expect(member1?.profile.username).toBe(orgMembersData[1].username);
|
||||
expect(member1?.bio).toBe(bio);
|
||||
expect(member1?.metadata).toEqual(metadata);
|
||||
|
||||
// Verify member 2
|
||||
expect(member2).toBeDefined();
|
||||
expect(member2?.email).toBe(orgMembersData[2].email);
|
||||
expect(member2?.profile.username).toBe(orgMembersData[2].username);
|
||||
expect(member2?.bio).toBe(bio);
|
||||
expect(member2?.metadata).toEqual(metadata);
|
||||
|
||||
expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0);
|
||||
});
|
||||
@@ -265,8 +297,12 @@ describe("Organizations Users Endpoints", () => {
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(1);
|
||||
|
||||
expect(userData.filter((user) => user.email === userEmail).length).toBe(1);
|
||||
expect(userData.find((u) => u.profile.username === user.username)).toBeDefined();
|
||||
const foundUser = userData.find((u) => u.email === userEmail);
|
||||
expect(foundUser).toBeDefined();
|
||||
expect(foundUser?.email).toBe(userEmail);
|
||||
expect(foundUser?.profile.username).toBe(user.username);
|
||||
expect(foundUser?.bio).toBe(bio);
|
||||
expect(foundUser?.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should get users within the specified emails array", async () => {
|
||||
@@ -285,8 +321,20 @@ describe("Organizations Users Endpoints", () => {
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(2);
|
||||
|
||||
expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
|
||||
expect(userData.filter((user: { email: string }) => user.email === orgMemberEmail).length).toBe(1);
|
||||
const adminUser = userData.find((u: GetOrgUsersWithProfileOutput) => u.email === userEmail);
|
||||
const orgMember = userData.find((u: GetOrgUsersWithProfileOutput) => u.email === orgMemberEmail);
|
||||
|
||||
expect(adminUser).toBeDefined();
|
||||
expect(adminUser?.email).toBe(userEmail);
|
||||
expect(adminUser?.profile.username).toBe(user.username);
|
||||
expect(adminUser?.bio).toBe(bio);
|
||||
expect(adminUser?.metadata).toEqual(metadata);
|
||||
|
||||
expect(orgMember).toBeDefined();
|
||||
expect(orgMember?.email).toBe(orgMemberEmail);
|
||||
expect(orgMember?.profile.username).toBe(orgMembersData[0].username);
|
||||
expect(orgMember?.bio).toBe(bio);
|
||||
expect(orgMember?.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should update an org user", async () => {
|
||||
@@ -304,10 +352,10 @@ describe("Organizations Users Endpoints", () => {
|
||||
});
|
||||
|
||||
it("should create a new org user", async () => {
|
||||
const newOrgUser = {
|
||||
const newOrgUser: CreateUserInput = {
|
||||
email: `organizations-users-new-member-${randomString()}@api.com`,
|
||||
organizationRole: "MEMBER",
|
||||
autoAccept: true,
|
||||
bio,
|
||||
metadata,
|
||||
};
|
||||
|
||||
const emailSpy = jest
|
||||
@@ -315,15 +363,15 @@ describe("Organizations Users Endpoints", () => {
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/users`)
|
||||
.send({
|
||||
email: newOrgUser.email,
|
||||
})
|
||||
.send(newOrgUser)
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.email).toBe(newOrgUser.email);
|
||||
expect(userData.bio).toBe(newOrgUser.bio);
|
||||
expect(userData.metadata).toEqual(newOrgUser.metadata);
|
||||
expect(emailSpy).toHaveBeenCalledWith({
|
||||
usernameOrEmail: newOrgUser.email,
|
||||
orgName: org.name,
|
||||
|
||||
+2
-12
@@ -1,9 +1,7 @@
|
||||
import { CreateOrganizationUserInput } from "@/modules/organizations/users/index/inputs/create-organization-user.input";
|
||||
import { UpdateOrganizationUserInput } from "@/modules/organizations/users/index/inputs/update-organization-user.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { CreationSource } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsUsersRepository {
|
||||
@@ -69,15 +67,7 @@ export class OrganizationsUsersRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async createOrganizationUser(orgId: number, createUserBody: CreateOrganizationUserInput) {
|
||||
const createdUser = await this.dbWrite.prisma.user.create({
|
||||
data: { ...createUserBody, creationSource: CreationSource.API_V2 },
|
||||
});
|
||||
|
||||
return createdUser;
|
||||
}
|
||||
|
||||
async updateOrganizationUser(orgId: number, userId: number, updateUserBody: UpdateOrganizationUserInput) {
|
||||
async updateOrganizationUser(orgId: number, userId: number, updateUserBody: Prisma.UserUpdateInput) {
|
||||
return await this.dbWrite.prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
|
||||
@@ -117,7 +117,7 @@ export class SlotsController_2024_09_04 {
|
||||
name: "eventTypeSlug",
|
||||
required: false,
|
||||
description:
|
||||
"The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.",
|
||||
"The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too and if relevant organizationSlug too.",
|
||||
example: "event-type-slug",
|
||||
})
|
||||
@ApiQuery({
|
||||
@@ -126,6 +126,12 @@ export class SlotsController_2024_09_04 {
|
||||
description: "The username of the user to get event types for.",
|
||||
example: "bob",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "organizationSlug",
|
||||
required: false,
|
||||
description: "The slug of the organization to which user with username belongs.",
|
||||
example: "org-slug",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "end",
|
||||
required: true,
|
||||
|
||||
+36
-1
@@ -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,
|
||||
@@ -416,6 +417,40 @@ 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=${teamMember1.username}`)
|
||||
.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(managedEventType.slug);
|
||||
expect(data[0].ownerId).toEqual(teamMember1.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=${teamMember1.username}&eventSlug=${managedEventType.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(managedEventType.slug);
|
||||
expect(data[0].ownerId).toEqual(teamMember1.id);
|
||||
expect(data[0].id).not.toEqual(managedEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not get a non existing event-type", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/event-types/999999`).expect(404);
|
||||
});
|
||||
|
||||
+48
-4
@@ -1,8 +1,5 @@
|
||||
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 { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
@@ -63,6 +60,11 @@ describe("Teams Memberships Endpoints", () => {
|
||||
|
||||
let teammateInvitedViaApi: User;
|
||||
|
||||
const metadata = {
|
||||
some: "key",
|
||||
};
|
||||
const bio = "This is a bio";
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
teamAdminEmail,
|
||||
@@ -82,18 +84,27 @@ describe("Teams Memberships Endpoints", () => {
|
||||
teamAdmin = await userRepositoryFixture.create({
|
||||
email: teamAdminEmail,
|
||||
username: teamAdminEmail,
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
|
||||
teamMember = await userRepositoryFixture.create({
|
||||
email: teamMemberEmail,
|
||||
username: teamMemberEmail,
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
|
||||
nonTeamUser = await userRepositoryFixture.create({
|
||||
email: nonTeamUserEmail,
|
||||
username: nonTeamUserEmail,
|
||||
});
|
||||
|
||||
teammateInvitedViaApi = await userRepositoryFixture.create({
|
||||
email: invitedUserEmail,
|
||||
username: invitedUserEmail,
|
||||
bio,
|
||||
metadata,
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixture.create({
|
||||
@@ -185,6 +196,20 @@ describe("Teams Memberships Endpoints", () => {
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
expect(responseBody.data[0].id).toEqual(teamAdminMembership.id);
|
||||
expect(responseBody.data[1].id).toEqual(teamMemberMembership.id);
|
||||
expect(responseBody.data[0].teamId).toEqual(team.id);
|
||||
expect(responseBody.data[1].teamId).toEqual(team.id);
|
||||
expect(responseBody.data[0].userId).toEqual(teamAdmin.id);
|
||||
expect(responseBody.data[1].userId).toEqual(teamMember.id);
|
||||
expect(responseBody.data[0].role).toEqual("ADMIN");
|
||||
expect(responseBody.data[1].role).toEqual("MEMBER");
|
||||
expect(responseBody.data[0].user.bio).toEqual(teamAdmin.bio);
|
||||
expect(responseBody.data[1].user.bio).toEqual(teamMember.bio);
|
||||
expect(responseBody.data[0].user.metadata).toEqual(teamAdmin.metadata);
|
||||
expect(responseBody.data[1].user.metadata).toEqual(teamMember.metadata);
|
||||
expect(responseBody.data[0].user.email).toEqual(teamAdmin.email);
|
||||
expect(responseBody.data[1].user.email).toEqual(teamMember.email);
|
||||
expect(responseBody.data[0].user.username).toEqual(teamAdmin.username);
|
||||
expect(responseBody.data[1].user.username).toEqual(teamMember.username);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,7 +226,13 @@ describe("Teams Memberships Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(teamMemberMembership.id);
|
||||
expect(responseBody.data[0].userId).toEqual(teamMember.id);
|
||||
expect(responseBody.data[0].role).toEqual("MEMBER");
|
||||
expect(responseBody.data[0].user.bio).toEqual(teamMember.bio);
|
||||
expect(responseBody.data[0].user.metadata).toEqual(teamMember.metadata);
|
||||
expect(responseBody.data[0].user.email).toEqual(teamMember.email);
|
||||
expect(responseBody.data[0].user.username).toEqual(teamMember.username);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].teamId).toEqual(team.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +245,11 @@ describe("Teams Memberships Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(teamAdminMembership.id);
|
||||
expect(responseBody.data.userId).toEqual(teamAdmin.id);
|
||||
expect(responseBody.data.role).toEqual("ADMIN");
|
||||
expect(responseBody.data.user.bio).toEqual(teamAdmin.bio);
|
||||
expect(responseBody.data.user.metadata).toEqual(teamAdmin.metadata);
|
||||
expect(responseBody.data.user.email).toEqual(teamAdmin.email);
|
||||
expect(responseBody.data.user.username).toEqual(teamAdmin.username);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +270,10 @@ describe("Teams Memberships Endpoints", () => {
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.teamId).toEqual(team.id);
|
||||
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
|
||||
expect(membershipCreatedViaApi.user.bio).toEqual(teammateInvitedViaApi.bio);
|
||||
expect(membershipCreatedViaApi.user.metadata).toEqual(teammateInvitedViaApi.metadata);
|
||||
expect(membershipCreatedViaApi.user.email).toEqual(teammateInvitedViaApi.email);
|
||||
expect(membershipCreatedViaApi.user.username).toEqual(teammateInvitedViaApi.username);
|
||||
expect(membershipCreatedViaApi.userId).toEqual(teammateInvitedViaApi.id);
|
||||
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
|
||||
});
|
||||
@@ -265,6 +305,10 @@ describe("Teams Memberships Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.role).toEqual("OWNER");
|
||||
expect(membershipCreatedViaApi.user.bio).toEqual(teammateInvitedViaApi.bio);
|
||||
expect(membershipCreatedViaApi.user.metadata).toEqual(teammateInvitedViaApi.metadata);
|
||||
expect(membershipCreatedViaApi.user.email).toEqual(teammateInvitedViaApi.email);
|
||||
expect(membershipCreatedViaApi.user.username).toEqual(teammateInvitedViaApi.username);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,7 +317,7 @@ describe("Teams Memberships Endpoints", () => {
|
||||
.delete(`/v2/teams/${team.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);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,53 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
|
||||
import { Expose, Transform, Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsObject, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
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;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly bio?: string;
|
||||
|
||||
@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, unknown>;
|
||||
}
|
||||
|
||||
export class TeamMembershipOutput {
|
||||
@IsInt()
|
||||
@@ -34,4 +80,10 @@ export class TeamMembershipOutput {
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly disableImpersonation?: boolean;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => MembershipUserOutputDto)
|
||||
@Expose()
|
||||
@ApiProperty({ type: MembershipUserOutputDto })
|
||||
user!: MembershipUserOutputDto;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,17 @@ import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/cr
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export const MembershipUserSelect: Prisma.UserSelect = {
|
||||
username: true,
|
||||
email: true,
|
||||
avatarUrl: true,
|
||||
name: true,
|
||||
metadata: true,
|
||||
bio: true,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TeamsMembershipsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -11,7 +22,7 @@ export class TeamsMembershipsRepository {
|
||||
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
|
||||
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 } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,7 +31,7 @@ export class TeamsMembershipsRepository {
|
||||
where: {
|
||||
teamId: teamId,
|
||||
},
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
@@ -32,7 +43,7 @@ export class TeamsMembershipsRepository {
|
||||
id: membershipId,
|
||||
teamId: teamId,
|
||||
},
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +74,7 @@ export class TeamsMembershipsRepository {
|
||||
id: membershipId,
|
||||
teamId: teamId,
|
||||
},
|
||||
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
|
||||
include: { user: { select: MembershipUserSelect } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,54 @@ export class TokensRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) {
|
||||
if (deleteOld) {
|
||||
try {
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
this.logger.error("createOAuthTokens - Failed to delete old tokens", err);
|
||||
}
|
||||
}
|
||||
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
refreshToken: refreshToken.secret,
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getAccessTokenExpiryDate(accessTokenSecret: string) {
|
||||
const accessToken = await this.dbRead.prisma.accessToken.findFirst({
|
||||
where: {
|
||||
@@ -75,57 +123,46 @@ export class TokensRepository {
|
||||
return accessToken?.userId;
|
||||
}
|
||||
|
||||
async refreshOAuthTokens(ownerId: number, clientId: string) {
|
||||
try {
|
||||
await this.deleteOAuthTokens(ownerId, clientId);
|
||||
} catch (err) {
|
||||
this.logger.error("refreshOAuthTokens - Failed to delete old tokens", err);
|
||||
}
|
||||
|
||||
return await this.createOAuthTokens(ownerId, clientId);
|
||||
}
|
||||
|
||||
private async deleteOAuthTokens(ownerId: number, clientId: string) {
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async createOAuthTokens(ownerId: number, clientId: string) {
|
||||
async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) {
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, _refresh, accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, expiresAt: { lte: new Date() } },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.delete({ where: { secret: refreshTokenSecret } }),
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
|
||||
secret: this.jwtService.signAccessToken({
|
||||
clientId,
|
||||
ownerId: tokenUserId,
|
||||
expiresAt: accessExpiry.valueOf(),
|
||||
userId: tokenUserId,
|
||||
}),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
owner: { connect: { id: tokenUserId } },
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
|
||||
// note(Lauris): I am leaving userId because it was before adding ownerId to standardize payload like in the createOAuthTokens function for
|
||||
// backwards compatability.
|
||||
secret: this.jwtService.signRefreshToken({
|
||||
clientId,
|
||||
ownerId: tokenUserId,
|
||||
expiresAt: refreshExpiry.valueOf(),
|
||||
userId: tokenUserId,
|
||||
}),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
owner: { connect: { id: tokenUserId } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async getAccessTokenClient(accessToken: string) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl } from "class-validator";
|
||||
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator";
|
||||
|
||||
import { ValidateMetadata } from "@calcom/platform-types";
|
||||
|
||||
export type WeekDay = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
|
||||
export type TimeFormat = 12 | 24;
|
||||
@@ -51,4 +53,27 @@ export class CreateManagedUserInput {
|
||||
description: `URL of the user's avatar image`,
|
||||
})
|
||||
avatarUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Bio",
|
||||
example: "I am a bio",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bio?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description:
|
||||
"You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@ValidateMetadata({
|
||||
message:
|
||||
"Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
|
||||
})
|
||||
metadata?: Record<string, string | boolean | number>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Transform } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
IsString,
|
||||
Validate,
|
||||
Min,
|
||||
IsObject,
|
||||
} from "class-validator";
|
||||
|
||||
import { ValidateMetadata } from "@calcom/platform-types";
|
||||
|
||||
import { AvatarValidator } from "../validators/avatarValidator";
|
||||
import { LocaleValidator } from "../validators/localeValidator";
|
||||
import { ThemeValidator } from "../validators/themeValidator";
|
||||
@@ -58,6 +61,37 @@ export class CreateUserInput {
|
||||
@Expose()
|
||||
brandColor?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Bio",
|
||||
example: "I am a bio",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
bio?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description:
|
||||
"You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@ValidateMetadata({
|
||||
message:
|
||||
"Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
|
||||
})
|
||||
@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>;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Locales } from "@/lib/enums/locales";
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
|
||||
import { IsEnum, IsIn, IsNumber, IsObject, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
|
||||
|
||||
import { ValidateMetadata } from "@calcom/platform-types";
|
||||
|
||||
export class UpdateManagedUserInput {
|
||||
@IsString()
|
||||
@@ -53,4 +55,27 @@ export class UpdateManagedUserInput {
|
||||
description: `URL of the user's avatar image`,
|
||||
})
|
||||
avatarUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Bio",
|
||||
example: "I am a bio",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bio?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description:
|
||||
"You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@ValidateMetadata({
|
||||
message:
|
||||
"Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
|
||||
})
|
||||
metadata?: Record<string, string | boolean | number>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsBoolean, IsDateString, IsInt, IsString, ValidateNested, IsArray } from "class-validator";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
IsArray,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
} from "class-validator";
|
||||
|
||||
export class GetUserOutput {
|
||||
@IsInt()
|
||||
@@ -223,6 +232,21 @@ export class GetUserOutput {
|
||||
example: 1,
|
||||
})
|
||||
invitedTo!: number | 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>;
|
||||
}
|
||||
|
||||
export class GetUsersOutput {
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
"/v2/organizations/{orgId}/bookings": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsBookingsController_getAllOrgTeamBookings",
|
||||
"summary": "Get organization team bookings",
|
||||
"summary": "Get Organization Bookings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "status",
|
||||
@@ -4106,14 +4106,6 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "orgId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -4283,6 +4275,246 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/users/{userId}/bookings": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsUsersBookingsController_getOrganizationUserBookings",
|
||||
"summary": "Get all bookings of an organization user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "orgId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.",
|
||||
"example": "?status=upcoming,past",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"upcoming",
|
||||
"recurring",
|
||||
"past",
|
||||
"cancelled",
|
||||
"unconfirmed"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "attendeeEmail",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by the attendee's email address.",
|
||||
"example": "example@domain.com",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "attendeeName",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by the attendee's name.",
|
||||
"example": "John Doe",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventTypeIds",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.",
|
||||
"example": "?eventTypeIds=100,200",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by event type id belonging to the user.",
|
||||
"example": "?eventTypeId=100",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "teamsIds",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.",
|
||||
"example": "?teamIds=50,60",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "teamId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings by team id that user is part of",
|
||||
"example": "?teamId=50",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "afterStart",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings with start after this date string.",
|
||||
"example": "?afterStart=2025-03-07T10:00:00.000Z",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "beforeEnd",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings with end before this date string.",
|
||||
"example": "?beforeEnd=2025-03-07T11:00:00.000Z",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "afterCreatedAt",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings that have been created after this date string.",
|
||||
"example": "?afterCreatedAt=2025-03-07T10:00:00.000Z",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "beforeCreatedAt",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings that have been created before this date string.",
|
||||
"example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "afterUpdatedAt",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings that have been updated after this date string.",
|
||||
"example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "beforeUpdatedAt",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter bookings that have been updated before this date string.",
|
||||
"example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortStart",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Sort results by their start time in ascending or descending order.",
|
||||
"example": "?sortStart=asc OR ?sortStart=desc",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortEnd",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Sort results by their end time in ascending or descending order.",
|
||||
"example": "?sortEnd=asc OR ?sortEnd=desc",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortCreated",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Sort results by their creation time (when booking was made) in ascending or descending order.",
|
||||
"example": "?sortCreated=asc OR ?sortCreated=desc",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortUpdatedAt",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.",
|
||||
"example": "?sortUpdated=asc OR ?sortUpdated=desc",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "take",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Orgs / Users / Bookings"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/organizations/{orgId}/users/{userId}/ooo": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO",
|
||||
@@ -5367,7 +5599,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_createBooking",
|
||||
"summary": "Create a booking",
|
||||
"description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n 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.\n ",
|
||||
"description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n 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.\n\n Finally, there are 2 ways to book an event type:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `username` and optionally `organizationSlug` if the user with the username is within an organization.\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -8476,6 +8708,14 @@
|
||||
"example": "2050-09-06",
|
||||
"schema": {}
|
||||
},
|
||||
{
|
||||
"name": "organizationSlug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The slug of the organization to which user with username belongs.",
|
||||
"example": "org-slug",
|
||||
"schema": {}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"required": false,
|
||||
@@ -8488,7 +8728,7 @@
|
||||
"name": "eventTypeSlug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.",
|
||||
"description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too and if relevant organizationSlug too.",
|
||||
"example": "event-type-slug",
|
||||
"schema": {}
|
||||
},
|
||||
@@ -9965,6 +10205,11 @@
|
||||
"nullable": true,
|
||||
"example": "alice"
|
||||
},
|
||||
"bio": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"example": "bio"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string",
|
||||
"example": "America/New_York"
|
||||
@@ -10040,6 +10285,12 @@
|
||||
"nullable": true,
|
||||
"example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
"description": "URL of the user's avatar image"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -10047,6 +10298,7 @@
|
||||
"email",
|
||||
"username",
|
||||
"name",
|
||||
"bio",
|
||||
"timeZone",
|
||||
"weekStart",
|
||||
"createdDate",
|
||||
@@ -10168,6 +10420,18 @@
|
||||
"type": "string",
|
||||
"example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
"description": "URL of the user's avatar image"
|
||||
},
|
||||
"bio": {
|
||||
"type": "string",
|
||||
"description": "Bio",
|
||||
"example": "I am a bio"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -10336,6 +10600,18 @@
|
||||
"type": "string",
|
||||
"example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
"description": "URL of the user's avatar image"
|
||||
},
|
||||
"bio": {
|
||||
"type": "string",
|
||||
"description": "Bio",
|
||||
"example": "I am a bio"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -15248,6 +15524,12 @@
|
||||
"description": "The ID of the user who invited this user",
|
||||
"example": 1
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"description": "organization user profile, contains user data within the organizaton context",
|
||||
"allOf": [
|
||||
@@ -15313,6 +15595,18 @@
|
||||
"description": "Brand color in HEX format",
|
||||
"example": "#FFFFFF"
|
||||
},
|
||||
"bio": {
|
||||
"type": "string",
|
||||
"description": "Bio",
|
||||
"example": "I am a bio"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"darkBrandColor": {
|
||||
"type": "string",
|
||||
"description": "Dark brand color in HEX format",
|
||||
@@ -15405,7 +15699,36 @@
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"OrgMembershipOutputDto": {
|
||||
"MembershipUserOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avatarUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"bio": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"TeamMembershipOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -15430,6 +15753,9 @@
|
||||
},
|
||||
"disableImpersonation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/MembershipUserOutputDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -15437,7 +15763,8 @@
|
||||
"userId",
|
||||
"teamId",
|
||||
"accepted",
|
||||
"role"
|
||||
"role",
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"GetAllOrgMemberships": {
|
||||
@@ -15452,7 +15779,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/OrgMembershipOutputDto"
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -15502,7 +15829,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/OrgMembershipOutputDto"
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -15522,7 +15849,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/OrgMembershipOutputDto"
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -15542,7 +15869,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/OrgMembershipOutputDto"
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -15581,7 +15908,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/OrgMembershipOutputDto"
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -16792,65 +17119,6 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"MembershipUserOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"avatarUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"OrgTeamMembershipOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number"
|
||||
},
|
||||
"userId": {
|
||||
"type": "number"
|
||||
},
|
||||
"teamId": {
|
||||
"type": "number"
|
||||
},
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"MEMBER",
|
||||
"OWNER",
|
||||
"ADMIN"
|
||||
]
|
||||
},
|
||||
"disableImpersonation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/MembershipUserOutputDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"userId",
|
||||
"teamId",
|
||||
"accepted",
|
||||
"role",
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"OrgTeamMembershipsOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16865,7 +17133,7 @@
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/OrgTeamMembershipOutputDto"
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -16886,7 +17154,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/OrgTeamMembershipOutputDto"
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -19256,9 +19524,24 @@
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "The ID of the event type that is booked.",
|
||||
"description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
|
||||
"example": 123
|
||||
},
|
||||
"eventTypeSlug": {
|
||||
"type": "string",
|
||||
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
|
||||
"example": "my-event-type"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
|
||||
"example": "john-doe"
|
||||
},
|
||||
"organizationSlug": {
|
||||
"type": "string",
|
||||
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
|
||||
"example": "acme-corp"
|
||||
},
|
||||
"attendee": {
|
||||
"description": "The attendee's details.",
|
||||
"allOf": [
|
||||
@@ -19330,7 +19613,6 @@
|
||||
},
|
||||
"required": [
|
||||
"start",
|
||||
"eventTypeId",
|
||||
"attendee"
|
||||
]
|
||||
},
|
||||
@@ -19349,9 +19631,24 @@
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "The ID of the event type that is booked.",
|
||||
"description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
|
||||
"example": 123
|
||||
},
|
||||
"eventTypeSlug": {
|
||||
"type": "string",
|
||||
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
|
||||
"example": "my-event-type"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
|
||||
"example": "john-doe"
|
||||
},
|
||||
"organizationSlug": {
|
||||
"type": "string",
|
||||
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
|
||||
"example": "acme-corp"
|
||||
},
|
||||
"attendee": {
|
||||
"description": "The attendee's details.",
|
||||
"allOf": [
|
||||
@@ -19428,7 +19725,6 @@
|
||||
},
|
||||
"required": [
|
||||
"start",
|
||||
"eventTypeId",
|
||||
"attendee",
|
||||
"instant"
|
||||
]
|
||||
@@ -19448,9 +19744,24 @@
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "The ID of the event type that is booked.",
|
||||
"description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
|
||||
"example": 123
|
||||
},
|
||||
"eventTypeSlug": {
|
||||
"type": "string",
|
||||
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
|
||||
"example": "my-event-type"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
|
||||
"example": "john-doe"
|
||||
},
|
||||
"organizationSlug": {
|
||||
"type": "string",
|
||||
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
|
||||
"example": "acme-corp"
|
||||
},
|
||||
"attendee": {
|
||||
"description": "The attendee's details.",
|
||||
"allOf": [
|
||||
@@ -19527,7 +19838,6 @@
|
||||
},
|
||||
"required": [
|
||||
"start",
|
||||
"eventTypeId",
|
||||
"attendee"
|
||||
]
|
||||
},
|
||||
@@ -21035,41 +21345,6 @@
|
||||
"userId"
|
||||
]
|
||||
},
|
||||
"TeamMembershipOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number"
|
||||
},
|
||||
"userId": {
|
||||
"type": "number"
|
||||
},
|
||||
"teamId": {
|
||||
"type": "number"
|
||||
},
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"MEMBER",
|
||||
"OWNER",
|
||||
"ADMIN"
|
||||
]
|
||||
},
|
||||
"disableImpersonation": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"userId",
|
||||
"teamId",
|
||||
"accepted",
|
||||
"role"
|
||||
]
|
||||
},
|
||||
"CreateTeamMembershipOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1027,4 +1027,4 @@ select:focus {
|
||||
|
||||
.prose :where(a:not([data-card])):not(:where([class~="not-prose"] *)) {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import "./global.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
@@ -26,7 +21,14 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<head>
|
||||
<style>{`
|
||||
:root {
|
||||
--font-inter: ${inter.style.fontFamily.replace(/\'/g, "")};
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<RootProvider>{children}</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: MultiOptionInput
|
||||
---
|
||||
import { MultiOptionInput } from "@calcom/ui/components/form"
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render"
|
||||
import { Row } from "@/app/components/row"
|
||||
import { StatesExample } from "./multiInputField.states"
|
||||
import { PasteExample } from "./multiInputField.paste"
|
||||
import { CustomizationExample } from "./multiInputField.customization"
|
||||
|
||||
## Overview
|
||||
|
||||
The MultiOptionInput component is a flexible form input that allows users to manage a list of options with features like:
|
||||
- Adding and removing options
|
||||
- Reordering options with move up/down buttons
|
||||
- Pasting multiple options with configurable delimiters
|
||||
- Minimum options requirement
|
||||
- Customizable placeholders and labels
|
||||
- Key-value pair mode for environment variables, configuration settings, etc.
|
||||
|
||||
## States
|
||||
|
||||
MultiOptionInput in different states: default, disabled, and with minimum options.
|
||||
|
||||
<StatesExample/>
|
||||
|
||||
## Paste Support
|
||||
|
||||
Demonstrates the paste functionality with different delimiters. Users can paste comma-separated values, newline-separated values, or use custom delimiters.
|
||||
|
||||
In key-value mode, the component can also parse key-value pairs from pasted text. For example:
|
||||
- `KEY=value`
|
||||
- `NODE_ENV:production`
|
||||
|
||||
<PasteExample/>
|
||||
|
||||
## Customization
|
||||
|
||||
Examples of different customization options like placeholders, move buttons, remove buttons, and key-value pairs.
|
||||
|
||||
<CustomizationExample/>
|
||||
|
||||
## Usage
|
||||
|
||||
### Standard Options
|
||||
|
||||
```tsx
|
||||
import { MultiOptionInput } from "@calcom/ui/components/form/inputs/MultiOptionInput";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
|
||||
type FormValues = {
|
||||
options: Array<{ label: string; id: string }>;
|
||||
};
|
||||
|
||||
const MyForm = () => {
|
||||
const methods = useForm<FormValues>();
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="options"
|
||||
optionPlaceholders={["Option 1", "Option 2"]}
|
||||
defaultNumberOfOptions={2}
|
||||
pasteDelimiters={["\n", ","]} // Optional
|
||||
showMoveButtons={true} // Optional
|
||||
minOptions={1} // Optional
|
||||
addOptionLabel="Add another option" // Optional
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Key-Value Pairs
|
||||
|
||||
```tsx
|
||||
import { MultiOptionInput } from "@calcom/ui/components/form/inputs/MultiOptionInput";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
|
||||
type FormValues = {
|
||||
envVars: Array<{ label: string; value: string; id: string }>;
|
||||
};
|
||||
|
||||
const EnvVarsForm = () => {
|
||||
const methods = useForm<FormValues>();
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="envVars"
|
||||
keyValueMode={true}
|
||||
keyLabel="Environment Variable"
|
||||
valueLabel="Value"
|
||||
optionPlaceholders={["NODE_ENV", "PORT"]}
|
||||
valuePlaceholders={["production", "3000"]}
|
||||
keyValueDelimiters={[":", "="]} // Optional, defaults to [":", "="]
|
||||
defaultNumberOfOptions={2}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
|
||||
import { MultiOptionInput } from "@calcom/ui/components/form";
|
||||
|
||||
type FormValues = {
|
||||
customPlaceholders: Array<{ label: string; id: string }>;
|
||||
noMoveButtons: Array<{ label: string; id: string }>;
|
||||
customLabel: Array<{ label: string; id: string }>;
|
||||
keyValuePairs: Array<{ label: string; value: string; id: string }>;
|
||||
};
|
||||
|
||||
export const CustomizationExample: React.FC = () => {
|
||||
const methods = useForm<FormValues>();
|
||||
|
||||
return (
|
||||
<RenderComponentWithSnippet>
|
||||
<FormProvider {...methods}>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Key-Value Pairs</h3>
|
||||
<p className="text-subtle text-xs">Allows inputting keys and values</p>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="keyValuePairs"
|
||||
keyValueMode
|
||||
keyLabel="Environment Variable"
|
||||
valueLabel="Value"
|
||||
optionPlaceholders={["NODE_ENV", "PORT", "DATABASE_URL"]}
|
||||
valuePlaceholders={["production", "3000", "postgres://..."]}
|
||||
defaultNumberOfOptions={3}
|
||||
keyValueDelimiters={[":", "="]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Custom Placeholders</h3>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="customPlaceholders"
|
||||
optionPlaceholders={["Enter your name", "Enter your email", "Enter your phone"]}
|
||||
defaultNumberOfOptions={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Without Move Buttons</h3>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="noMoveButtons"
|
||||
optionPlaceholders={["Static option 1", "Static option 2"]}
|
||||
defaultNumberOfOptions={2}
|
||||
showMoveButtons={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Custom Add Button Label</h3>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="customLabel"
|
||||
optionPlaceholders={["Social media link"]}
|
||||
defaultNumberOfOptions={1}
|
||||
addOptionLabel="Add another social media link"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
|
||||
import { MultiOptionInput } from "@calcom/ui/components/form";
|
||||
|
||||
type FormValues = {
|
||||
newlineOptions: Array<{ label: string; id: string }>;
|
||||
commaOptions: Array<{ label: string; id: string }>;
|
||||
customOptions: Array<{ label: string; id: string }>;
|
||||
keyValueOptions: Array<{ label: string; value: string; id: string }>;
|
||||
};
|
||||
|
||||
export const PasteExample: React.FC = () => {
|
||||
const methods = useForm<FormValues>();
|
||||
|
||||
return (
|
||||
<RenderComponentWithSnippet>
|
||||
<FormProvider {...methods}>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Default Delimiters (Newline and Comma)</h3>
|
||||
<p className="text-subtle text-xs">
|
||||
Try pasting: “Option 1, Option 2” or multiple lines
|
||||
</p>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="newlineOptions"
|
||||
optionPlaceholders={["Paste here..."]}
|
||||
defaultNumberOfOptions={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Comma Only Delimiter</h3>
|
||||
<p className="text-subtle text-xs">Try pasting: “First, Second, Third”</p>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="commaOptions"
|
||||
optionPlaceholders={["Paste here..."]}
|
||||
defaultNumberOfOptions={1}
|
||||
pasteDelimiters={[","]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Key-Value Pair Paste Support</h3>
|
||||
<p className="text-subtle text-xs">
|
||||
Try pasting: “NODE_ENV=production” or “KEY1:value1, KEY2:value2”
|
||||
</p>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="keyValueOptions"
|
||||
keyValueMode
|
||||
optionPlaceholders={["Key..."]}
|
||||
valuePlaceholders={["Value..."]}
|
||||
defaultNumberOfOptions={1}
|
||||
keyValueDelimiters={[":", "="]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Custom Delimiters (Semicolon and Pipe)</h3>
|
||||
<p className="text-subtle text-xs">Try pasting: “One;Two|Three”</p>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="customOptions"
|
||||
optionPlaceholders={["Paste here..."]}
|
||||
defaultNumberOfOptions={1}
|
||||
pasteDelimiters={[";", "|"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { RenderComponentWithSnippet } from "@/app/components/render";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
|
||||
import { MultiOptionInput } from "@calcom/ui/components/form";
|
||||
|
||||
type FormValues = {
|
||||
defaultOptions: Array<{ label: string; id: string }>;
|
||||
disabledOptions: Array<{ label: string; id: string }>;
|
||||
minOptions: Array<{ label: string; id: string }>;
|
||||
};
|
||||
|
||||
export const StatesExample: React.FC = () => {
|
||||
const methods = useForm<FormValues>();
|
||||
|
||||
return (
|
||||
<RenderComponentWithSnippet>
|
||||
<FormProvider {...methods}>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Default State</h3>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="defaultOptions"
|
||||
optionPlaceholders={["First option", "Second option", "Third option"]}
|
||||
defaultNumberOfOptions={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Disabled State</h3>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="disabledOptions"
|
||||
optionPlaceholders={["Disabled option 1", "Disabled option 2"]}
|
||||
defaultNumberOfOptions={2}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Minimum Options (2)</h3>
|
||||
<MultiOptionInput<FormValues>
|
||||
fieldArrayName="minOptions"
|
||||
optionPlaceholders={["Required option 1", "Required option 2", "Optional option"]}
|
||||
defaultNumberOfOptions={3}
|
||||
minOptions={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</RenderComponentWithSnippet>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
"./node_modules/fumadocs-ui/dist/**/*.js",
|
||||
"../../internal/ui/src/**/*.tsx",
|
||||
"../../internal/icons/src/**/*.tsx",
|
||||
"../../packages/ui/components/**/*.{ts,tsx}",
|
||||
],
|
||||
plugins: [...base.plugins],
|
||||
theme: base.theme,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
title,
|
||||
profile: { name: profileName, image: profileImage },
|
||||
users: [
|
||||
...(eventData?.users || []).map((user) => ({
|
||||
...(eventData?.subsetOfUsers || []).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
|
||||
@@ -30,7 +30,11 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
title,
|
||||
profile: { name: profileName, image: profileImage },
|
||||
users: [
|
||||
...(eventData?.users || []).map((user) => ({
|
||||
...(
|
||||
(eventData as UserTypePageProps["eventData"])?.subsetOfUsers ??
|
||||
(eventData as TeamTypePageProps["eventData"])?.users ??
|
||||
[]
|
||||
).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -17,7 +20,8 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async ({ params, searchParams }: PageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const _searchParams = await searchParams;
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, _searchParams);
|
||||
const session = await getServerSession({ req: context.req });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
@@ -26,12 +30,16 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
const caller = await createRouterCaller(eventTypesRouter);
|
||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||
const initialData = await caller.getUserEventGroups({ filters });
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("event_types_page_title")}
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
CTA={<EventTypesCTA />}>
|
||||
<EventTypes />
|
||||
CTA={<EventTypesCTA initialData={initialData} filters={filters} />}>
|
||||
<EventTypes initialData={initialData} filters={filters} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps as _PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
@@ -7,7 +6,6 @@ import { z } from "zod";
|
||||
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import type { PageProps as EventTypePageProps } from "@lib/event-types/[type]/getServerSideProps";
|
||||
import { getServerSideProps } from "@lib/event-types/[type]/getServerSideProps";
|
||||
|
||||
import EventTypePageWrapper from "~/event-types/views/event-types-single-view";
|
||||
@@ -40,11 +38,9 @@ export const generateMetadata = async ({ params }: _PageProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<EventTypePageProps>(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: _PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(legacyCtx);
|
||||
const props = await getServerSideProps(legacyCtx);
|
||||
|
||||
return <EventTypePageWrapper {...props} />;
|
||||
};
|
||||
|
||||
+1
-3
@@ -199,9 +199,7 @@ const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnab
|
||||
(child) => isOrgAdminOrOwner || !organizationAdminKeys.includes(child.name)
|
||||
);
|
||||
|
||||
// TODO: figure out feature flag as it doesnt cause a re-render of the component when loaded.
|
||||
// You have to refresh the page to see the changes.
|
||||
if (true) {
|
||||
if (isOrgAdminOrOwner) {
|
||||
newArray.splice(4, 0, {
|
||||
name: "attributes",
|
||||
href: "/settings/organizations/attributes",
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateWebhooksList() {
|
||||
revalidatePath("/settings/developer/webhooks");
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { SkeletonLoader } from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<SettingsHeader
|
||||
title="Webhooks"
|
||||
description="Receive meeting data in real-time when something happens in Cal.com"
|
||||
borderInShellHeader={true}
|
||||
CTA={null}>
|
||||
<SkeletonLoader />
|
||||
</SettingsHeader>
|
||||
);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { CreateNewWebhookButton } from "@calcom/features/webhooks/components";
|
||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("webhooks"),
|
||||
(t) => t("add_webhook_description", { appName: APP_NAME })
|
||||
);
|
||||
|
||||
const WebhooksViewServerWrapper = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
const caller = await createRouterCaller(webhookRouter);
|
||||
const data = await caller.getByViewer();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("webhooks")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
CTA={data && data.webhookGroups.length > 0 ? <CreateNewWebhookButton isAdmin={isAdmin} /> : null}
|
||||
borderInShellHeader={(data && data.profiles.length === 1) || !data?.webhookGroups?.length}>
|
||||
<WebhooksView data={data} isAdmin={isAdmin} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebhooksViewServerWrapper;
|
||||
+1
-13
@@ -2,9 +2,8 @@ import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||
import { NewWebhookView, SkeletonLoader } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -12,17 +11,6 @@ export const generateMetadata = async () =>
|
||||
(t) => t("add_webhook_description", { appName: APP_NAME })
|
||||
);
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("webhooks"),
|
||||
(t) => t("add_webhook_description", { appName: APP_NAME })
|
||||
);
|
||||
|
||||
const WebhooksViewServerWrapper = async () => {
|
||||
return <WebhooksView />;
|
||||
};
|
||||
|
||||
export default WebhooksViewServerWrapper;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import type { Params } from "app/_types";
|
||||
import { ApiError } from "next/dist/server/api-utils";
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -48,7 +49,6 @@ export const defaultResponderForAppDir = <T extends NextResponse | Response = Ne
|
||||
// Don't report 400-499 errors to Sentry/console
|
||||
if (!(serverError.statusCode >= 400 && serverError.statusCode < 500)) {
|
||||
console.error(serverError);
|
||||
const captureException = (await import("@sentry/nextjs")).captureException;
|
||||
captureException(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -83,7 +84,8 @@ async function handler(request: NextRequest) {
|
||||
const res = {} as any; // Response is still mocked as it's not used in this context
|
||||
|
||||
const ctx = await createContext({ req: legacyReq, res }, sessionGetter);
|
||||
const caller = bookingsRouter.createCaller({
|
||||
const createCaller = createCallerFactory(bookingsRouter);
|
||||
const caller = createCaller({
|
||||
...ctx,
|
||||
req: legacyReq,
|
||||
res,
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ErrorProps = {
|
||||
reset?: () => void;
|
||||
};
|
||||
|
||||
export default function Error({ error }: ErrorProps) {
|
||||
export default function Error({ error, reset }: ErrorProps) {
|
||||
React.useEffect(() => {
|
||||
log.error(error);
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function Error({ error }: ErrorProps) {
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
reset={reset}
|
||||
statusCode={processedError.statusCode}
|
||||
error={processedError}
|
||||
message={processedError.message}
|
||||
|
||||
@@ -84,16 +84,16 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
exact: true,
|
||||
}
|
||||
),
|
||||
utils.viewer.connectedCalendars.invalidate(),
|
||||
utils.viewer.calendars.connectedCalendars.invalidate(),
|
||||
]);
|
||||
const query = trpc.viewer.connectedCalendars.useQuery();
|
||||
const query = trpc.viewer.calendars.connectedCalendars.useQuery();
|
||||
const installedCalendars = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "calendar",
|
||||
onlyInstalled: true,
|
||||
});
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation({
|
||||
const mutation = trpc.viewer.calendars.setDestinationCalendar.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.connectedCalendars.invalidate();
|
||||
utils.viewer.calendars.connectedCalendars.invalidate();
|
||||
},
|
||||
});
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
@@ -36,7 +36,7 @@ export function MultiDisconnectIntegration({ credentials, onSuccess }: Props) {
|
||||
} | null>(null);
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
|
||||
const mutation = trpc.viewer.deleteCredential.useMutation({
|
||||
const mutation = trpc.viewer.credentials.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("app_removed_successfully"), "success");
|
||||
onSuccess && onSuccess();
|
||||
@@ -47,7 +47,7 @@ export function MultiDisconnectIntegration({ credentials, onSuccess }: Props) {
|
||||
setConfirmationDialogOpen(false);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.viewer.connectedCalendars.invalidate();
|
||||
await utils.viewer.calendars.connectedCalendars.invalidate();
|
||||
await utils.viewer.apps.integrations.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ const LocationsWrapper = ({
|
||||
};
|
||||
|
||||
const EventTypeConferencingAppSettings = ({ eventType, slug }: { eventType: TEventType; slug: string }) => {
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery({});
|
||||
const locationsQuery = trpc.viewer.apps.locationOptions.useQuery({});
|
||||
const { t } = useLocale();
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
|
||||
@@ -163,6 +163,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
@@ -386,10 +387,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const bookingYear = dayjs(booking.startTime).year();
|
||||
const currentYear = dayjs().year();
|
||||
const isDifferentYear = bookingYear !== currentYear;
|
||||
|
||||
const startTime = dayjs(booking.startTime)
|
||||
.tz(userTimeZone)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
.format(isUpcoming ? (isDifferentYear ? "ddd, D MMM YYYY" : "ddd, D MMM") : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
@@ -730,6 +735,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
recurringDates={recurringDates}
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
isRescheduled={isRescheduled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -750,12 +756,14 @@ const BookingItemBadges = ({
|
||||
recurringDates,
|
||||
userTimeFormat,
|
||||
userTimeZone,
|
||||
isRescheduled,
|
||||
}: {
|
||||
booking: BookingItemProps;
|
||||
isPending: boolean;
|
||||
recurringDates: Date[] | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
isRescheduled: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -766,6 +774,13 @@ const BookingItemBadges = ({
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRescheduled && (
|
||||
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
|
||||
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2">
|
||||
{t("rescheduled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
|
||||
@@ -92,7 +92,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
||||
teamId,
|
||||
} = props;
|
||||
const { t } = useLocale();
|
||||
const locationsQuery = trpc.viewer.locationOptions.useQuery({ teamId });
|
||||
const locationsQuery = trpc.viewer.apps.locationOptions.useQuery({ teamId });
|
||||
|
||||
useEffect(() => {
|
||||
if (selection) {
|
||||
|
||||
@@ -11,8 +11,10 @@ import { EditLocationDialog } from "../EditLocationDialog";
|
||||
vi.mock("@calcom/trpc/react", () => ({
|
||||
trpc: {
|
||||
viewer: {
|
||||
locationOptions: {
|
||||
useQuery: vi.fn(),
|
||||
apps: {
|
||||
locationOptions: {
|
||||
useQuery: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,8 +7,11 @@ import { ErrorPage } from "./error-page";
|
||||
export default function BookingPageErrorBoundary({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ error }) => <ErrorPage error={error} message={`${error}`} displayDebug={true} />}
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<ErrorPage reset={resetErrorBoundary} error={error} message={`${error}`} displayDebug={true} />
|
||||
)}
|
||||
onError={(error, info) => {
|
||||
console.error(error);
|
||||
captureReactException(error, info);
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -12,6 +12,7 @@ type Props = {
|
||||
/** Display debugging information */
|
||||
displayDebug?: boolean;
|
||||
children?: never;
|
||||
reset?: () => void;
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
@@ -53,7 +54,10 @@ const ErrorDebugPanel: React.FC<{ error: Props["error"]; children?: never }> = (
|
||||
|
||||
export const ErrorPage: React.FC<Props> = (props) => {
|
||||
const { message, statusCode, error, displayDebug } = { ...defaultProps, ...props };
|
||||
|
||||
const handleReset = () => {
|
||||
window.location.reload();
|
||||
props.reset?.();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div className="bg-subtle flex h-screen">
|
||||
@@ -77,8 +81,8 @@ export const ErrorPage: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
|
||||
<Button href="mailto:support@cal.com">Contact Support</Button>
|
||||
<Button color="secondary" href="javascript:history.back()" className="ml-2">
|
||||
Go back
|
||||
<Button color="secondary" className="ml-2" onClick={handleReset}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { RouterInputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
interface ICreateEventsOnCalendarSelectProps {
|
||||
calendar?: RouterInputs["viewer"]["setDestinationCalendar"] | null;
|
||||
calendar?: RouterInputs["viewer"]["calendars"]["setDestinationCalendar"] | null;
|
||||
}
|
||||
|
||||
const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps) => {
|
||||
const { calendar } = props;
|
||||
const { t } = useLocale();
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation();
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const mutation = trpc.viewer.calendars.setDestinationCalendar.useMutation();
|
||||
const connectedCalendarsQuery = trpc.viewer.calendars.connectedCalendars.useQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -16,7 +16,7 @@ interface IConnectCalendarsProps {
|
||||
|
||||
const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||
const { nextStep, isPageLoading } = props;
|
||||
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({
|
||||
const queryConnectedCalendars = trpc.viewer.calendars.connectedCalendars.useQuery({
|
||||
onboarding: true,
|
||||
eventTypeId: null,
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ const UserProfile = () => {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
// Create a separate mutation for avatar updates
|
||||
const avatarMutation = trpc.viewer.updateProfile.useMutation({
|
||||
const avatarMutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
setImageSrc(data.avatarUrl ?? "");
|
||||
@@ -49,7 +49,7 @@ const UserProfile = () => {
|
||||
});
|
||||
|
||||
// Original mutation remains for onboarding completion
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
const mutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
try {
|
||||
if (eventTypes?.length === 0) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user