Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89c08868f6 | ||
|
|
ebab3b701d | ||
|
|
9ffa4b8477 | ||
|
|
c656063b06 | ||
|
|
5b7f2a6ffc | ||
|
|
63c7e4b55a | ||
|
|
24e5226e3e | ||
|
|
9f43c5f1e7 | ||
|
|
b48e3c7bb4 | ||
|
|
a561b805fd | ||
|
|
d21c91c519 | ||
|
|
41e565fcf8 | ||
|
|
2f946fc5da | ||
|
|
06bb7055ac | ||
|
|
1a8492b9c2 | ||
|
|
b8ba95dbb0 | ||
|
|
40df4cf511 | ||
|
|
58cabe974c | ||
|
|
199d95b35f | ||
|
|
c6febad364 | ||
|
|
0ddcc068ce | ||
|
|
3ff3a97c78 | ||
|
|
baaa1fa71e | ||
|
|
27d331ad2d | ||
|
|
9a1a86ed3a | ||
|
|
7902c6157d | ||
|
|
92d0fc6eb4 | ||
|
|
4d0b417925 | ||
|
|
9947b5ec85 | ||
|
|
fa06f38d47 | ||
|
|
99a6b1c7eb | ||
|
|
e3861ad22a | ||
|
|
37caa57798 | ||
|
|
6e66f7f8f8 | ||
|
|
62dd5ea95b | ||
|
|
365dfe9178 | ||
|
|
4d89da99a7 | ||
|
|
6954ad1ea5 | ||
|
|
825573da8e | ||
|
|
2c517f4878 | ||
|
|
8508a6f1a0 | ||
|
|
db03e9db20 | ||
|
|
e6a16f5b5c | ||
|
|
b94aa603c5 | ||
|
|
31e6b88951 |
No files matched your search
@@ -18,6 +18,10 @@
|
|||||||
|
|
||||||
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
|
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
|
||||||
CALCOM_LICENSE_KEY=
|
CALCOM_LICENSE_KEY=
|
||||||
|
# Signature token for the Cal.com License API (used for self-hosted integrations)
|
||||||
|
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
|
||||||
|
CAL_SIGNATURE_TOKEN=
|
||||||
|
# The route to the Cal.com License API
|
||||||
CALCOM_PRIVATE_API_ROUTE="https://goblin.cal.com"
|
CALCOM_PRIVATE_API_ROUTE="https://goblin.cal.com"
|
||||||
# ***********************************************************************************************************
|
# ***********************************************************************************************************
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,56 @@
|
|||||||
|
---
|
||||||
name: Feature request
|
name: Feature request
|
||||||
description: "Suggest an idea for this project \U0001F680"
|
about: Suggest a feature or idea
|
||||||
title: ""
|
title: ""
|
||||||
labels: ["✨ feature", "🚨 needs approval"],
|
labels: ["✨ feature", "🚨 needs approval"]
|
||||||
assignees: []
|
assignees: ""
|
||||||
body:
|
---
|
||||||
- type: textarea
|
|
||||||
id: problem-description
|
|
||||||
attributes:
|
|
||||||
label: Is your feature request related to a problem? Please describe.
|
|
||||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: solution-description
|
|
||||||
attributes:
|
|
||||||
label: Describe the solution you'd like
|
|
||||||
description: A clear and concise description of what you want to happen.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: alternate-solution-description
|
|
||||||
attributes:
|
|
||||||
label: Describe alternatives you've considered
|
|
||||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Add any other context or screenshots about the feature request here.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
- type: markdown
|
|
||||||
id: cal-info
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
### How we code at Cal.com
|
|
||||||
|
|
||||||
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
### 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)
|
||||||
@@ -69,12 +69,6 @@ jobs:
|
|||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
analyze:
|
|
||||||
name: Analyze Build
|
|
||||||
needs: [build]
|
|
||||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
required:
|
required:
|
||||||
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: "Next.js Bundle Analysis Annotation"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
annotate:
|
||||||
|
if: always()
|
||||||
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/yarn-install
|
||||||
|
- uses: ./.github/actions/cache-build
|
||||||
|
- name: Download base branch bundle stats
|
||||||
|
uses: dawidd6/action-download-artifact@v2
|
||||||
|
if: success()
|
||||||
|
with:
|
||||||
|
workflow: nextjs-bundle-analysis.yml
|
||||||
|
branch: ${{ github.event.pull_request.base.ref }}
|
||||||
|
path: apps/web/.next/analyze/base
|
||||||
|
|
||||||
|
# And here's the second place - this runs after we have both the current and
|
||||||
|
# base branch bundle stats, and will compare them to determine what changed.
|
||||||
|
# There are two configurable arguments that come from package.json:
|
||||||
|
#
|
||||||
|
# - budget: optional, set a budget (bytes) against which size changes are measured
|
||||||
|
# it's set to 350kb here by default, as informed by the following piece:
|
||||||
|
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
||||||
|
#
|
||||||
|
# - red-status-percentage: sets the percent size increase where you get a red
|
||||||
|
# status indicator, defaults to 20%
|
||||||
|
#
|
||||||
|
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
||||||
|
# entry in your package.json file.
|
||||||
|
|
||||||
|
- name: Compare with base branch bundle
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
cd apps/web
|
||||||
|
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
||||||
|
|
||||||
|
- name: Get comment body
|
||||||
|
id: get-comment-body
|
||||||
|
if: success() && github.event.number
|
||||||
|
run: |
|
||||||
|
cd apps/web
|
||||||
|
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
||||||
|
body="${body//'%'/'%25'}"
|
||||||
|
body="${body//$'\n'/'%0A'}"
|
||||||
|
body="${body//$'\r'/'%0D'}"
|
||||||
|
echo ::set-output name=body::$body
|
||||||
|
|
||||||
|
- name: Find Comment
|
||||||
|
uses: peter-evans/find-comment@v2
|
||||||
|
if: success() && github.event.number
|
||||||
|
id: fc
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
|
||||||
|
|
||||||
|
- name: Create Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v3
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
|
||||||
|
- name: Update Comment
|
||||||
|
uses: peter-evans/create-or-update-comment@v3
|
||||||
|
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.number }}
|
||||||
|
body: ${{ steps.get-comment-body.outputs.body }}
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
edit-mode: replace
|
||||||
@@ -67,65 +67,3 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: bundle
|
name: bundle
|
||||||
path: apps/web/.next/analyze/__bundle_analysis.json
|
path: apps/web/.next/analyze/__bundle_analysis.json
|
||||||
|
|
||||||
- name: Download base branch bundle stats
|
|
||||||
uses: dawidd6/action-download-artifact@v2
|
|
||||||
if: success()
|
|
||||||
with:
|
|
||||||
workflow: nextjs-bundle-analysis.yml
|
|
||||||
branch: ${{ github.event.pull_request.base.ref }}
|
|
||||||
path: apps/web/.next/analyze/base
|
|
||||||
|
|
||||||
# And here's the second place - this runs after we have both the current and
|
|
||||||
# base branch bundle stats, and will compare them to determine what changed.
|
|
||||||
# There are two configurable arguments that come from package.json:
|
|
||||||
#
|
|
||||||
# - budget: optional, set a budget (bytes) against which size changes are measured
|
|
||||||
# it's set to 350kb here by default, as informed by the following piece:
|
|
||||||
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
|
||||||
#
|
|
||||||
# - red-status-percentage: sets the percent size increase where you get a red
|
|
||||||
# status indicator, defaults to 20%
|
|
||||||
#
|
|
||||||
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
|
||||||
# entry in your package.json file.
|
|
||||||
- name: Compare with base branch bundle
|
|
||||||
if: success()
|
|
||||||
run: |
|
|
||||||
cd apps/web
|
|
||||||
ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
|
||||||
|
|
||||||
- name: Get comment body
|
|
||||||
id: get-comment-body
|
|
||||||
if: success() && github.event.number
|
|
||||||
run: |
|
|
||||||
cd apps/web
|
|
||||||
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
|
||||||
body="${body//'%'/'%25'}"
|
|
||||||
body="${body//$'\n'/'%0A'}"
|
|
||||||
body="${body//$'\r'/'%0D'}"
|
|
||||||
echo ::set-output name=body::$body
|
|
||||||
|
|
||||||
- name: Find Comment
|
|
||||||
uses: peter-evans/find-comment@v2
|
|
||||||
if: success() && github.event.number
|
|
||||||
id: fc
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.number }}
|
|
||||||
body-includes: "<!-- __NEXTJS_BUNDLE_@calcom/web -->"
|
|
||||||
|
|
||||||
- name: Create Comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v3
|
|
||||||
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.number }}
|
|
||||||
body: ${{ steps.get-comment-body.outputs.body }}
|
|
||||||
|
|
||||||
- name: Update Comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v3
|
|
||||||
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
|
||||||
with:
|
|
||||||
issue-number: ${{ github.event.number }}
|
|
||||||
body: ${{ steps.get-comment-body.outputs.body }}
|
|
||||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
|
||||||
edit-mode: replace
|
|
||||||
@@ -47,6 +47,10 @@ import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
|||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* format: date-time
|
||||||
* description: 'End time of the Event'
|
* description: 'End time of the Event'
|
||||||
|
* rescheduleUid:
|
||||||
|
* type: string
|
||||||
|
* format: UID
|
||||||
|
* description: 'Uid of the booking to reschedule'
|
||||||
* responses:
|
* responses:
|
||||||
* type: object
|
* type: object
|
||||||
* required:
|
* required:
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validati
|
|||||||
* ownerId:
|
* ownerId:
|
||||||
* type: number
|
* type: number
|
||||||
* description: ID of the team owner - only admins can set this.
|
* description: ID of the team owner - only admins can set this.
|
||||||
|
* parentId:
|
||||||
|
* type: number
|
||||||
|
* description: ID of the parent organization.
|
||||||
* tags:
|
* tags:
|
||||||
* - teams
|
* - teams
|
||||||
* responses:
|
* responses:
|
||||||
@@ -99,7 +102,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if parentId is related to this user
|
// Check if parentId is related to this user and is an organization
|
||||||
if (data.parentId) {
|
if (data.parentId) {
|
||||||
const parentTeam = await prisma.team.findFirst({
|
const parentTeam = await prisma.team.findFirst({
|
||||||
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
||||||
@@ -107,7 +110,13 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
if (!parentTeam)
|
if (!parentTeam)
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
message: "Unauthorized: Invalid parent id. You can only use parent id of your own teams.",
|
message: "Unauthorized: Invalid parent id. You can only use parent id if you are org owner or admin.",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parentTeam.parentId)
|
||||||
|
throw new HttpError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "parentId must be of an organization, not a team.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +128,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!IS_TEAM_BILLING_ENABLED) {
|
if (!IS_TEAM_BILLING_ENABLED || data.parentId) {
|
||||||
const team = await prisma.team.create({
|
const team = await prisma.team.create({
|
||||||
data: {
|
data: {
|
||||||
...cloneData,
|
...cloneData,
|
||||||
@@ -135,7 +144,7 @@ async function postHandler(req: NextApiRequest) {
|
|||||||
return {
|
return {
|
||||||
team: schemaTeamReadPublic.parse(team),
|
team: schemaTeamReadPublic.parse(team),
|
||||||
owner: schemaMembershipPublic.parse(team.members[0]),
|
owner: schemaMembershipPublic.parse(team.members[0]),
|
||||||
message: `Team created successfully. We also made user with ID=${ownerId} the owner of this team.`,
|
message: `Team created successfully. We also made user with ID=${effectiveUserId} the owner of this team.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
|||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import type { Booking } from "@calcom/prisma";
|
||||||
|
|
||||||
import handler from "../../../pages/api/bookings/_post";
|
import handler from "../../../pages/api/bookings/_post";
|
||||||
|
|
||||||
@@ -157,6 +158,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
|
|
||||||
describe("Success", () => {
|
describe("Success", () => {
|
||||||
describe("Regular event-type", () => {
|
describe("Regular event-type", () => {
|
||||||
|
let createdBooking: Booking;
|
||||||
test("Creates one single booking", async () => {
|
test("Creates one single booking", async () => {
|
||||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -181,9 +183,43 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
|||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
|
createdBooking = JSON.parse(res._getData());
|
||||||
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Reschedule created booking", async () => {
|
||||||
|
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: "testReschedule",
|
||||||
|
start: dayjs().add(2, "day").format(),
|
||||||
|
end: dayjs().add(3, "day").format(),
|
||||||
|
eventTypeId: 2,
|
||||||
|
email: "test@example.com",
|
||||||
|
location: "Cal.com Video",
|
||||||
|
timeZone: "America/Montevideo",
|
||||||
|
language: "en",
|
||||||
|
customInputs: [],
|
||||||
|
metadata: {},
|
||||||
|
userId: 4,
|
||||||
|
rescheduleUid: createdBooking.uid,
|
||||||
|
},
|
||||||
|
prisma,
|
||||||
|
});
|
||||||
|
|
||||||
|
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType());
|
||||||
|
prismaMock.booking.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||||
|
const rescheduledBooking = JSON.parse(res._getData()) as Booking;
|
||||||
|
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(rescheduledBooking.fromReschedule).toEqual(createdBooking.uid);
|
||||||
|
const previousBooking = await prisma.booking.findUnique({
|
||||||
|
where: { uid: createdBooking.uid },
|
||||||
|
});
|
||||||
|
expect(previousBooking?.status).toBe("cancelled");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Recurring event-type", () => {
|
describe("Recurring event-type", () => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-libraries-0.0.21": "npm:@calcom/platform-libraries@0.0.21",
|
"@calcom/platform-libraries-0.0.22": "npm:@calcom/platform-libraries@0.0.22",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { RedisService } from "@/modules/redis/redis.service";
|
|||||||
import { BullModule } from "@nestjs/bull";
|
import { BullModule } from "@nestjs/bull";
|
||||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
|
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||||
|
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
@@ -29,15 +29,8 @@ import { AppController } from "./app.controller";
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
RedisModule,
|
RedisModule,
|
||||||
BullModule.forRootAsync({
|
BullModule.forRoot({
|
||||||
imports: [RedisModule],
|
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
|
||||||
useFactory: async (redisService: RedisService) => ({
|
|
||||||
redis: {
|
|
||||||
host: redisService.redis.options.host,
|
|
||||||
port: redisService.redis.options.port,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
inject: [RedisService],
|
|
||||||
}),
|
}),
|
||||||
ThrottlerModule.forRootAsync({
|
ThrottlerModule.forRootAsync({
|
||||||
imports: [RedisModule],
|
imports: [RedisModule],
|
||||||
@@ -45,14 +38,9 @@ import { AppController } from "./app.controller";
|
|||||||
useFactory: (redisService: RedisService) => ({
|
useFactory: (redisService: RedisService) => ({
|
||||||
throttlers: [
|
throttlers: [
|
||||||
{
|
{
|
||||||
name: "short",
|
name: "long",
|
||||||
ttl: seconds(10),
|
ttl: seconds(60), // Time to live for the long period in seconds
|
||||||
limit: 3,
|
limit: 120, // Maximum number of requests within the long ttl
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "medium",
|
|
||||||
ttl: seconds(30),
|
|
||||||
limit: 10,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||||
@@ -69,6 +57,10 @@ import { AppController } from "./app.controller";
|
|||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: ResponseInterceptor,
|
useClass: ResponseInterceptor,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
|||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
import { handleNewBooking } from "@calcom/platform-libraries-0.0.21";
|
import { handleNewBooking } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
describe("Bookings Endpoints", () => {
|
describe("Bookings Endpoints", () => {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
getBookingInfo,
|
getBookingInfo,
|
||||||
handleCancelBooking,
|
handleCancelBooking,
|
||||||
getBookingForReschedule,
|
getBookingForReschedule,
|
||||||
} from "@calcom/platform-libraries-0.0.21";
|
} from "@calcom/platform-libraries-0.0.22";
|
||||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||||
import { ApiResponse } from "@calcom/platform-types";
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||||
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
|
||||||
|
|
||||||
import type { AppsStatus } from "@calcom/platform-libraries-0.0.21";
|
import type { AppsStatus } from "@calcom/platform-libraries-0.0.22";
|
||||||
|
|
||||||
export class CreateRecurringBookingInput extends CreateBookingInput {
|
export class CreateRecurringBookingInput extends CreateBookingInput {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
|
||||||
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.21";
|
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.22";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { User } from "@prisma/client";
|
|||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.21";
|
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { Calendar } from "@calcom/platform-types";
|
import { Calendar } from "@calcom/platform-types";
|
||||||
import { PrismaClient } from "@calcom/prisma";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ import {
|
|||||||
EventTypesPublic,
|
EventTypesPublic,
|
||||||
eventTypeBookingFields,
|
eventTypeBookingFields,
|
||||||
eventTypeLocations,
|
eventTypeLocations,
|
||||||
} from "@calcom/platform-libraries-0.0.21";
|
} from "@calcom/platform-libraries-0.0.22";
|
||||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
describe("Event types Endpoints", () => {
|
describe("Event types Endpoints", () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { getEventTypeById } from "@calcom/platform-libraries-0.0.21";
|
import { getEventTypeById } from "@calcom/platform-libraries-0.0.22";
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ import {
|
|||||||
updateEventType,
|
updateEventType,
|
||||||
EventTypesPublic,
|
EventTypesPublic,
|
||||||
getEventTypesPublic,
|
getEventTypesPublic,
|
||||||
} from "@calcom/platform-libraries-0.0.21";
|
} from "@calcom/platform-libraries-0.0.22";
|
||||||
import { EventType } from "@calcom/prisma/client";
|
import { EventType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
getEventTypeById,
|
getEventTypeById,
|
||||||
transformApiEventTypeBookingFields,
|
transformApiEventTypeBookingFields,
|
||||||
transformApiEventTypeLocations,
|
transformApiEventTypeLocations,
|
||||||
} from "@calcom/platform-libraries-0.0.21";
|
} from "@calcom/platform-libraries-0.0.22";
|
||||||
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -10,9 +10,9 @@ import { UsersService } from "@/modules/users/services/users.service";
|
|||||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
|
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.21";
|
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.21";
|
import { dynamicEvent } from "@calcom/platform-libraries-0.0.22";
|
||||||
import {
|
import {
|
||||||
CreateEventTypeInput_2024_06_14,
|
CreateEventTypeInput_2024_06_14,
|
||||||
UpdateEventTypeInput_2024_06_14,
|
UpdateEventTypeInput_2024_06_14,
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
|||||||
import {
|
import {
|
||||||
transformApiEventTypeBookingFields,
|
transformApiEventTypeBookingFields,
|
||||||
transformApiEventTypeLocations,
|
transformApiEventTypeLocations,
|
||||||
} from "@calcom/platform-libraries-0.0.21";
|
} from "@calcom/platform-libraries-0.0.22";
|
||||||
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
|||||||
parseRecurringEvent,
|
parseRecurringEvent,
|
||||||
TransformedLocationsSchema,
|
TransformedLocationsSchema,
|
||||||
BookingFieldsSchema,
|
BookingFieldsSchema,
|
||||||
} from "@calcom/platform-libraries-0.0.21";
|
} from "@calcom/platform-libraries-0.0.22";
|
||||||
|
|
||||||
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
||||||
type DatabaseEventType = EventType & EventTypeRelations;
|
type DatabaseEventType = EventType & EventTypeRelations;
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ export class MeOutput {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
timeZone!: string;
|
timeZone!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
organizationId!: number | null;
|
||||||
}
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
|||||||
import {
|
import {
|
||||||
transformApiScheduleOverrides,
|
transformApiScheduleOverrides,
|
||||||
transformApiScheduleAvailability,
|
transformApiScheduleAvailability,
|
||||||
} from "@calcom/platform-libraries-0.0.21";
|
} from "@calcom/platform-libraries-0.0.22";
|
||||||
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
|
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
|
||||||
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
|
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
|||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import { ERROR_STATUS } from "@calcom/platform-constants";
|
import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
import { TRPCError } from "@calcom/platform-libraries-0.0.21";
|
import { TRPCError } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { Response } from "@calcom/platform-types";
|
import { Response } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Catch(TRPCError)
|
@Catch(TRPCError)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
|
|||||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
|
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
|
|||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
DeploymentsModule,
|
DeploymentsModule,
|
||||||
|
ProfilesModule,
|
||||||
],
|
],
|
||||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||||
exports: [NextAuthGuard, ApiAuthGuard],
|
exports: [NextAuthGuard, ApiAuthGuard],
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { PlatformPlanType } from "@/modules/billing/types";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
export const PlatformPlan = Reflector.createDecorator<PlatformPlanType>();
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { createMock } from "@golevelup/ts-jest";
|
||||||
|
import { ExecutionContext } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
describe("PlatformPlanGuard", () => {
|
||||||
|
let guard: PlatformPlanGuard;
|
||||||
|
let reflector: Reflector;
|
||||||
|
let organizationsRepository: OrganizationsRepository;
|
||||||
|
let redisService: RedisService;
|
||||||
|
|
||||||
|
const mockContext = createMockExecutionContext({
|
||||||
|
params: { teamId: "1", orgId: "1" },
|
||||||
|
user: { id: "1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
reflector = new Reflector();
|
||||||
|
organizationsRepository = createMock<OrganizationsRepository>();
|
||||||
|
redisService = createMock<RedisService>({
|
||||||
|
redis: {
|
||||||
|
get: jest.fn().mockResolvedValue(null),
|
||||||
|
set: jest.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
guard = new PlatformPlanGuard(reflector, organizationsRepository, redisService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(guard).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||||
|
isPlatform: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
platformBilling: {
|
||||||
|
subscriptionId: "sub_123",
|
||||||
|
plan: "SCALE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if the organization does not exist", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if the organization is not platform", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||||
|
isPlatform: false,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
platformBilling: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if the organization has no subscription", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||||
|
isPlatform: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
platformBilling: {
|
||||||
|
subscriptionId: null,
|
||||||
|
plan: "STARTER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if the user has a lower plan than required", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||||
|
isPlatform: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
platformBilling: {
|
||||||
|
subscriptionId: "sub_123",
|
||||||
|
plan: "STARTER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if the result is cached in Redis", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(true));
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if the result is cached in Redis", async () => {
|
||||||
|
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||||
|
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
||||||
|
return createMock<ExecutionContext>({
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
params: context.params,
|
||||||
|
user: context.user,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
|
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { PlatformPlanType } from "@/modules/billing/types";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PlatformPlanGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private readonly organizationsRepository: OrganizationsRepository,
|
||||||
|
private readonly redisService: RedisService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const teamId = request.params.teamId as string;
|
||||||
|
const orgId = request.params.orgId as string;
|
||||||
|
const user = request.user as GetUserReturnType;
|
||||||
|
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||||
|
|
||||||
|
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||||
|
teamId ?? "none"
|
||||||
|
}:guard:platformbilling:${minimumPlan}`;
|
||||||
|
|
||||||
|
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||||
|
|
||||||
|
if (cachedAccess) {
|
||||||
|
return cachedAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canAccess = false;
|
||||||
|
|
||||||
|
if (user && orgId) {
|
||||||
|
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
||||||
|
const isPlatform = team?.isPlatform;
|
||||||
|
const hasSubscription = team?.platformBilling?.subscriptionId;
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
canAccess = false;
|
||||||
|
} else if (!isPlatform) {
|
||||||
|
canAccess = true;
|
||||||
|
} else if (!hasSubscription) {
|
||||||
|
canAccess = false;
|
||||||
|
} else {
|
||||||
|
canAccess = hasMinimumPlan({
|
||||||
|
currentPlan: team.platformBilling?.plan as PlatformPlanType,
|
||||||
|
minimumPlan: minimumPlan,
|
||||||
|
plans: ["STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||||
|
return canAccess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasMinimumPlanProp = {
|
||||||
|
currentPlan: PlatformPlanType;
|
||||||
|
minimumPlan: PlatformPlanType;
|
||||||
|
plans: PlatformPlanType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
|
||||||
|
const currentPlanIndex = props.plans.indexOf(props.currentPlan);
|
||||||
|
const minimumPlanIndex = props.plans.indexOf(props.minimumPlan);
|
||||||
|
|
||||||
|
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentPlanIndex >= minimumPlanIndex;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
|||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { ExecutionContext, HttpException } from "@nestjs/common";
|
import { ExecutionContext, HttpException } from "@nestjs/common";
|
||||||
@@ -17,11 +18,14 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { MockedRedisService } from "test/mocks/mock-redis-service";
|
import { MockedRedisService } from "test/mocks/mock-redis-service";
|
||||||
|
|
||||||
|
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
|
|
||||||
import { ApiAuthStrategy } from "./api-auth.strategy";
|
import { ApiAuthStrategy } from "./api-auth.strategy";
|
||||||
|
|
||||||
describe("ApiAuthStrategy", () => {
|
describe("ApiAuthStrategy", () => {
|
||||||
@@ -33,11 +37,17 @@ describe("ApiAuthStrategy", () => {
|
|||||||
let oAuthClient: PlatformOAuthClient;
|
let oAuthClient: PlatformOAuthClient;
|
||||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
|
||||||
const validApiKeyEmail = "api-key-user-email@example.com";
|
const validApiKeyEmail = "api-key-user-email@example.com";
|
||||||
const validAccessTokenEmail = "access-token-user-email@example.com";
|
const validAccessTokenEmail = "access-token-user-email@example.com";
|
||||||
|
const validOAuthEmail = "oauth-user@example.com";
|
||||||
|
|
||||||
let validApiKeyUser: User;
|
let validApiKeyUser: User;
|
||||||
let validAccessTokenUser: User;
|
let validAccessTokenUser: User;
|
||||||
|
let validOAuthUser: User;
|
||||||
let module: TestingModule;
|
let module: TestingModule;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
module = await Test.createTestingModule({
|
module = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -46,6 +56,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig],
|
load: [appConfig],
|
||||||
}),
|
}),
|
||||||
|
ProfilesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
MockedRedisService,
|
MockedRedisService,
|
||||||
@@ -71,6 +82,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
||||||
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
||||||
|
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
||||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||||
validApiKeyUser = await userRepositoryFixture.create({
|
validApiKeyUser = await userRepositoryFixture.create({
|
||||||
email: validApiKeyEmail,
|
email: validApiKeyEmail,
|
||||||
@@ -78,6 +90,18 @@ describe("ApiAuthStrategy", () => {
|
|||||||
validAccessTokenUser = await userRepositoryFixture.create({
|
validAccessTokenUser = await userRepositoryFixture.create({
|
||||||
email: validAccessTokenEmail,
|
email: validAccessTokenEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
validOAuthUser = await userRepositoryFixture.create({
|
||||||
|
email: validOAuthEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
await profilesRepositoryFixture.create({
|
||||||
|
uid: "asd-asd",
|
||||||
|
username: validOAuthEmail,
|
||||||
|
user: { connect: { id: validOAuthUser.id } },
|
||||||
|
organization: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
logo: "logo-url",
|
logo: "logo-url",
|
||||||
name: "name",
|
name: "name",
|
||||||
@@ -94,22 +118,9 @@ describe("ApiAuthStrategy", () => {
|
|||||||
oAuthClient.id
|
oAuthClient.id
|
||||||
);
|
);
|
||||||
|
|
||||||
const context: ExecutionContext = {
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getRequest: () => ({
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
get: (key: string) =>
|
|
||||||
({ Authorization: `Bearer ${accessToken}`, origin: "http://localhost:3000" }[key]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
} as ExecutionContext;
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
const user = await strategy.accessTokenStrategy(accessToken);
|
const user = await strategy.accessTokenStrategy(accessToken);
|
||||||
await expect(user).toBeDefined();
|
expect(user).toBeDefined();
|
||||||
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
|
expect(user?.id).toEqual(validAccessTokenUser.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return user associated with valid api key", async () => {
|
it("should return user associated with valid api key", async () => {
|
||||||
@@ -117,22 +128,15 @@ describe("ApiAuthStrategy", () => {
|
|||||||
now.setDate(now.getDate() + 1);
|
now.setDate(now.getDate() + 1);
|
||||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now);
|
||||||
|
|
||||||
const context: ExecutionContext = {
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getRequest: () => ({
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer cal_test_${keyString}`,
|
|
||||||
},
|
|
||||||
get: (key: string) =>
|
|
||||||
({ Authorization: `Bearer cal_test_${keyString}`, origin: "http://localhost:3000" }[key]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
} as ExecutionContext;
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
|
|
||||||
const user = await strategy.apiKeyStrategy(keyString);
|
const user = await strategy.apiKeyStrategy(keyString);
|
||||||
await expect(user).toBeDefined();
|
expect(user).toBeDefined();
|
||||||
if (user) expect(user.id).toEqual(validApiKeyUser.id);
|
expect(user?.id).toEqual(validApiKeyUser.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return user associated with valid OAuth client", async () => {
|
||||||
|
const user = await strategy.oAuthClientStrategy(oAuthClient.id, oAuthClient.secret);
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user.id).toEqual(validOAuthUser.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw 401 if api key is invalid", async () => {
|
it("should throw 401 if api key is invalid", async () => {
|
||||||
@@ -158,7 +162,55 @@ describe("ApiAuthStrategy", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw 401 if Authorization header does not contain auth token", async () => {
|
it("should throw 401 if OAuth ID is invalid", async () => {
|
||||||
|
const context: ExecutionContext = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
headers: {
|
||||||
|
[X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`,
|
||||||
|
[X_CAL_SECRET_KEY]: `secret`,
|
||||||
|
},
|
||||||
|
get: (key: string) =>
|
||||||
|
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await strategy.authenticate(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
expect(error.getStatus()).toEqual(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw 401 if OAuth secret is invalid", async () => {
|
||||||
|
const context: ExecutionContext = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
headers: {
|
||||||
|
[X_CAL_CLIENT_ID]: `${oAuthClient.id}`,
|
||||||
|
[X_CAL_SECRET_KEY]: `gibberish`,
|
||||||
|
},
|
||||||
|
get: (key: string) =>
|
||||||
|
({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as ExecutionContext;
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await strategy.authenticate(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
expect(error.getStatus()).toEqual(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw 401 if request does not contain Bearer token nor OAuth client credentials", async () => {
|
||||||
const context: ExecutionContext = {
|
const context: ExecutionContext = {
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
getRequest: () => ({
|
getRequest: () => ({
|
||||||
@@ -179,8 +231,11 @@ describe("ApiAuthStrategy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await userRepositoryFixture.deleteByEmail(validApiKeyEmail);
|
await oAuthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await userRepositoryFixture.deleteByEmail(validAccessTokenEmail);
|
await userRepositoryFixture.delete(validApiKeyUser.id);
|
||||||
|
await userRepositoryFixture.delete(validAccessTokenUser.id);
|
||||||
|
await userRepositoryFixture.delete(validOAuthUser.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
module.close();
|
module.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,9 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
|||||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||||
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
|
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
||||||
@@ -10,7 +12,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
|
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
||||||
@@ -20,19 +22,74 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
private readonly oauthFlowService: OAuthFlowService,
|
private readonly oauthFlowService: OAuthFlowService,
|
||||||
private readonly tokensRepository: TokensRepository,
|
private readonly tokensRepository: TokensRepository,
|
||||||
private readonly userRepository: UsersRepository,
|
private readonly userRepository: UsersRepository,
|
||||||
private readonly apiKeyRepository: ApiKeyRepository
|
private readonly apiKeyRepository: ApiKeyRepository,
|
||||||
|
private readonly oauthRepository: OAuthClientRepository,
|
||||||
|
private readonly profilesRepository: ProfilesRepository
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate(request: Request) {
|
async authenticate(request: Request) {
|
||||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
try {
|
||||||
if (!authString) {
|
const { params } = request;
|
||||||
return this.error(new UnauthorizedException("No Authorization header provided"));
|
const oAuthClientSecret = request.get(X_CAL_SECRET_KEY);
|
||||||
|
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
|
||||||
|
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||||
|
|
||||||
|
if (oAuthClientId && oAuthClientSecret) {
|
||||||
|
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bearerToken) {
|
||||||
|
const requestOrigin = request.get("Origin");
|
||||||
|
return await this.authenticateBearerToken(bearerToken, requestOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers"
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return this.error(err);
|
||||||
|
}
|
||||||
|
return this.error(
|
||||||
|
new InternalServerErrorException("An error occurred while authenticating the request")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) {
|
||||||
|
const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret);
|
||||||
|
return this.success(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async oAuthClientStrategy(oAuthClientId: string, oAuthClientSecret: string) {
|
||||||
|
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestOrigin = request.get("Origin");
|
if (client.secret !== oAuthClientSecret) {
|
||||||
|
throw new UnauthorizedException("Invalid client secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
||||||
|
|
||||||
|
if (!platformCreatorId) {
|
||||||
|
throw new UnauthorizedException("No owner ID found for this OAuth client");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException("No user associated with the provided OAuth client");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticateBearerToken(authString: string, requestOrigin: string | undefined) {
|
||||||
try {
|
try {
|
||||||
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||||
? await this.apiKeyStrategy(authString)
|
? await this.apiKeyStrategy(authString)
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export enum PlatformPlan {
|
|||||||
SCALE = "SCALE",
|
SCALE = "SCALE",
|
||||||
ENTERPRISE = "ENTERPRISE",
|
ENTERPRISE = "ENTERPRISE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.21";
|
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.22";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
|
|||||||
+269
-3
@@ -14,10 +14,11 @@ import { UsersModule } from "@/modules/users/users.module";
|
|||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
@@ -78,9 +79,13 @@ describe("OAuth Client Users Endpoints", () => {
|
|||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
|
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
|
||||||
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
|
||||||
let postResponseData: CreateUserResponse;
|
let postResponseData: CreateUserResponse;
|
||||||
|
|
||||||
|
const platformAdminEmail = "platform-sensei@mail.com";
|
||||||
|
let platformAdmin: User;
|
||||||
|
|
||||||
const userEmail = "oauth-client-user@gmail.com";
|
const userEmail = "oauth-client-user@gmail.com";
|
||||||
const userTimeZone = "Europe/Rome";
|
const userTimeZone = "Europe/Rome";
|
||||||
|
|
||||||
@@ -98,9 +103,20 @@ describe("OAuth Client Users Endpoints", () => {
|
|||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||||
|
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
|
||||||
|
|
||||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
|
await profilesRepositoryFixture.create({
|
||||||
|
uid: "asd-asd",
|
||||||
|
username: userEmail,
|
||||||
|
user: { connect: { id: platformAdmin.id } },
|
||||||
|
organization: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,10 +204,16 @@ describe("OAuth Client Users Endpoints", () => {
|
|||||||
// note(Lauris): to determine count see default event types created in EventTypesService.createUserDefaultEventTypes
|
// note(Lauris): to determine count see default event types created in EventTypesService.createUserDefaultEventTypes
|
||||||
expect(defaultEventTypes?.length).toEqual(4);
|
expect(defaultEventTypes?.length).toEqual(4);
|
||||||
expect(
|
expect(
|
||||||
defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.thirtyMinutes.length)
|
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.sixtyMinutes.length)
|
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +299,250 @@ describe("OAuth Client Users Endpoints", () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// User might have been deleted by the test
|
// User might have been deleted by the test
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await userRepositoryFixture.delete(platformAdmin.id);
|
||||||
|
} catch (e) {
|
||||||
|
// User might have been deleted by the test
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("User team even-types", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let oAuthClient1: PlatformOAuthClient;
|
||||||
|
let oAuthClient2: PlatformOAuthClient;
|
||||||
|
|
||||||
|
let organization: Team;
|
||||||
|
let team1: Team;
|
||||||
|
let team2: Team;
|
||||||
|
let owner: User;
|
||||||
|
|
||||||
|
let managedEventType1: EventType;
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
|
||||||
|
let postResponseData: CreateUserResponse;
|
||||||
|
|
||||||
|
const userEmail = "oauth-client-users-user@gmail.com";
|
||||||
|
const userTimeZone = "Europe/Rome";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||||
|
imports: [AppModule, UsersModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({
|
||||||
|
name: "Testy Organization",
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
owner = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
username: userEmail,
|
||||||
|
organization: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${owner.id}`,
|
||||||
|
username: userEmail,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: organization.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: owner.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
oAuthClient1 = await createOAuthClient(organization.id);
|
||||||
|
oAuthClient2 = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
|
team1 = await teamRepositoryFixture.create({
|
||||||
|
name: "Testy org team",
|
||||||
|
isOrganization: false,
|
||||||
|
parent: { connect: { id: organization.id } },
|
||||||
|
createdByOAuthClient: { connect: { id: oAuthClient1.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
team2 = await teamRepositoryFixture.create({
|
||||||
|
name: "Testy org team 2",
|
||||||
|
isOrganization: false,
|
||||||
|
parent: { connect: { id: organization.id } },
|
||||||
|
createdByOAuthClient: { connect: { id: oAuthClient2.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// note(Lauris): team1 team event-types
|
||||||
|
await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
|
team: {
|
||||||
|
connect: { id: team1.id },
|
||||||
|
},
|
||||||
|
title: "Collective Event Type",
|
||||||
|
slug: "collective-event-type",
|
||||||
|
length: 30,
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
managedEventType1 = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "MANAGED",
|
||||||
|
team: {
|
||||||
|
connect: { id: team1.id },
|
||||||
|
},
|
||||||
|
title: "Managed Event Type",
|
||||||
|
slug: "managed-event-type",
|
||||||
|
length: 60,
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// note(Lauris): team2 team event-types
|
||||||
|
await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
|
team: {
|
||||||
|
connect: { id: team2.id },
|
||||||
|
},
|
||||||
|
title: "Collective Event Type team 2",
|
||||||
|
slug: "collective-event-type-team-2",
|
||||||
|
length: 30,
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "MANAGED",
|
||||||
|
team: {
|
||||||
|
connect: { id: team2.id },
|
||||||
|
},
|
||||||
|
title: "Managed Event Type team 2",
|
||||||
|
slug: "managed-event-type-team-2",
|
||||||
|
length: 60,
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: [CLIENT_REDIRECT_URI],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(oauthClientRepositoryFixture).toBeDefined();
|
||||||
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
|
expect(oAuthClient1).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should create managed user and update team event-types of OAuthClient marked as assignAllTeamMembers: true`, async () => {
|
||||||
|
const requestBody: CreateManagedUserInput = {
|
||||||
|
email: userEmail,
|
||||||
|
timeZone: userTimeZone,
|
||||||
|
weekStart: "Monday",
|
||||||
|
timeFormat: 24,
|
||||||
|
locale: Locales.FR,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/api/v2/oauth-clients/${oAuthClient1.id}/users`)
|
||||||
|
.set("x-cal-secret-key", oAuthClient1.secret)
|
||||||
|
.send(requestBody)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: ApiSuccessResponse<{
|
||||||
|
user: Omit<User, "password">;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}> = response.body;
|
||||||
|
|
||||||
|
postResponseData = responseBody.data;
|
||||||
|
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
|
||||||
|
await userHasCorrectEventTypes(responseBody.data.user.id);
|
||||||
|
await teamHasCorrectEventTypes(team1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function userHasCorrectEventTypes(userId: number) {
|
||||||
|
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
|
||||||
|
|
||||||
|
expect(eventTypes?.length).toEqual(5);
|
||||||
|
|
||||||
|
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||||
|
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
|
||||||
|
|
||||||
|
// note(Lauris): default event types
|
||||||
|
expect(
|
||||||
|
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
|
||||||
|
).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamHasCorrectEventTypes(teamId: number) {
|
||||||
|
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
|
||||||
|
|
||||||
|
expect(eventTypes?.length).toEqual(2);
|
||||||
|
|
||||||
|
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||||
|
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
|
||||||
|
|
||||||
|
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
|
||||||
|
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||||
|
expect(collective).toBeTruthy();
|
||||||
|
expect(collective?.hosts).toBeDefined();
|
||||||
|
expect(collective?.hosts?.length).toEqual(1);
|
||||||
|
expect(collective?.hosts[0].userId).toEqual(postResponseData.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oauthClientRepositoryFixture.delete(oAuthClient1.id);
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
await userRepositoryFixture.delete(owner.id);
|
||||||
|
try {
|
||||||
|
await userRepositoryFixture.delete(postResponseData.user.id);
|
||||||
|
} catch (e) {
|
||||||
|
// User might have been deleted by the test
|
||||||
|
}
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-2
@@ -1,11 +1,11 @@
|
|||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { Locales } from "@/lib/enums/locales";
|
import { Locales } from "@/lib/enums/locales";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||||
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
|
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
@@ -37,7 +37,7 @@ import { Pagination } from "@calcom/platform-types";
|
|||||||
path: "/v2/oauth-clients/:clientId/users",
|
path: "/v2/oauth-clients/:clientId/users",
|
||||||
version: API_VERSIONS_VALUES,
|
version: API_VERSIONS_VALUES,
|
||||||
})
|
})
|
||||||
@UseGuards(OAuthClientCredentialsGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@DocsTags("Managed users")
|
@DocsTags("Managed users")
|
||||||
export class OAuthClientUsersController {
|
export class OAuthClientUsersController {
|
||||||
private readonly logger = new Logger("UserController");
|
private readonly logger = new Logger("UserController");
|
||||||
@@ -78,6 +78,7 @@ export class OAuthClientUsersController {
|
|||||||
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
|
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
|
||||||
);
|
);
|
||||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||||
|
console.log("asap createUser client", JSON.stringify(client, null, 2));
|
||||||
|
|
||||||
const isPlatformManaged = true;
|
const isPlatformManaged = true;
|
||||||
const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser(
|
const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser(
|
||||||
|
|||||||
+10
@@ -15,6 +15,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { withNextAuth } from "test/utils/withNextAuth";
|
import { withNextAuth } from "test/utils/withNextAuth";
|
||||||
@@ -58,6 +59,7 @@ describe("OAuthFlow Endpoints", () => {
|
|||||||
let usersRepositoryFixtures: UserRepositoryFixture;
|
let usersRepositoryFixtures: UserRepositoryFixture;
|
||||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||||
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
|
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
|
||||||
let user: User;
|
let user: User;
|
||||||
let organization: Team;
|
let organization: Team;
|
||||||
@@ -83,11 +85,19 @@ describe("OAuthFlow Endpoints", () => {
|
|||||||
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
|
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
|
||||||
|
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
user = await usersRepositoryFixtures.create({
|
user = await usersRepositoryFixtures.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
organization = await organizationsRepositoryFixture.create({ name: "organization" });
|
organization = await organizationsRepositoryFixture.create({ name: "organization" });
|
||||||
|
await profilesRepositoryFixture.create({
|
||||||
|
uid: "asd-asd",
|
||||||
|
username: userEmail,
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
organization: { connect: { id: organization.id } },
|
||||||
|
});
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
import { getEnv } from "@/env";
|
import { getEnv } from "@/env";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||||
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
|
|
||||||
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
|
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
|
||||||
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
|
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
|
||||||
import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input";
|
import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input";
|
||||||
@@ -137,7 +137,7 @@ export class OAuthFlowController {
|
|||||||
|
|
||||||
@Post("/refresh")
|
@Post("/refresh")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(OAuthClientCredentialsGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
async refreshAccessToken(
|
async refreshAccessToken(
|
||||||
@Param("clientId") clientId: string,
|
@Param("clientId") clientId: string,
|
||||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||||
|
|||||||
-94
@@ -1,94 +0,0 @@
|
|||||||
import { AppModule } from "@/app.module";
|
|
||||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
|
||||||
import { createMock } from "@golevelup/ts-jest";
|
|
||||||
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
|
||||||
import { PlatformOAuthClient, Team } from "@prisma/client";
|
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
|
||||||
|
|
||||||
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
|
||||||
|
|
||||||
import { OAuthClientCredentialsGuard } from "./oauth-client-credentials.guard";
|
|
||||||
|
|
||||||
describe("OAuthClientCredentialsGuard", () => {
|
|
||||||
let guard: OAuthClientCredentialsGuard;
|
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
|
||||||
let oauthClient: PlatformOAuthClient;
|
|
||||||
let organization: Team;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
imports: [AppModule, OAuthClientModule],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
guard = module.get<OAuthClientCredentialsGuard>(OAuthClientCredentialsGuard);
|
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
|
||||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
|
||||||
|
|
||||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
logo: "logo-url",
|
|
||||||
name: "name",
|
|
||||||
redirectUris: ["redirect-uri"],
|
|
||||||
permissions: 32,
|
|
||||||
};
|
|
||||||
const secret = "secret";
|
|
||||||
|
|
||||||
oauthClient = await oauthClientRepositoryFixture.create(organization.id, data, secret);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be defined", () => {
|
|
||||||
expect(guard).toBeDefined();
|
|
||||||
expect(oauthClient).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true if client ID and secret are valid", async () => {
|
|
||||||
const mockContext = createMockExecutionContext(
|
|
||||||
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
|
|
||||||
{ clientId: oauthClient.id }
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if client ID is invalid", async () => {
|
|
||||||
const mockContext = createMockExecutionContext(
|
|
||||||
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
|
|
||||||
{ clientId: "invalid id" }
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if secret key is invalid", async () => {
|
|
||||||
const mockContext = createMockExecutionContext(
|
|
||||||
{ [X_CAL_SECRET_KEY]: "invalid secret" },
|
|
||||||
{ clientId: oauthClient.id }
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await oauthClientRepositoryFixture.delete(oauthClient.id);
|
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
function createMockExecutionContext(
|
|
||||||
headers: Record<string, string>,
|
|
||||||
params: Record<string, string>
|
|
||||||
): ExecutionContext {
|
|
||||||
return createMock<ExecutionContext>({
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getRequest: () => ({
|
|
||||||
headers,
|
|
||||||
params,
|
|
||||||
get: (headerName: string) => headers[headerName],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
|
||||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
|
||||||
import { Request } from "express";
|
|
||||||
|
|
||||||
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OAuthClientCredentialsGuard implements CanActivate {
|
|
||||||
constructor(private readonly oauthRepository: OAuthClientRepository) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
const { params } = request;
|
|
||||||
|
|
||||||
const oauthClientId = params.clientId;
|
|
||||||
const oauthClientSecret = request.get(X_CAL_SECRET_KEY);
|
|
||||||
|
|
||||||
if (!oauthClientId) {
|
|
||||||
throw new UnauthorizedException("Missing client ID");
|
|
||||||
}
|
|
||||||
if (!oauthClientSecret) {
|
|
||||||
throw new UnauthorizedException("Missing client secret");
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await this.oauthRepository.getOAuthClient(oauthClientId);
|
|
||||||
|
|
||||||
if (!client || client.secret !== oauthClientSecret) {
|
|
||||||
throw new UnauthorizedException("Invalid client credentials");
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,11 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
|||||||
import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
|
import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
|
||||||
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
|
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 { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
|
||||||
import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard";
|
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||||
|
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
@@ -36,12 +36,12 @@ import { Global, Module } from "@nestjs/common";
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
OAuthClientRepository,
|
OAuthClientRepository,
|
||||||
OAuthClientCredentialsGuard,
|
|
||||||
TokensRepository,
|
TokensRepository,
|
||||||
OAuthFlowService,
|
OAuthFlowService,
|
||||||
OAuthClientUsersService,
|
OAuthClientUsersService,
|
||||||
|
OrganizationsTeamsService,
|
||||||
],
|
],
|
||||||
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
||||||
exports: [OAuthClientRepository, OAuthClientCredentialsGuard],
|
exports: [OAuthClientRepository],
|
||||||
})
|
})
|
||||||
export class OAuthClientModule {}
|
export class OAuthClientModule {}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||||
@@ -7,7 +8,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.21";
|
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.22";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthClientUsersService {
|
export class OAuthClientUsersService {
|
||||||
@@ -15,7 +16,8 @@ export class OAuthClientUsersService {
|
|||||||
private readonly userRepository: UsersRepository,
|
private readonly userRepository: UsersRepository,
|
||||||
private readonly tokensRepository: TokensRepository,
|
private readonly tokensRepository: TokensRepository,
|
||||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||||
private readonly schedulesService: SchedulesService_2024_04_15
|
private readonly schedulesService: SchedulesService_2024_04_15,
|
||||||
|
private readonly organizationsTeamsService: OrganizationsTeamsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createOauthClientUser(
|
async createOauthClientUser(
|
||||||
@@ -78,6 +80,8 @@ export class OAuthClientUsersService {
|
|||||||
user.defaultScheduleId = defaultSchedule.id;
|
user.defaultScheduleId = defaultSchedule.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
tokens: {
|
tokens: {
|
||||||
|
|||||||
+28
-8
@@ -1,7 +1,9 @@
|
|||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
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 { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
@@ -32,6 +34,7 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
|||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import {
|
import {
|
||||||
CreateTeamEventTypeInput_2024_06_14,
|
CreateTeamEventTypeInput_2024_06_14,
|
||||||
|
GetTeamEventTypesQuery_2024_06_14,
|
||||||
SkipTakePagination,
|
SkipTakePagination,
|
||||||
UpdateTeamEventTypeInput_2024_06_14,
|
UpdateTeamEventTypeInput_2024_06_14,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
@@ -40,13 +43,13 @@ import {
|
|||||||
path: "/v2/organizations/:orgId",
|
path: "/v2/organizations/:orgId",
|
||||||
version: API_VERSIONS_VALUES,
|
version: API_VERSIONS_VALUES,
|
||||||
})
|
})
|
||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
|
||||||
@DocsTags("Organizations Event Types")
|
@DocsTags("Organizations Event Types")
|
||||||
export class OrganizationsEventTypesController {
|
export class OrganizationsEventTypesController {
|
||||||
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
|
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
@UseGuards(IsTeamInOrg)
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||||
@Post("/teams/:teamId/event-types")
|
@Post("/teams/:teamId/event-types")
|
||||||
async createTeamEventType(
|
async createTeamEventType(
|
||||||
@GetUser() user: UserWithProfile,
|
@GetUser() user: UserWithProfile,
|
||||||
@@ -68,7 +71,8 @@ export class OrganizationsEventTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
@UseGuards(IsTeamInOrg)
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||||
@Get("/teams/:teamId/event-types/:eventTypeId")
|
@Get("/teams/:teamId/event-types/:eventTypeId")
|
||||||
async getTeamEventType(
|
async getTeamEventType(
|
||||||
@Param("teamId", ParseIntPipe) teamId: number,
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
@@ -86,10 +90,22 @@ export class OrganizationsEventTypesController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@UseGuards(IsOrgGuard, IsTeamInOrg)
|
||||||
@UseGuards(IsTeamInOrg)
|
|
||||||
@Get("/teams/:teamId/event-types")
|
@Get("/teams/:teamId/event-types")
|
||||||
async getTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<GetTeamEventTypesOutput> {
|
async getTeamEventTypes(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||||
|
): Promise<GetTeamEventTypesOutput> {
|
||||||
|
const { eventSlug } = queryParams;
|
||||||
|
if (eventSlug) {
|
||||||
|
const eventType = await this.organizationsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: eventType ? [eventType] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
|
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -99,6 +115,8 @@ export class OrganizationsEventTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||||
@Get("/teams/event-types")
|
@Get("/teams/event-types")
|
||||||
async getTeamsEventTypes(
|
async getTeamsEventTypes(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@@ -114,7 +132,8 @@ export class OrganizationsEventTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
@UseGuards(IsTeamInOrg)
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||||
@Patch("/teams/:teamId/event-types/:eventTypeId")
|
@Patch("/teams/:teamId/event-types/:eventTypeId")
|
||||||
async updateTeamEventType(
|
async updateTeamEventType(
|
||||||
@Param("teamId", ParseIntPipe) teamId: number,
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
@@ -136,7 +155,8 @@ export class OrganizationsEventTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
@UseGuards(IsTeamInOrg)
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||||
@Delete("/teams/:teamId/event-types/:eventTypeId")
|
@Delete("/teams/:teamId/event-types/:eventTypeId")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async deleteTeamEventType(
|
async deleteTeamEventType(
|
||||||
|
|||||||
+6
-8
@@ -248,17 +248,15 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
options: ["javascript", "python", "cobol"],
|
options: ["javascript", "python", "cobol"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
schedulingType: "COLLECTIVE",
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
schedulingType: "collective",
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
userId: teammate1.id,
|
userId: teammate1.id,
|
||||||
mandatory: true,
|
|
||||||
priority: "high",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: teammate2.id,
|
userId: teammate2.id,
|
||||||
mandatory: false,
|
|
||||||
priority: "low",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -274,6 +272,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
const data = responseBody.data;
|
const data = responseBody.data;
|
||||||
expect(data.title).toEqual(body.title);
|
expect(data.title).toEqual(body.title);
|
||||||
expect(data.hosts.length).toEqual(2);
|
expect(data.hosts.length).toEqual(2);
|
||||||
|
expect(data.schedulingType).toEqual("COLLECTIVE");
|
||||||
evaluateHost(body.hosts[0], data.hosts[0]);
|
evaluateHost(body.hosts[0], data.hosts[0]);
|
||||||
evaluateHost(body.hosts[1], data.hosts[1]);
|
evaluateHost(body.hosts[1], data.hosts[1]);
|
||||||
|
|
||||||
@@ -389,7 +388,6 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
|
||||||
const data = responseBody.data;
|
const data = responseBody.data;
|
||||||
console.log("asap responseBody.data", JSON.stringify(responseBody.data, null, 2));
|
|
||||||
expect(data.length).toEqual(2);
|
expect(data.length).toEqual(2);
|
||||||
|
|
||||||
const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||||
@@ -431,8 +429,6 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
|
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
|
||||||
{
|
{
|
||||||
userId: teammate1.id,
|
userId: teammate1.id,
|
||||||
mandatory: true,
|
|
||||||
priority: "medium",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -603,7 +599,9 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
await userRepositoryFixture.deleteByEmail(teammate2.email);
|
await userRepositoryFixture.deleteByEmail(teammate2.email);
|
||||||
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
|
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
|
||||||
await teamsRepositoryFixture.delete(team.id);
|
await teamsRepositoryFixture.delete(team.id);
|
||||||
|
await teamsRepositoryFixture.delete(falseTestTeam.id);
|
||||||
await organizationsRepositoryFixture.delete(org.id);
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
|
await organizationsRepositoryFixture.delete(falseTestOrg.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-1
@@ -1,7 +1,9 @@
|
|||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
|
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
|
import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
@@ -39,12 +41,13 @@ import { Membership } from "@calcom/prisma/client";
|
|||||||
path: "/v2/organizations/:orgId/memberships",
|
path: "/v2/organizations/:orgId/memberships",
|
||||||
version: API_VERSIONS_VALUES,
|
version: API_VERSIONS_VALUES,
|
||||||
})
|
})
|
||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||||
@DocsTags("Organizations Memberships")
|
@DocsTags("Organizations Memberships")
|
||||||
export class OrganizationsMembershipsController {
|
export class OrganizationsMembershipsController {
|
||||||
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
|
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async getAllMemberships(
|
async getAllMemberships(
|
||||||
@@ -66,6 +69,7 @@ export class OrganizationsMembershipsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
async createMembership(
|
async createMembership(
|
||||||
@@ -80,6 +84,7 @@ export class OrganizationsMembershipsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsMembershipInOrg)
|
@UseGuards(IsMembershipInOrg)
|
||||||
@Get("/:membershipId")
|
@Get("/:membershipId")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -91,6 +96,7 @@ export class OrganizationsMembershipsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsMembershipInOrg)
|
@UseGuards(IsMembershipInOrg)
|
||||||
@Delete("/:membershipId")
|
@Delete("/:membershipId")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -107,6 +113,7 @@ export class OrganizationsMembershipsController {
|
|||||||
|
|
||||||
@UseGuards(IsMembershipInOrg)
|
@UseGuards(IsMembershipInOrg)
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Patch("/:membershipId")
|
@Patch("/:membershipId")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async updateMembership(
|
async updateMembership(
|
||||||
|
|||||||
+9
-1
@@ -1,7 +1,9 @@
|
|||||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
||||||
@@ -38,7 +40,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
|||||||
path: "/v2/organizations/:orgId",
|
path: "/v2/organizations/:orgId",
|
||||||
version: API_VERSIONS_VALUES,
|
version: API_VERSIONS_VALUES,
|
||||||
})
|
})
|
||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||||
@DocsTags("Organizations Schedules")
|
@DocsTags("Organizations Schedules")
|
||||||
export class OrganizationsSchedulesController {
|
export class OrganizationsSchedulesController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -47,6 +49,7 @@ export class OrganizationsSchedulesController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Get("/schedules")
|
@Get("/schedules")
|
||||||
async getOrganizationSchedules(
|
async getOrganizationSchedules(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@@ -63,6 +66,7 @@ export class OrganizationsSchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg)
|
@UseGuards(IsUserInOrg)
|
||||||
@Post("/users/:userId/schedules")
|
@Post("/users/:userId/schedules")
|
||||||
async createUserSchedule(
|
async createUserSchedule(
|
||||||
@@ -78,6 +82,7 @@ export class OrganizationsSchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg)
|
@UseGuards(IsUserInOrg)
|
||||||
@Get("/users/:userId/schedules/:scheduleId")
|
@Get("/users/:userId/schedules/:scheduleId")
|
||||||
async getUserSchedule(
|
async getUserSchedule(
|
||||||
@@ -93,6 +98,7 @@ export class OrganizationsSchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg)
|
@UseGuards(IsUserInOrg)
|
||||||
@Get("/users/:userId/schedules")
|
@Get("/users/:userId/schedules")
|
||||||
async getUserSchedules(
|
async getUserSchedules(
|
||||||
@@ -107,6 +113,7 @@ export class OrganizationsSchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg)
|
@UseGuards(IsUserInOrg)
|
||||||
@Patch("/users/:userId/schedules/:scheduleId")
|
@Patch("/users/:userId/schedules/:scheduleId")
|
||||||
async updateUserSchedule(
|
async updateUserSchedule(
|
||||||
@@ -123,6 +130,7 @@ export class OrganizationsSchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg)
|
@UseGuards(IsUserInOrg)
|
||||||
@Delete("/users/:userId/schedules/:scheduleId")
|
@Delete("/users/:userId/schedules/:scheduleId")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,8 @@
|
|||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
@@ -38,7 +40,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
|||||||
path: "/v2/organizations/:orgId/teams/:teamId/memberships",
|
path: "/v2/organizations/:orgId/teams/:teamId/memberships",
|
||||||
version: API_VERSIONS_VALUES,
|
version: API_VERSIONS_VALUES,
|
||||||
})
|
})
|
||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||||
@DocsTags("Organizations Teams")
|
@DocsTags("Organizations Teams")
|
||||||
export class OrganizationsTeamsMembershipsController {
|
export class OrganizationsTeamsMembershipsController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -50,6 +52,7 @@ export class OrganizationsTeamsMembershipsController {
|
|||||||
@ApiOperation({ summary: "Get all the memberships of a team of an organization." })
|
@ApiOperation({ summary: "Get all the memberships of a team of an organization." })
|
||||||
@UseGuards()
|
@UseGuards()
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async getAllOrgTeamMemberships(
|
async getAllOrgTeamMemberships(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@@ -75,6 +78,7 @@ export class OrganizationsTeamsMembershipsController {
|
|||||||
@ApiOperation({ summary: "Get the membership of an organization's team by ID" })
|
@ApiOperation({ summary: "Get the membership of an organization's team by ID" })
|
||||||
@UseGuards()
|
@UseGuards()
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async getOrgTeamMembership(
|
async getOrgTeamMembership(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@@ -93,6 +97,7 @@ export class OrganizationsTeamsMembershipsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Delete("/:membershipId")
|
@Delete("/:membershipId")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
|
@ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
|
||||||
@@ -113,6 +118,7 @@ export class OrganizationsTeamsMembershipsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Patch("/:membershipId")
|
@Patch("/:membershipId")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Update the membership of an organization's team by ID" })
|
@ApiOperation({ summary: "Update the membership of an organization's team by ID" })
|
||||||
@@ -135,6 +141,7 @@ export class OrganizationsTeamsMembershipsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: "Create a membership of an organization's team" })
|
@ApiOperation({ summary: "Create a membership of an organization's team" })
|
||||||
|
|||||||
+190
-12
@@ -11,20 +11,20 @@ import { Test } from "@nestjs/testing";
|
|||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
describe("Organizations Team Endpoints", () => {
|
describe("Organizations Team Endpoints", () => {
|
||||||
describe("User Authentication - User is Org Admin", () => {
|
describe("User Authentication - User is Org Admin", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
|
||||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
let team: Team;
|
let team: Team;
|
||||||
let team2: Team;
|
let team2: Team;
|
||||||
let teamCreatedViaApi: Team;
|
let teamCreatedViaApi: Team;
|
||||||
|
let teamCreatedViaApi2: Team;
|
||||||
|
|
||||||
const userEmail = "org-admin-teams-controller-e2e@api.com";
|
const userEmail = "org-admin-teams-controller-e2e@api.com";
|
||||||
let user: User;
|
let user: User;
|
||||||
@@ -45,7 +46,6 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
).compile();
|
).compile();
|
||||||
|
|
||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
|
||||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
username: userEmail,
|
username: userEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
org = await organizationsRepositoryFixture.create({
|
org = await teamsRepositoryFixture.create({
|
||||||
name: "Test Organization",
|
name: "Test Organization",
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
});
|
});
|
||||||
@@ -85,7 +85,7 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
|
|
||||||
it("should be defined", () => {
|
it("should be defined", () => {
|
||||||
expect(userRepositoryFixture).toBeDefined();
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
expect(organizationsRepositoryFixture).toBeDefined();
|
expect(teamsRepositoryFixture).toBeDefined();
|
||||||
expect(user).toBeDefined();
|
expect(user).toBeDefined();
|
||||||
expect(org).toBeDefined();
|
expect(org).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -147,6 +147,7 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
teamCreatedViaApi.id
|
teamCreatedViaApi.id
|
||||||
);
|
);
|
||||||
expect(membership?.role ?? "").toEqual("OWNER");
|
expect(membership?.role ?? "").toEqual("OWNER");
|
||||||
|
expect(membership?.accepted).toEqual(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,11 +200,35 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
|
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create the team of the org without auto-accepting creator", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${org.id}/teams`)
|
||||||
|
.send({
|
||||||
|
name: "Team II created via API",
|
||||||
|
autoAcceptCreator: false,
|
||||||
|
} satisfies CreateOrgTeamDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
teamCreatedViaApi2 = responseBody.data;
|
||||||
|
expect(teamCreatedViaApi2.name).toEqual("Team II created via API");
|
||||||
|
expect(teamCreatedViaApi2.parentId).toEqual(org.id);
|
||||||
|
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
|
||||||
|
user.id,
|
||||||
|
teamCreatedViaApi2.id
|
||||||
|
);
|
||||||
|
expect(membership?.role ?? "").toEqual("OWNER");
|
||||||
|
expect(membership?.accepted).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
await teamsRepositoryFixture.delete(team.id);
|
await teamsRepositoryFixture.delete(team.id);
|
||||||
await teamsRepositoryFixture.delete(team2.id);
|
await teamsRepositoryFixture.delete(team2.id);
|
||||||
await organizationsRepositoryFixture.delete(org.id);
|
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
|
||||||
|
await teamsRepositoryFixture.delete(org.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -214,7 +239,6 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
|
||||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
|
||||||
@@ -234,7 +258,6 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
).compile();
|
).compile();
|
||||||
|
|
||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
|
||||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
@@ -243,7 +266,7 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
username: userEmail,
|
username: userEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
org = await organizationsRepositoryFixture.create({
|
org = await teamsRepositoryFixture.create({
|
||||||
name: "Test Organization",
|
name: "Test Organization",
|
||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
});
|
});
|
||||||
@@ -274,7 +297,7 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
|
|
||||||
it("should be defined", () => {
|
it("should be defined", () => {
|
||||||
expect(userRepositoryFixture).toBeDefined();
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
expect(organizationsRepositoryFixture).toBeDefined();
|
expect(teamsRepositoryFixture).toBeDefined();
|
||||||
expect(user).toBeDefined();
|
expect(user).toBeDefined();
|
||||||
expect(org).toBeDefined();
|
expect(org).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -317,7 +340,7 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
await teamsRepositoryFixture.delete(team.id);
|
await teamsRepositoryFixture.delete(team.id);
|
||||||
await teamsRepositoryFixture.delete(team2.id);
|
await teamsRepositoryFixture.delete(team2.id);
|
||||||
await organizationsRepositoryFixture.delete(org.id);
|
await teamsRepositoryFixture.delete(org.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -448,3 +471,158 @@ describe("Organizations Team Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Organizations Team Endpoints", () => {
|
||||||
|
describe("Platform teams", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
|
||||||
|
let oAuthClient1: PlatformOAuthClient;
|
||||||
|
let oAuthClient2: PlatformOAuthClient;
|
||||||
|
let org: Team;
|
||||||
|
let team1: Team;
|
||||||
|
let team2: Team;
|
||||||
|
|
||||||
|
const userEmail = "platform-org-member-teams-owner-controller-e2e@api.com";
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
userEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
|
})
|
||||||
|
).compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: userEmail,
|
||||||
|
username: userEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
org = await teamsRepositoryFixture.create({
|
||||||
|
name: "Platform Test Organization",
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipsRepositoryFixture.create({
|
||||||
|
role: "ADMIN",
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
oAuthClient1 = await createOAuthClient(org.id);
|
||||||
|
oAuthClient2 = await createOAuthClient(org.id);
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["redirect-uri"],
|
||||||
|
permissions: 32,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
return await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
|
expect(teamsRepositoryFixture).toBeDefined();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(org).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create first oAuth client team", async () => {
|
||||||
|
const teamName = "Platform team created via API";
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${org.id}/teams`)
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient1.id)
|
||||||
|
.send({
|
||||||
|
name: teamName,
|
||||||
|
} satisfies CreateOrgTeamDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
team1 = responseBody.data;
|
||||||
|
expect(team1.name).toEqual(teamName);
|
||||||
|
expect(team1.parentId).toEqual(org.id);
|
||||||
|
|
||||||
|
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team1.id);
|
||||||
|
|
||||||
|
expect(membership?.role ?? "").toEqual("OWNER");
|
||||||
|
expect(membership?.accepted).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create second oAuth client team", async () => {
|
||||||
|
const teamName = "Platform team II created via API";
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${org.id}/teams`)
|
||||||
|
.set(X_CAL_CLIENT_ID, oAuthClient2.id)
|
||||||
|
.set(X_CAL_SECRET_KEY, oAuthClient2.secret)
|
||||||
|
.send({
|
||||||
|
name: teamName,
|
||||||
|
} satisfies CreateOrgTeamDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
team2 = responseBody.data;
|
||||||
|
expect(team2.name).toEqual(teamName);
|
||||||
|
expect(team2.parentId).toEqual(org.id);
|
||||||
|
|
||||||
|
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team2.id);
|
||||||
|
|
||||||
|
expect(membership?.role ?? "").toEqual("OWNER");
|
||||||
|
expect(membership?.accepted).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get all the platform teams correctly tied to OAuth clients", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/teams`)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
const responseBody: ApiSuccessResponse<Team[]> = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data[0].id).toEqual(team1.id);
|
||||||
|
expect(responseBody.data[1].id).toEqual(team2.id);
|
||||||
|
|
||||||
|
const oAuthClientTeams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient1.id);
|
||||||
|
expect(oAuthClientTeams.length).toEqual(1);
|
||||||
|
const oAuthClientTeam = oAuthClientTeams[0];
|
||||||
|
expect(oAuthClientTeam.id).toEqual(team1.id);
|
||||||
|
expect(oAuthClientTeam.name).toEqual(team1.name);
|
||||||
|
|
||||||
|
const oAuthClient2Teams = await teamsRepositoryFixture.getPlatformOrgTeams(org.id, oAuthClient2.id);
|
||||||
|
expect(oAuthClient2Teams.length).toEqual(1);
|
||||||
|
const oAuthClientTeam2 = oAuthClient2Teams[0];
|
||||||
|
expect(oAuthClientTeam2.id).toEqual(team2.id);
|
||||||
|
expect(oAuthClientTeam2.name).toEqual(team2.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await teamsRepositoryFixture.delete(team1.id);
|
||||||
|
await teamsRepositoryFixture.delete(org.id);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
-6
@@ -1,16 +1,18 @@
|
|||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
|
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||||
|
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
|
||||||
import {
|
import {
|
||||||
OrgMeTeamOutputDto,
|
OrgMeTeamOutputDto,
|
||||||
OrgMeTeamsOutputResponseDto,
|
OrgMeTeamsOutputResponseDto,
|
||||||
OrgTeamOutputDto,
|
|
||||||
OrgTeamOutputResponseDto,
|
OrgTeamOutputResponseDto,
|
||||||
OrgTeamsOutputResponseDto,
|
OrgTeamsOutputResponseDto,
|
||||||
} from "@/modules/organizations/outputs/organization-team.output";
|
} from "@/modules/organizations/outputs/organization-team.output";
|
||||||
@@ -27,11 +29,13 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
|
Headers,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||||
|
import { OrgTeamOutputDto } from "@calcom/platform-types";
|
||||||
import { SkipTakePagination } from "@calcom/platform-types";
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -39,7 +43,7 @@ import { Team } from "@calcom/prisma/client";
|
|||||||
path: "/v2/organizations/:orgId/teams",
|
path: "/v2/organizations/:orgId/teams",
|
||||||
version: API_VERSIONS_VALUES,
|
version: API_VERSIONS_VALUES,
|
||||||
})
|
})
|
||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||||
@DocsTags("Organizations Teams")
|
@DocsTags("Organizations Teams")
|
||||||
export class OrganizationsTeamsController {
|
export class OrganizationsTeamsController {
|
||||||
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
||||||
@@ -47,6 +51,7 @@ export class OrganizationsTeamsController {
|
|||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: "Get all the teams of an organization." })
|
@ApiOperation({ summary: "Get all the teams of an organization." })
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
async getAllTeams(
|
async getAllTeams(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Query() queryParams: SkipTakePagination
|
@Query() queryParams: SkipTakePagination
|
||||||
@@ -62,6 +67,7 @@ export class OrganizationsTeamsController {
|
|||||||
@Get("/me")
|
@Get("/me")
|
||||||
@ApiOperation({ summary: "Get the organization's teams user is a member of" })
|
@ApiOperation({ summary: "Get the organization's teams user is a member of" })
|
||||||
@Roles("ORG_MEMBER")
|
@Roles("ORG_MEMBER")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
async getMyTeams(
|
async getMyTeams(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Query() queryParams: SkipTakePagination,
|
@Query() queryParams: SkipTakePagination,
|
||||||
@@ -88,6 +94,7 @@ export class OrganizationsTeamsController {
|
|||||||
|
|
||||||
@UseGuards(IsTeamInOrg)
|
@UseGuards(IsTeamInOrg)
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Get("/:teamId")
|
@Get("/:teamId")
|
||||||
@ApiOperation({ summary: "Get a team of the organization by ID." })
|
@ApiOperation({ summary: "Get a team of the organization by ID." })
|
||||||
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
|
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
|
||||||
@@ -99,6 +106,7 @@ export class OrganizationsTeamsController {
|
|||||||
|
|
||||||
@UseGuards(IsTeamInOrg)
|
@UseGuards(IsTeamInOrg)
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Delete("/:teamId")
|
@Delete("/:teamId")
|
||||||
@ApiOperation({ summary: "Delete a team of the organization by ID." })
|
@ApiOperation({ summary: "Delete a team of the organization by ID." })
|
||||||
async deleteTeam(
|
async deleteTeam(
|
||||||
@@ -114,12 +122,13 @@ export class OrganizationsTeamsController {
|
|||||||
|
|
||||||
@UseGuards(IsTeamInOrg)
|
@UseGuards(IsTeamInOrg)
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Patch("/:teamId")
|
@Patch("/:teamId")
|
||||||
@ApiOperation({ summary: "Update a team of the organization by ID." })
|
@ApiOperation({ summary: "Update a team of the organization by ID." })
|
||||||
async updateTeam(
|
async updateTeam(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Param("teamId", ParseIntPipe) teamId: number,
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
@Body() body: CreateOrgTeamDto
|
@Body() body: UpdateOrgTeamDto
|
||||||
): Promise<OrgTeamOutputResponseDto> {
|
): Promise<OrgTeamOutputResponseDto> {
|
||||||
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
|
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
|
||||||
return {
|
return {
|
||||||
@@ -130,13 +139,18 @@ export class OrganizationsTeamsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@ApiOperation({ summary: "Create a team for an organization." })
|
@ApiOperation({ summary: "Create a team for an organization." })
|
||||||
async createTeam(
|
async createTeam(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Body() body: CreateOrgTeamDto,
|
@Body() body: CreateOrgTeamDto,
|
||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile,
|
||||||
|
@Headers(X_CAL_CLIENT_ID) oAuthClientId?: string
|
||||||
): Promise<OrgTeamOutputResponseDto> {
|
): Promise<OrgTeamOutputResponseDto> {
|
||||||
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
|
const team = oAuthClientId
|
||||||
|
? await this.organizationsTeamsService.createPlatformOrgTeam(orgId, oAuthClientId, body, user)
|
||||||
|
: await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||||
|
|||||||
+7
-1
@@ -1,8 +1,10 @@
|
|||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
|
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
||||||
@@ -39,7 +41,7 @@ import { Team } from "@calcom/prisma/client";
|
|||||||
version: API_VERSIONS_VALUES,
|
version: API_VERSIONS_VALUES,
|
||||||
})
|
})
|
||||||
@UseInterceptors(ClassSerializerInterceptor)
|
@UseInterceptors(ClassSerializerInterceptor)
|
||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||||
@UseGuards(IsOrgGuard)
|
@UseGuards(IsOrgGuard)
|
||||||
@DocsTags("Organizations Users")
|
@DocsTags("Organizations Users")
|
||||||
export class OrganizationsUsersController {
|
export class OrganizationsUsersController {
|
||||||
@@ -47,6 +49,7 @@ export class OrganizationsUsersController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
async getOrganizationsUsers(
|
async getOrganizationsUsers(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Query() query: GetOrganizationsUsersInput
|
@Query() query: GetOrganizationsUsersInput
|
||||||
@@ -66,6 +69,7 @@ export class OrganizationsUsersController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
async createOrganizationUser(
|
async createOrganizationUser(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@GetOrg() org: Team,
|
@GetOrg() org: Team,
|
||||||
@@ -85,6 +89,7 @@ export class OrganizationsUsersController {
|
|||||||
|
|
||||||
@Patch("/:userId")
|
@Patch("/:userId")
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg)
|
@UseGuards(IsUserInOrg)
|
||||||
async updateOrganizationUser(
|
async updateOrganizationUser(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@@ -101,6 +106,7 @@ export class OrganizationsUsersController {
|
|||||||
|
|
||||||
@Delete("/:userId")
|
@Delete("/:userId")
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg)
|
@UseGuards(IsUserInOrg)
|
||||||
async deleteOrganizationUser(
|
async deleteOrganizationUser(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
|||||||
+164
-3
@@ -8,6 +8,7 @@ import { INestApplication } from "@nestjs/common";
|
|||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
@@ -15,7 +16,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
|||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
import { User, Team, EventType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
describe("Organizations Users Endpoints", () => {
|
describe("Organizations Users Endpoints", () => {
|
||||||
describe("Member role", () => {
|
describe("Member role", () => {
|
||||||
@@ -29,7 +30,6 @@ describe("Organizations Users Endpoints", () => {
|
|||||||
const userEmail = "member1@org.com";
|
const userEmail = "member1@org.com";
|
||||||
let user: User;
|
let user: User;
|
||||||
let org: Team;
|
let org: Team;
|
||||||
let membership: Membership;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withApiAuth(
|
||||||
@@ -70,7 +70,7 @@ describe("Organizations Users Endpoints", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
@@ -344,4 +344,165 @@ describe("Organizations Users Endpoints", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Member event-types", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let membershipFixtures: MembershipRepositoryFixture;
|
||||||
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
|
||||||
|
const authEmail = "auth@org.com";
|
||||||
|
let user: User;
|
||||||
|
let org: Team;
|
||||||
|
let team: Team;
|
||||||
|
let managedEventType: EventType;
|
||||||
|
let createdUser: User;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
authEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
|
})
|
||||||
|
).compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
org = await organizationsRepositoryFixture.create({
|
||||||
|
name: "Test org 4",
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
team = await organizationsRepositoryFixture.create({
|
||||||
|
name: "Test org 4 team",
|
||||||
|
isOrganization: false,
|
||||||
|
parent: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: authEmail,
|
||||||
|
username: authEmail,
|
||||||
|
organization: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${user.id}`,
|
||||||
|
username: authEmail,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: org.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||||
|
|
||||||
|
await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
|
team: {
|
||||||
|
connect: { id: team.id },
|
||||||
|
},
|
||||||
|
title: "Collective Event Type",
|
||||||
|
slug: "collective-event-type",
|
||||||
|
length: 30,
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||||
|
schedulingType: "MANAGED",
|
||||||
|
team: {
|
||||||
|
connect: { id: team.id },
|
||||||
|
},
|
||||||
|
title: "Managed Event Type",
|
||||||
|
slug: "managed-event-type",
|
||||||
|
length: 60,
|
||||||
|
assignAllTeamMembers: true,
|
||||||
|
bookingFields: [],
|
||||||
|
locations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
|
expect(organizationsRepositoryFixture).toBeDefined();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(org).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a new org user with team event-types", async () => {
|
||||||
|
const newOrgUser = {
|
||||||
|
email: "new-org-member-d@org.com",
|
||||||
|
organizationRole: "MEMBER",
|
||||||
|
autoAccept: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { body } = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${org.id}/users`)
|
||||||
|
.send({
|
||||||
|
email: newOrgUser.email,
|
||||||
|
})
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.set("Accept", "application/json");
|
||||||
|
|
||||||
|
const userData = body.data;
|
||||||
|
expect(body.status).toBe(SUCCESS_STATUS);
|
||||||
|
userHasCorrectEventTypes(userData.id);
|
||||||
|
createdUser = userData;
|
||||||
|
teamHasCorrectEventTypes(team.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function userHasCorrectEventTypes(userId: number) {
|
||||||
|
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
|
||||||
|
|
||||||
|
expect(eventTypes?.length).toEqual(1);
|
||||||
|
|
||||||
|
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||||
|
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamHasCorrectEventTypes(teamId: number) {
|
||||||
|
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
|
||||||
|
|
||||||
|
expect(eventTypes?.length).toEqual(2);
|
||||||
|
|
||||||
|
// note(Lauris): managed event-types with assignAllTeamMembers: true
|
||||||
|
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
|
||||||
|
|
||||||
|
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
|
||||||
|
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||||
|
expect(collective).toBeTruthy();
|
||||||
|
expect(collective?.hosts).toBeDefined();
|
||||||
|
expect(collective?.hosts?.length).toEqual(1);
|
||||||
|
expect(collective?.hosts[0].userId).toEqual(createdUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// await membershipFixtures.delete(membership.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(createdUser.email);
|
||||||
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -72,4 +72,8 @@ export class CreateOrgTeamDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
readonly weekStart?: string = "Sunday";
|
readonly weekStart?: string = "Sunday";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
readonly autoAcceptCreator?: boolean = true;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateOrgTeamDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(1)
|
||||||
|
readonly name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
readonly logoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
readonly calVideoLogo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
readonly appLogo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
readonly appIconLogo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly bio?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
readonly hideBranding?: boolean = false;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
readonly isPrivate?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
readonly hideBookATeamMember?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly theme?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly brandColor?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly darkBrandColor?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
readonly bannerUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly timeFormat?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly timeZone?: string = "Europe/London";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
readonly weekStart?: string = "Sunday";
|
||||||
|
}
|
||||||
@@ -1,122 +1,9 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { Expose, Type } from "class-transformer";
|
import { Expose, Type } from "class-transformer";
|
||||||
import {
|
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||||
IsBoolean,
|
|
||||||
IsEnum,
|
|
||||||
IsInt,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUrl,
|
|
||||||
Length,
|
|
||||||
ValidateNested,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { OrgTeamOutputDto } from "@calcom/platform-types";
|
||||||
export class OrgTeamOutputDto {
|
|
||||||
@IsInt()
|
|
||||||
@Expose()
|
|
||||||
readonly id!: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@IsOptional()
|
|
||||||
@Expose()
|
|
||||||
readonly parentId?: number;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Length(1)
|
|
||||||
@Expose()
|
|
||||||
readonly name!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
readonly slug?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl()
|
|
||||||
@Expose()
|
|
||||||
readonly logoUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl()
|
|
||||||
@Expose()
|
|
||||||
readonly calVideoLogo?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl()
|
|
||||||
@Expose()
|
|
||||||
readonly appLogo?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl()
|
|
||||||
@Expose()
|
|
||||||
readonly appIconLogo?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
readonly bio?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
@Expose()
|
|
||||||
readonly hideBranding?: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
@Expose()
|
|
||||||
readonly isOrganization?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
@Expose()
|
|
||||||
readonly isPrivate?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
@Expose()
|
|
||||||
readonly hideBookATeamMember?: boolean = false;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
readonly metadata?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
readonly theme?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
readonly brandColor?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
readonly darkBrandColor?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl()
|
|
||||||
@Expose()
|
|
||||||
readonly bannerUrl?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
readonly timeFormat?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
readonly timeZone?: string = "Europe/London";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
readonly weekStart?: string = "Sunday";
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
|
export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
+12
@@ -16,6 +16,18 @@ export class OrganizationsEventTypesRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string) {
|
||||||
|
return this.dbRead.prisma.eventType.findUnique({
|
||||||
|
where: {
|
||||||
|
teamId_slug: {
|
||||||
|
teamId,
|
||||||
|
slug: eventTypeSlug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { users: true, schedule: true, hosts: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getTeamEventTypes(teamId: number) {
|
async getTeamEventTypes(teamId: number) {
|
||||||
return this.dbRead.prisma.eventType.findMany({
|
return this.dbRead.prisma.eventType.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
+29
-1
@@ -1,4 +1,5 @@
|
|||||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||||
|
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
@@ -16,6 +17,14 @@ export class OrganizationsTeamsRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findOrgTeams(organizationId: number) {
|
||||||
|
return this.dbRead.prisma.team.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteOrgTeam(organizationId: number, teamId: number) {
|
async deleteOrgTeam(organizationId: number, teamId: number) {
|
||||||
return this.dbRead.prisma.team.delete({
|
return this.dbRead.prisma.team.delete({
|
||||||
where: {
|
where: {
|
||||||
@@ -32,7 +41,26 @@ export class OrganizationsTeamsRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
|
async createPlatformOrgTeam(organizationId: number, oAuthClientId: string, data: CreateOrgTeamDto) {
|
||||||
|
return this.dbRead.prisma.team.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
parentId: organizationId,
|
||||||
|
createdByOAuthClientId: oAuthClientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlatformOrgTeams(organizationId: number, oAuthClientId: string) {
|
||||||
|
return this.dbRead.prisma.team.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: organizationId,
|
||||||
|
createdByOAuthClientId: oAuthClientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOrgTeam(organizationId: number, teamId: number, data: UpdateOrgTeamDto) {
|
||||||
return this.dbRead.prisma.team.update({
|
return this.dbRead.prisma.team.update({
|
||||||
data: { ...data },
|
data: { ...data },
|
||||||
where: { id: teamId, parentId: organizationId, isOrganization: false },
|
where: { id: teamId, parentId: organizationId, isOrganization: false },
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
|
|||||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
|
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
|
||||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateTeamEventTypeInput_2024_06_14,
|
CreateTeamEventTypeInput_2024_06_14,
|
||||||
UpdateTeamEventTypeInput_2024_06_14,
|
UpdateTeamEventTypeInput_2024_06_14,
|
||||||
HostPriority,
|
HostPriority,
|
||||||
|
SchedulingType,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -30,7 +31,9 @@ export class InputOrganizationsEventTypesService {
|
|||||||
|
|
||||||
const teamEventType = {
|
const teamEventType = {
|
||||||
...eventType,
|
...eventType,
|
||||||
hosts: assignAllTeamMembers ? await this.getAllTeamMembers(teamId) : this.transformInputHosts(hosts),
|
hosts: assignAllTeamMembers
|
||||||
|
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
|
||||||
|
: this.transformInputHosts(hosts, inputEventType.schedulingType),
|
||||||
assignAllTeamMembers,
|
assignAllTeamMembers,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
@@ -46,6 +49,11 @@ export class InputOrganizationsEventTypesService {
|
|||||||
const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
|
const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
|
||||||
|
|
||||||
const eventType = this.inputEventTypesService.transformInputUpdateEventType(rest);
|
const eventType = this.inputEventTypesService.transformInputUpdateEventType(rest);
|
||||||
|
const dbEventType = await this.orgEventTypesRepository.getTeamEventType(teamId, eventTypeId);
|
||||||
|
|
||||||
|
if (!dbEventType) {
|
||||||
|
throw new BadRequestException("Event type to update not found");
|
||||||
|
}
|
||||||
|
|
||||||
const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId);
|
const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId);
|
||||||
const teamEventType = {
|
const teamEventType = {
|
||||||
@@ -53,8 +61,8 @@ export class InputOrganizationsEventTypesService {
|
|||||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||||
hosts: !children
|
hosts: !children
|
||||||
? assignAllTeamMembers
|
? assignAllTeamMembers
|
||||||
? await this.getAllTeamMembers(teamId)
|
? await this.getAllTeamMembers(teamId, dbEventType.schedulingType)
|
||||||
: this.transformInputHosts(hosts)
|
: this.transformInputHosts(hosts, dbEventType.schedulingType)
|
||||||
: undefined,
|
: undefined,
|
||||||
assignAllTeamMembers,
|
assignAllTeamMembers,
|
||||||
children,
|
children,
|
||||||
@@ -118,28 +126,34 @@ export class InputOrganizationsEventTypesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllTeamMembers(teamId: number) {
|
async getAllTeamMembers(teamId: number, schedulingType: SchedulingType | null) {
|
||||||
const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
|
const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
|
||||||
|
const isFixed = schedulingType === "COLLECTIVE" ? true : false;
|
||||||
|
|
||||||
return membersIds.map((id) => ({
|
return membersIds.map((id) => ({
|
||||||
userId: id,
|
userId: id,
|
||||||
isFixed: false,
|
isFixed,
|
||||||
priority: 2,
|
priority: 2,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
transformInputHosts(inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) {
|
transformInputHosts(
|
||||||
|
inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined,
|
||||||
|
schedulingType: SchedulingType | null
|
||||||
|
) {
|
||||||
if (!inputHosts) {
|
if (!inputHosts) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultMandatory = false;
|
|
||||||
const defaultPriority = "medium";
|
const defaultPriority = "medium";
|
||||||
|
const defaultIsFixed = false;
|
||||||
|
|
||||||
return inputHosts.map((host) => ({
|
return inputHosts.map((host) => ({
|
||||||
userId: host.userId,
|
userId: host.userId,
|
||||||
isFixed: host.mandatory || defaultMandatory,
|
isFixed: schedulingType === "COLLECTIVE" ? true : host.mandatory || defaultIsFixed,
|
||||||
priority: getPriorityValue(host.priority || defaultPriority),
|
priority: getPriorityValue(
|
||||||
|
schedulingType === "COLLECTIVE" ? "medium" : host.priority || defaultPriority
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -9,7 +9,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
|
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.22";
|
||||||
import {
|
import {
|
||||||
CreateTeamEventTypeInput_2024_06_14,
|
CreateTeamEventTypeInput_2024_06_14,
|
||||||
UpdateTeamEventTypeInput_2024_06_14,
|
UpdateTeamEventTypeInput_2024_06_14,
|
||||||
@@ -98,6 +98,19 @@ export class OrganizationsEventTypesService {
|
|||||||
return this.outputService.getResponseTeamEventType(eventType);
|
return this.outputService.getResponseTeamEventType(eventType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTeamEventTypeBySlug(teamId: number, eventTypeSlug: string) {
|
||||||
|
const eventType = await this.organizationEventTypesRepository.getTeamEventTypeBySlug(
|
||||||
|
teamId,
|
||||||
|
eventTypeSlug
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!eventType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.outputService.getResponseTeamEventType(eventType);
|
||||||
|
}
|
||||||
|
|
||||||
async getTeamEventTypes(teamId: number) {
|
async getTeamEventTypes(teamId: number) {
|
||||||
const eventTypes = await this.organizationEventTypesRepository.getTeamEventTypes(teamId);
|
const eventTypes = await this.organizationEventTypesRepository.getTeamEventTypes(teamId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
|
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
|
||||||
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import type { EventType, User, Schedule, Host } from "@prisma/client";
|
import type { EventType, User, Schedule, Host } from "@prisma/client";
|
||||||
|
|
||||||
import { HostPriority } from "@calcom/platform-types";
|
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
|
||||||
|
import { SchedulingType } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
type EventTypeRelations = { users: User[]; schedule: Schedule | null; hosts: Host[] };
|
type EventTypeRelations = { users: User[]; schedule: Schedule | null; hosts: Host[] };
|
||||||
type DatabaseEventType = EventType & EventTypeRelations;
|
type DatabaseEventType = EventType & EventTypeRelations;
|
||||||
@@ -47,7 +49,8 @@ type Input = Pick<
|
|||||||
export class OutputOrganizationsEventTypesService {
|
export class OutputOrganizationsEventTypesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
|
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
|
||||||
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository
|
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository,
|
||||||
|
private readonly usersRepository: UsersRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getResponseTeamEventType(databaseEventType: Input) {
|
async getResponseTeamEventType(databaseEventType: Input) {
|
||||||
@@ -60,7 +63,7 @@ export class OutputOrganizationsEventTypesService {
|
|||||||
const hosts =
|
const hosts =
|
||||||
databaseEventType.schedulingType === "MANAGED"
|
databaseEventType.schedulingType === "MANAGED"
|
||||||
? await this.getManagedEventTypeHosts(databaseEventType.id)
|
? await this.getManagedEventTypeHosts(databaseEventType.id)
|
||||||
: this.transformHosts(databaseEventType.hosts);
|
: await this.transformHosts(databaseEventType.hosts, databaseEventType.schedulingType);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -74,25 +77,41 @@ export class OutputOrganizationsEventTypesService {
|
|||||||
|
|
||||||
async getManagedEventTypeHosts(eventTypeId: number) {
|
async getManagedEventTypeHosts(eventTypeId: number) {
|
||||||
const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventTypeId);
|
const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventTypeId);
|
||||||
const hostsIds: number[] = [];
|
const transformedHosts: TeamEventTypeResponseHost[] = [];
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (child.userId) {
|
if (child.userId) {
|
||||||
hostsIds.push(child.userId);
|
const user = await this.usersRepository.findById(child.userId);
|
||||||
|
transformedHosts.push({ userId: child.userId, name: user?.name || "" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hostsIds.map((userId) => ({ userId }));
|
return transformedHosts;
|
||||||
}
|
}
|
||||||
|
|
||||||
transformHosts(hosts: Host[]) {
|
async transformHosts(
|
||||||
if (!hosts) return [];
|
databaseHosts: Host[],
|
||||||
|
schedulingType: SchedulingType | null
|
||||||
|
): Promise<TeamEventTypeResponseHost[]> {
|
||||||
|
if (!schedulingType) return [];
|
||||||
|
|
||||||
return hosts.map((host) => {
|
const transformedHosts: TeamEventTypeResponseHost[] = [];
|
||||||
return {
|
const databaseUsers = await this.usersRepository.findByIds(databaseHosts.map((host) => host.userId));
|
||||||
userId: host.userId,
|
|
||||||
mandatory: host.isFixed,
|
for (const databaseHost of databaseHosts) {
|
||||||
priority: getPriorityLabel(host.priority || 2),
|
const databaseUser = databaseUsers.find((u) => u.id === databaseHost.userId);
|
||||||
};
|
if (schedulingType === "ROUND_ROBIN") {
|
||||||
});
|
// note(Lauris): round robin is the only team event where mandatory (isFixed) and priority are used
|
||||||
|
transformedHosts.push({
|
||||||
|
userId: databaseHost.userId,
|
||||||
|
name: databaseUser?.name || "",
|
||||||
|
mandatory: databaseHost.isFixed,
|
||||||
|
priority: getPriorityLabel(databaseHost.priority || 2),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
transformedHosts.push({ userId: databaseHost.userId, name: databaseUser?.name || "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformedHosts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||||
|
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
|
||||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries-0.0.22";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsTeamsService {
|
export class OrganizationsTeamsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -31,16 +34,58 @@ export class OrganizationsTeamsService {
|
|||||||
return team;
|
return team;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
|
async updateOrgTeam(organizationId: number, teamId: number, data: UpdateOrgTeamDto) {
|
||||||
const team = await this.organizationsTeamRepository.updateOrgTeam(organizationId, teamId, data);
|
const team = await this.organizationsTeamRepository.updateOrgTeam(organizationId, teamId, data);
|
||||||
return team;
|
return team;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
|
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
|
||||||
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, data);
|
const { autoAcceptCreator, ...rest } = data;
|
||||||
|
|
||||||
|
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, rest);
|
||||||
|
|
||||||
if (user.role !== "ADMIN") {
|
if (user.role !== "ADMIN") {
|
||||||
await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", true);
|
await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", !!autoAcceptCreator);
|
||||||
}
|
}
|
||||||
return team;
|
return team;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createPlatformOrgTeam(
|
||||||
|
organizationId: number,
|
||||||
|
oAuthClientId: string,
|
||||||
|
data: CreateOrgTeamDto,
|
||||||
|
user: UserWithProfile
|
||||||
|
) {
|
||||||
|
const { autoAcceptCreator, ...rest } = data;
|
||||||
|
|
||||||
|
const team = await this.organizationsTeamRepository.createPlatformOrgTeam(
|
||||||
|
organizationId,
|
||||||
|
oAuthClientId,
|
||||||
|
rest
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user.role !== "ADMIN") {
|
||||||
|
await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", !!autoAcceptCreator);
|
||||||
|
}
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addUserToTeamEvents(userId: number, organizationId: number) {
|
||||||
|
const orgTeams = await this.organizationsTeamRepository.findOrgTeams(organizationId);
|
||||||
|
|
||||||
|
for (const team of orgTeams) {
|
||||||
|
await updateNewTeamMemberEventTypes(userId, team.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addUserToPlatformTeamEvents(userId: number, organizationId: number, oAuthClientId: string) {
|
||||||
|
const oAuthClientTeams = await this.organizationsTeamRepository.getPlatformOrgTeams(
|
||||||
|
organizationId,
|
||||||
|
oAuthClientId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const team of oAuthClientTeams) {
|
||||||
|
await updateNewTeamMemberEventTypes(userId, team.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,17 +2,19 @@ import { EmailService } from "@/modules/email/email.service";
|
|||||||
import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
|
import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
|
||||||
import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
|
import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
|
||||||
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
|
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
|
||||||
|
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||||
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
|
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
|
||||||
import { Injectable, ConflictException } from "@nestjs/common";
|
import { Injectable, ConflictException } from "@nestjs/common";
|
||||||
import { plainToInstance } from "class-transformer";
|
import { plainToInstance } from "class-transformer";
|
||||||
|
|
||||||
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.21";
|
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsUsersService {
|
export class OrganizationsUsersService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly organizationsUsersRepository: OrganizationsUsersRepository,
|
private readonly organizationsUsersRepository: OrganizationsUsersRepository,
|
||||||
|
private readonly organizationsTeamsService: OrganizationsTeamsService,
|
||||||
private readonly emailService: EmailService
|
private readonly emailService: EmailService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -77,6 +79,8 @@ export class OrganizationsUsersService {
|
|||||||
updateUserBody
|
updateUserBody
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.organizationsTeamsService.addUserToTeamEvents(user.id, org.id);
|
||||||
|
|
||||||
// Need to send email to new user to create password
|
// Need to send email to new user to create password
|
||||||
await this.emailService.sendSignupToOrganizationEmail({
|
await this.emailService.sendSignupToOrganizationEmail({
|
||||||
usernameOrEmail,
|
usernameOrEmail,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [ProfilesRepository],
|
||||||
|
exports: [ProfilesRepository],
|
||||||
|
})
|
||||||
|
export class ProfilesModule {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProfilesRepository {
|
||||||
|
constructor(private readonly dbRead: PrismaReadService) {}
|
||||||
|
|
||||||
|
async getPlatformOwnerUserId(organizationId: number) {
|
||||||
|
const profile = await this.dbRead.prisma.profile.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return profile?.userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
|||||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.21";
|
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.22";
|
||||||
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.21";
|
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
|
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
|
||||||
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
|
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.21";
|
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { ReserveSlotInput } from "@calcom/platform-types";
|
import { ReserveSlotInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Controller, Get } from "@nestjs/common";
|
|||||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.21";
|
import type { CityTimezones } from "@calcom/platform-libraries-0.0.22";
|
||||||
import { ApiResponse } from "@calcom/platform-types";
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { RedisService } from "@/modules/redis/redis.service";
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.21";
|
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.22";
|
||||||
import type { CityTimezones } from "@calcom/platform-libraries-0.0.21";
|
import type { CityTimezones } from "@calcom/platform-libraries-0.0.22";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TimezonesService {
|
export class TimezonesService {
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ export class UsersRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByIds(userIds: number[]) {
|
||||||
|
return this.dbRead.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: userIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findByIdWithCalendars(userId: number) {
|
async findByIdWithCalendars(userId: number) {
|
||||||
return this.dbRead.prisma.user.findUnique({
|
return this.dbRead.prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -962,6 +962,14 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "x-cal-client-id",
|
||||||
|
"required": true,
|
||||||
|
"in": "header",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -991,6 +999,57 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v2/organizations/{orgId}/teams/me": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "OrganizationsTeamsController_getMyTeams",
|
||||||
|
"summary": "Get the organization's teams user is a member of",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "orgId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/OrgMeTeamsOutputResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Organizations Teams"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v2/organizations/{orgId}/teams/{teamId}": {
|
"/v2/organizations/{orgId}/teams/{teamId}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsTeamsController_getTeam",
|
"operationId": "OrganizationsTeamsController_getTeam",
|
||||||
@@ -1075,7 +1134,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/CreateOrgTeamDto"
|
"$ref": "#/components/schemas/UpdateOrgTeamDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1160,6 +1219,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CreateScheduleInput_2024_06_11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"201": {
|
"201": {
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -1262,6 +1331,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -2301,6 +2380,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -5100,6 +5189,29 @@
|
|||||||
"data"
|
"data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"OrgMeTeamsOutputResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "success",
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/OrgTeamOutputDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
},
|
||||||
"OrgTeamOutputResponseDto": {
|
"OrgTeamOutputResponseDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5120,6 +5232,69 @@
|
|||||||
"data"
|
"data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"UpdateOrgTeamDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"logoUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"calVideoLogo": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"appLogo": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"appIconLogo": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bio": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"hideBranding": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"isPrivate": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"hideBookATeamMember": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"brandColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"darkBrandColor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bannerUrl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timeFormat": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"timeZone": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "Europe/London"
|
||||||
|
},
|
||||||
|
"weekStart": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "Sunday"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CreateOrgTeamDto": {
|
"CreateOrgTeamDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5180,6 +5355,10 @@
|
|||||||
"weekStart": {
|
"weekStart": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "Sunday"
|
"default": "Sunday"
|
||||||
|
},
|
||||||
|
"autoAcceptCreator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -5329,6 +5508,57 @@
|
|||||||
"data"
|
"data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"CreateScheduleInput_2024_06_11": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "One-on-one coaching"
|
||||||
|
},
|
||||||
|
"timeZone": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Europe/Rome"
|
||||||
|
},
|
||||||
|
"availability": {
|
||||||
|
"example": [
|
||||||
|
{
|
||||||
|
"days": [
|
||||||
|
"Monday",
|
||||||
|
"Tuesday"
|
||||||
|
],
|
||||||
|
"startTime": "09:00",
|
||||||
|
"endTime": "10:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isDefault": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"example": [
|
||||||
|
{
|
||||||
|
"date": "2024-05-20",
|
||||||
|
"startTime": "12:00",
|
||||||
|
"endTime": "14:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"timeZone",
|
||||||
|
"isDefault"
|
||||||
|
]
|
||||||
|
},
|
||||||
"CreateScheduleOutput_2024_06_11": {
|
"CreateScheduleOutput_2024_06_11": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5377,6 +5607,52 @@
|
|||||||
"data"
|
"data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"UpdateScheduleInput_2024_06_11": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "One-on-one coaching"
|
||||||
|
},
|
||||||
|
"timeZone": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Europe/Rome"
|
||||||
|
},
|
||||||
|
"availability": {
|
||||||
|
"example": [
|
||||||
|
{
|
||||||
|
"days": [
|
||||||
|
"Monday",
|
||||||
|
"Tuesday"
|
||||||
|
],
|
||||||
|
"startTime": "09:00",
|
||||||
|
"endTime": "10:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isDefault": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"example": [
|
||||||
|
{
|
||||||
|
"date": "2024-05-20",
|
||||||
|
"startTime": "12:00",
|
||||||
|
"endTime": "14:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UpdateScheduleOutput_2024_06_11": {
|
"UpdateScheduleOutput_2024_06_11": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6012,9 +6288,12 @@
|
|||||||
"freq"
|
"freq"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Host": {
|
"TeamEventTypeResponseHost": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
@@ -6028,6 +6307,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"name",
|
||||||
"userId"
|
"userId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -6145,7 +6425,7 @@
|
|||||||
"hosts": {
|
"hosts": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/Host"
|
"$ref": "#/components/schemas/TeamEventTypeResponseHost"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"assignAllTeamMembers": {
|
"assignAllTeamMembers": {
|
||||||
@@ -6467,6 +6747,26 @@
|
|||||||
"userId"
|
"userId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"GetDefaultScheduleOutput_2024_06_11": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "success",
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/ScheduleOutput_2024_06_11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
},
|
||||||
"CreateAvailabilityInput_2024_04_15": {
|
"CreateAvailabilityInput_2024_04_15": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6764,6 +7064,66 @@
|
|||||||
"data"
|
"data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"UpdateScheduleInput_2024_04_15": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"timeZone": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"isDefault": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"example": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"start": "2022-01-01T00:00:00.000Z",
|
||||||
|
"end": "2022-01-02T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"dateOverrides": {
|
||||||
|
"example": [
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"start": "2022-01-01T00:00:00.000Z",
|
||||||
|
"end": "2022-01-02T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"timeZone",
|
||||||
|
"name",
|
||||||
|
"isDefault",
|
||||||
|
"schedule"
|
||||||
|
]
|
||||||
|
},
|
||||||
"EventTypeModel_2024_04_15": {
|
"EventTypeModel_2024_04_15": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7033,6 +7393,10 @@
|
|||||||
},
|
},
|
||||||
"timeZone": {
|
"timeZone": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"organizationId": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -7042,7 +7406,8 @@
|
|||||||
"timeFormat",
|
"timeFormat",
|
||||||
"defaultScheduleId",
|
"defaultScheduleId",
|
||||||
"weekStart",
|
"weekStart",
|
||||||
"timeZone"
|
"timeZone",
|
||||||
|
"organizationId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"GetMeOutput": {
|
"GetMeOutput": {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
import { EventType } from "@prisma/client";
|
import { EventType } from "@prisma/client";
|
||||||
|
|
||||||
|
import { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export class EventTypesRepositoryFixture {
|
export class EventTypesRepositoryFixture {
|
||||||
private primaReadClient: PrismaReadService["prisma"];
|
private primaReadClient: PrismaReadService["prisma"];
|
||||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||||
@@ -26,6 +28,9 @@ export class EventTypesRepositoryFixture {
|
|||||||
where: {
|
where: {
|
||||||
teamId,
|
teamId,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
hosts: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +46,10 @@ export class EventTypesRepositoryFixture {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createTeamEventType(data: Prisma.EventTypeCreateInput) {
|
||||||
|
return this.prismaWriteClient.eventType.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
async delete(eventTypeId: EventType["id"]) {
|
async delete(eventTypeId: EventType["id"]) {
|
||||||
return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } });
|
return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,13 @@ export class TeamRepositoryFixture {
|
|||||||
async delete(teamId: Team["id"]) {
|
async delete(teamId: Team["id"]) {
|
||||||
return this.prismaWriteClient.team.delete({ where: { id: teamId } });
|
return this.prismaWriteClient.team.delete({ where: { id: teamId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPlatformOrgTeams(organizationId: number, oAuthClientId: string) {
|
||||||
|
return this.primaReadClient.team.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: organizationId,
|
||||||
|
createdByOAuthClientId: oAuthClientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,10 @@ export default function withEmbedSsrAppDir<T extends Record<string, any>>(
|
|||||||
) {
|
) {
|
||||||
return async (context: GetServerSidePropsContext): Promise<T> => {
|
return async (context: GetServerSidePropsContext): Promise<T> => {
|
||||||
const { embed, layout } = context.query;
|
const { embed, layout } = context.query;
|
||||||
|
const isCOEPEnabled = context.query["flag.coep"] === "true";
|
||||||
|
if (isCOEPEnabled) {
|
||||||
|
context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const props = await getData(context);
|
const props = await getData(context);
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
|
|||||||
<input
|
<input
|
||||||
id={`${id}`}
|
id={`${id}`}
|
||||||
checked={selected}
|
checked={selected}
|
||||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
|
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ import type { ActionType } from "@calcom/ui";
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
Dropdown,
|
Dropdown,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -43,7 +39,6 @@ import {
|
|||||||
MeetingTimeInTimezones,
|
MeetingTimeInTimezones,
|
||||||
showToast,
|
showToast,
|
||||||
TableActions,
|
TableActions,
|
||||||
TextAreaField,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
@@ -75,19 +70,12 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
i18n: { language },
|
i18n: { language },
|
||||||
} = useLocale();
|
} = useLocale();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
|
||||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
|
||||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||||
const cardCharged = booking?.payment[0]?.success;
|
const cardCharged = booking?.payment[0]?.success;
|
||||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: () => {
|
||||||
if (data?.status === BookingStatus.REJECTED) {
|
showToast(t("booking_confirmation_success"), "success");
|
||||||
setRejectionDialogIsOpen(false);
|
|
||||||
showToast(t("booking_rejection_success"), "success");
|
|
||||||
} else {
|
|
||||||
showToast(t("booking_confirmation_success"), "success");
|
|
||||||
}
|
|
||||||
utils.viewer.bookings.invalidate();
|
utils.viewer.bookings.invalidate();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -122,7 +110,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
let body = {
|
let body = {
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
confirmed: confirm,
|
confirmed: confirm,
|
||||||
reason: rejectionReason,
|
reason: "",
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||||
@@ -145,9 +133,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
{
|
{
|
||||||
id: "reject",
|
id: "reject",
|
||||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||||
onClick: () => {
|
href: `/booking/${booking.uid}?reject=true`,
|
||||||
setRejectionDialogIsOpen(true);
|
|
||||||
},
|
|
||||||
icon: "ban",
|
icon: "ban",
|
||||||
disabled: mutation.isPending,
|
disabled: mutation.isPending,
|
||||||
},
|
},
|
||||||
@@ -347,38 +333,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
timeFormat={userTimeFormat ?? null}
|
timeFormat={userTimeFormat ?? null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col transition sm:flex-row">
|
||||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
|
||||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
|
||||||
<div>
|
|
||||||
<TextAreaField
|
|
||||||
name="rejectionReason"
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
{t("rejection_reason")}
|
|
||||||
<span className="text-subtle font-normal"> (Optional)</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
value={rejectionReason}
|
|
||||||
onChange={(e) => setRejectionReason(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose />
|
|
||||||
<Button
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
data-testid="rejection-confirm"
|
|
||||||
onClick={() => {
|
|
||||||
bookingConfirm(false);
|
|
||||||
}}>
|
|
||||||
{t("rejection_confirmation")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
|
|
||||||
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
|
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
|
||||||
<Link href={bookingLink}>
|
<Link href={bookingLink}>
|
||||||
<div className="cursor-pointer py-4">
|
<div className="cursor-pointer py-4">
|
||||||
@@ -721,7 +676,7 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
|||||||
<button
|
<button
|
||||||
data-testid="guest"
|
data-testid="guest"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="radix-state-open:text-blue-500 hover:text-blue-500">
|
className="radix-state-open:text-blue-500 transition hover:text-blue-500">
|
||||||
{noShow ? (
|
{noShow ? (
|
||||||
<s>
|
<s>
|
||||||
{name || email} <Icon name="eye-off" className="inline h-4" />
|
{name || email} <Icon name="eye-off" className="inline h-4" />
|
||||||
@@ -839,7 +794,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
|||||||
<button
|
<button
|
||||||
data-testid="more-guests"
|
data-testid="more-guests"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
|
className="radix-state-open:text-blue-500 transition hover:text-blue-500 focus:outline-none">
|
||||||
{t("plus_more", { count: attendees.length - 1 })}
|
{t("plus_more", { count: attendees.length - 1 })}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -900,7 +855,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
|
className="radix-state-open:text-blue-500 transition hover:text-blue-500 focus:outline-none">
|
||||||
{t("plus_more", { count: guests.length - 1 })}
|
{t("plus_more", { count: guests.length - 1 })}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { Button, TextArea } from "@calcom/ui";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
booking: {
|
||||||
|
uid?: string | null;
|
||||||
|
id: number;
|
||||||
|
recurringEventId: string | null;
|
||||||
|
};
|
||||||
|
setIsRejectionMode: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RejectBooking(props: Props) {
|
||||||
|
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||||
|
const { t } = useLocale();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const { booking } = props;
|
||||||
|
const isRecurring = booking.recurringEventId !== null;
|
||||||
|
|
||||||
|
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
props.setIsRejectionMode();
|
||||||
|
utils.viewer.bookings.invalidate();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
utils.viewer.bookings.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingConfirm = async (confirm: boolean) => {
|
||||||
|
let body = {
|
||||||
|
bookingId: booking.id,
|
||||||
|
confirmed: confirm,
|
||||||
|
reason: rejectionReason,
|
||||||
|
};
|
||||||
|
if (isRecurring) {
|
||||||
|
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||||
|
}
|
||||||
|
mutation.mutate(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||||
|
if (node !== null) {
|
||||||
|
node.scrollIntoView({ behavior: "smooth" });
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-5 sm:mt-6">
|
||||||
|
<label className="text-default font-medium">
|
||||||
|
<>
|
||||||
|
{t("rejection_reason")}
|
||||||
|
<span className="text-subtle font-normal"> (Optional)</span>
|
||||||
|
</>
|
||||||
|
</label>
|
||||||
|
<TextArea
|
||||||
|
ref={rejectBookingRef}
|
||||||
|
placeholder={t("rejection_reason_placeholder")}
|
||||||
|
value={rejectionReason}
|
||||||
|
onChange={(e) => setRejectionReason(e.target.value)}
|
||||||
|
className="mb-4 mt-2 w-full "
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||||
|
<div className="ml-auto flex w-full space-x-4 ">
|
||||||
|
<Button className="ml-auto" color="secondary" onClick={() => props.setIsRejectionMode()}>
|
||||||
|
{t("nevermind")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={mutation.isPending}
|
||||||
|
data-testid="rejection-confirm"
|
||||||
|
onClick={() => {
|
||||||
|
bookingConfirm(false);
|
||||||
|
}}>
|
||||||
|
{t("rejection_confirmation")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useFormContext, Controller } from "react-hook-form";
|
import { useFormContext, Controller } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getTemplateFieldsSchema } from "@calcom/features/ee/cal-ai-phone/getTemplateFieldsSchema";
|
||||||
|
import { TEMPLATES_FIELDS } from "@calcom/features/ee/cal-ai-phone/template-fields-map";
|
||||||
|
import type { TemplateType } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||||
|
import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -17,9 +21,9 @@ import {
|
|||||||
SettingsToggle,
|
SettingsToggle,
|
||||||
Divider,
|
Divider,
|
||||||
TextField,
|
TextField,
|
||||||
TextAreaField,
|
|
||||||
PhoneInput,
|
PhoneInput,
|
||||||
showToast,
|
showToast,
|
||||||
|
Icon,
|
||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
|
|
||||||
type AIEventControllerProps = {
|
type AIEventControllerProps = {
|
||||||
@@ -85,10 +89,64 @@ export default function AIEventController({ eventType, isTeamEvent }: AIEventCon
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ErrorMessage = ({ fieldName, message }: { fieldName: string; message: string }) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<div data-testid={`error-message-${fieldName}`} className="mt-2 flex items-center text-sm text-red-700 ">
|
||||||
|
<Icon name="info" className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||||
|
<p>{t(message || "invalid_input")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TemplateFields = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const formMethods = useFormContext<FormValues>();
|
||||||
|
const { control, watch } = formMethods;
|
||||||
|
|
||||||
|
const templateType = watch("aiPhoneCallConfig.templateType");
|
||||||
|
const fields = TEMPLATES_FIELDS[templateType as TemplateType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fields?.map((field) => (
|
||||||
|
<div key={field.name}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`aiPhoneCallConfig.${field.name}`}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => {
|
||||||
|
const { variableName, ...restField } = field;
|
||||||
|
const variableInfo = !!variableName ? `: ${t("variable")} {{${variableName}}}` : "";
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ComponentForField
|
||||||
|
field={{
|
||||||
|
...restField,
|
||||||
|
label: `${t(field.defaultLabel)}${variableInfo}`,
|
||||||
|
placeholder: t(field.placeholder),
|
||||||
|
}}
|
||||||
|
value={value ?? ""}
|
||||||
|
readOnly={false}
|
||||||
|
setValue={(val: unknown) => {
|
||||||
|
onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error?.message && <ErrorMessage message={error.message} fieldName={field.name} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
|
|
||||||
const [calApiKey, setCalApiKey] = useState("");
|
const [calApiKey, setCalApiKey] = useState("");
|
||||||
|
|
||||||
const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({
|
const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({
|
||||||
@@ -98,43 +156,25 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
showToast(t("something_went_wrong"), "error");
|
const message = err?.message ? err.message : t("something_went_wrong");
|
||||||
|
showToast(message, "error");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// const NewPhoneButton = () => {
|
|
||||||
// const { t } = useLocale();
|
|
||||||
// return (
|
|
||||||
// <Button
|
|
||||||
// color="primary"
|
|
||||||
// data-testid="new_phone_number"
|
|
||||||
// StartIcon={Plus}
|
|
||||||
// onClick={() => setCreateModalOpen(true)}>
|
|
||||||
// {t("New Phone number")}
|
|
||||||
// </Button>
|
|
||||||
// );
|
|
||||||
// };
|
|
||||||
|
|
||||||
// v1 will require the user to log in to Retellai.com to create a phone number, and an agent and
|
|
||||||
// authorize it with the Cal.com API key / OAuth
|
|
||||||
// const retellAuthorized = true; // TODO: call retellAPI here
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = formMethods.getValues("aiPhoneCallConfig");
|
const values = formMethods.getValues("aiPhoneCallConfig");
|
||||||
|
const { templateType } = values;
|
||||||
|
|
||||||
const data = await AIPhoneSettingSchema.parseAsync({
|
const schema = getTemplateFieldsSchema({ templateType });
|
||||||
generalPrompt: values.generalPrompt,
|
|
||||||
beginMessage: values.beginMessage,
|
const data = schema.parse({
|
||||||
enabled: values.enabled,
|
...values,
|
||||||
guestName: values.guestName,
|
guestEmail: values.guestEmail && values.guestEmail.trim().length ? values.guestEmail : undefined,
|
||||||
guestEmail: values.guestEmail.trim().length ? values.guestEmail : undefined,
|
guestCompany:
|
||||||
guestCompany: values.guestCompany.trim().length ? values.guestCompany : undefined,
|
values.guestCompany && values.guestCompany.trim().length ? values.guestCompany : undefined,
|
||||||
|
guestName: values.guestName && values.guestName.trim().length ? values.guestName : undefined,
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
numberToCall: values.numberToCall,
|
|
||||||
yourPhoneNumber: values.yourPhoneNumber,
|
|
||||||
calApiKey,
|
calApiKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,6 +184,15 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
const fieldName = err.issues?.[0]?.path?.[0];
|
const fieldName = err.issues?.[0]?.path?.[0];
|
||||||
const message = err.issues?.[0]?.message;
|
const message = err.issues?.[0]?.message;
|
||||||
showToast(`Error on ${fieldName}: ${message} `, "error");
|
showToast(`Error on ${fieldName}: ${message} `, "error");
|
||||||
|
|
||||||
|
const issues = err.issues;
|
||||||
|
for (const issue of issues) {
|
||||||
|
const fieldName = `aiPhoneCallConfig.${issue.path[0]}` as unknown as keyof FormValues;
|
||||||
|
formMethods.setError(fieldName, {
|
||||||
|
type: "custom",
|
||||||
|
message: issue.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast(t("something_went_wrong"), "error");
|
showToast(t("something_went_wrong"), "error");
|
||||||
}
|
}
|
||||||
@@ -157,68 +206,89 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
<Label>{t("your_phone_number")}</Label>
|
<Label>{t("your_phone_number")}</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||||
render={({ field: { onChange, value } }) => {
|
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
|
||||||
return (
|
return (
|
||||||
<PhoneInput
|
<div>
|
||||||
required
|
<PhoneInput
|
||||||
placeholder={t("your_phone_number")}
|
required
|
||||||
id="aiPhoneCallConfig.yourPhoneNumber"
|
placeholder={t("your_phone_number")}
|
||||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
id="aiPhoneCallConfig.yourPhoneNumber"
|
||||||
value={value}
|
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||||
onChange={(val) => {
|
value={value}
|
||||||
onChange(val);
|
onChange={(val) => {
|
||||||
}}
|
onChange(val);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
{error?.message && <ErrorMessage message={error.message} fieldName={name} />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<RadioGroup.Root
|
||||||
|
defaultValue={eventType?.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE"}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
formMethods.setValue("aiPhoneCallConfig.templateType", val, { shouldDirty: true });
|
||||||
|
}}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex flex-1 rounded-lg border p-4">
|
||||||
|
<RadioGroup.Item
|
||||||
|
id="CHECK_IN_APPOINTMENT"
|
||||||
|
key="CHECK_IN_APPOINTMENT"
|
||||||
|
value="CHECK_IN_APPOINTMENT"
|
||||||
|
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2 ">
|
||||||
|
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||||
|
</RadioGroup.Item>
|
||||||
|
<label className="h-full" htmlFor="CHECK_IN_APPOINTMENT">
|
||||||
|
<h2 className="font-semibold leading-tight">{t("check_in_assistant")}</h2>
|
||||||
|
<p className="text-subtle mt-2">{t("check_in_assistant_description")}</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 rounded-lg border p-4">
|
||||||
|
<RadioGroup.Item
|
||||||
|
id="CUSTOM_TEMPLATE"
|
||||||
|
key="CUSTOM_TEMPLATE"
|
||||||
|
value="CUSTOM_TEMPLATE"
|
||||||
|
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||||
|
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||||
|
</RadioGroup.Item>
|
||||||
|
<label htmlFor="CUSTOM_TEMPLATE">
|
||||||
|
<h2 className="font-semibold leading-tight">{t("custom_template")}</h2>
|
||||||
|
<p className="text-subtle mt-2">{t("create_your_own_prompt")}</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</>
|
||||||
|
|
||||||
<Label>{t("number_to_call")}</Label>
|
<Label>{t("number_to_call")}</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="aiPhoneCallConfig.numberToCall"
|
name="aiPhoneCallConfig.numberToCall"
|
||||||
render={({ field: { onChange, value } }) => {
|
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
|
||||||
return (
|
return (
|
||||||
<PhoneInput
|
<div>
|
||||||
required
|
<PhoneInput
|
||||||
placeholder={t("phone_number")}
|
required
|
||||||
id="aiPhoneCallConfig.numberToCall"
|
placeholder={t("phone_number")}
|
||||||
name="aiPhoneCallConfig.numberToCall"
|
id="aiPhoneCallConfig.numberToCall"
|
||||||
value={value}
|
name="aiPhoneCallConfig.numberToCall"
|
||||||
onChange={(val) => {
|
value={value}
|
||||||
onChange(val);
|
onChange={(val) => {
|
||||||
}}
|
onChange(val);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
{error?.message && <ErrorMessage message={error.message} fieldName={name} />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TemplateFields />
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
</>
|
</>
|
||||||
|
|
||||||
<TextField
|
|
||||||
type="text"
|
|
||||||
hint="Variable: {{name}}"
|
|
||||||
label={t("guest_name")}
|
|
||||||
placeholder="Jane Doe"
|
|
||||||
{...formMethods.register("aiPhoneCallConfig.guestName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
type="text"
|
|
||||||
hint="Variable: {{email}}"
|
|
||||||
label={t("guest_email")}
|
|
||||||
placeholder="jane@acme.com"
|
|
||||||
{...formMethods.register("aiPhoneCallConfig.guestEmail")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
type="text"
|
|
||||||
hint="Variable: {{company}}"
|
|
||||||
label={t("guest_company")}
|
|
||||||
placeholder="Acme"
|
|
||||||
{...formMethods.register("aiPhoneCallConfig.guestCompany")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
hint="For eg:- cal_live_0123.."
|
hint="For eg:- cal_live_0123.."
|
||||||
@@ -232,26 +302,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<TextAreaField
|
|
||||||
rows={3}
|
|
||||||
required
|
|
||||||
placeholder={t("general_prompt")}
|
|
||||||
label={t("general_prompt")}
|
|
||||||
{...formMethods.register("aiPhoneCallConfig.generalPrompt")}
|
|
||||||
onChange={(e) => {
|
|
||||||
formMethods.setValue("aiPhoneCallConfig.generalPrompt", e.target.value, { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAreaField
|
|
||||||
rows={3}
|
|
||||||
placeholder={t("begin_message")}
|
|
||||||
label={t("begin_message")}
|
|
||||||
{...formMethods.register("aiPhoneCallConfig.beginMessage")}
|
|
||||||
onChange={(e) => {
|
|
||||||
formMethods.setValue("aiPhoneCallConfig.beginMessage", e.target.value, { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={createCallMutation.isPending}
|
disabled={createCallMutation.isPending}
|
||||||
@@ -268,34 +318,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
|||||||
and learn how to build workflows.
|
and learn how to build workflows.
|
||||||
</small> */}
|
</small> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO:
|
|
||||||
<>
|
|
||||||
<EmptyScreen
|
|
||||||
Icon={Phone}
|
|
||||||
headline={t("Create your phone number")}
|
|
||||||
description={t(
|
|
||||||
"This phone number can be called by guests but can also do proactive outbound calls by the AI agent."
|
|
||||||
)}
|
|
||||||
buttonRaw={
|
|
||||||
<div className="flex justify-between gap-2">
|
|
||||||
<NewPhoneButton />
|
|
||||||
<Button color="secondary">{t("learn_more")}</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Dialog open={createModalOpen} onOpenChange={(isOpen) => !isOpen && setCreateModalOpen(false)}>
|
|
||||||
<DialogContent
|
|
||||||
enableOverflow
|
|
||||||
title={t("Create phone number")}
|
|
||||||
description={t("This number can later be called or can do proactive outbound calls")}>
|
|
||||||
<div className="mb-12 mt-4">
|
|
||||||
<TextField placeholder="+415" hint="Area Code" />
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -53,45 +53,63 @@ const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder={placeHolder_}
|
placeholder={placeHolder_}
|
||||||
{...register("customEventName", {
|
{...register("customEventName", {
|
||||||
validate: (value) =>
|
validate: (value) => {
|
||||||
validateCustomEventName(value, t("invalid_event_name_variables"), event.bookingFields),
|
const validationResult = validateCustomEventName(value, event.bookingFields);
|
||||||
|
return typeof validationResult === "string"
|
||||||
|
? t("invalid_event_name_variables", { item: validationResult })
|
||||||
|
: validationResult;
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
className="mb-0"
|
className="mb-0"
|
||||||
/>
|
/>
|
||||||
<div className="pt-6 text-sm">
|
<div className="pt-6 text-sm">
|
||||||
<div className="bg-subtle mb-6 rounded-md p-2">
|
<div className="bg-subtle mb-6 rounded-md p-2">
|
||||||
<h1 className="text-emphasis mb-2 ml-1 font-medium">{t("available_variables")}</h1>
|
<h1 className="text-emphasis mb-2 ml-1 font-medium">{t("available_variables")}</h1>
|
||||||
<div className="mb-2.5 flex font-normal">
|
<div className="scroll-bar h-[216px] overflow-y-auto">
|
||||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Event type title}`}</p>
|
|
||||||
<p className="text-emphasis">{t("event_name_info")}</p>
|
|
||||||
</div>
|
|
||||||
<div className="mb-2.5 flex font-normal">
|
|
||||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser}`}</p>
|
|
||||||
<p className="text-emphasis">{t("your_full_name")}</p>
|
|
||||||
</div>
|
|
||||||
<div className="mb-2.5 flex font-normal">
|
|
||||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser first name}`}</p>
|
|
||||||
<p className="text-emphasis">{t("organizer_first_name")}</p>
|
|
||||||
</div>
|
|
||||||
<div className="mb-2.5 flex font-normal">
|
|
||||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler}`}</p>
|
|
||||||
<p className="text-emphasis">{t("scheduler_full_name")}</p>
|
|
||||||
</div>
|
|
||||||
{isNameFieldSplit && (
|
|
||||||
<div className="mb-2.5 flex font-normal">
|
<div className="mb-2.5 flex font-normal">
|
||||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler first name}`}</p>
|
<p className="text-subtle ml-1 mr-5 w-32">{`{Event type title}`}</p>
|
||||||
<p className="text-emphasis">{t("scheduler_first_name")}</p>
|
<p className="text-emphasis">{t("event_name_info")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{isNameFieldSplit && (
|
|
||||||
<div className="mb-2.5 flex font-normal">
|
<div className="mb-2.5 flex font-normal">
|
||||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler last name}`}</p>
|
<p className="text-subtle ml-1 mr-5 w-32">{`{Organiser}`}</p>
|
||||||
<p className="text-emphasis">{t("scheduler_last_name")}</p>
|
<p className="text-emphasis">{t("your_full_name")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mb-2.5 flex font-normal">
|
||||||
<div className="mb-1 flex font-normal">
|
<p className="text-subtle ml-1 mr-5 w-32">{`{Organiser first name}`}</p>
|
||||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Location}`}</p>
|
<p className="text-emphasis">{t("organizer_first_name")}</p>
|
||||||
<p className="text-emphasis">{t("location_info")}</p>
|
</div>
|
||||||
|
<div className="mb-2.5 flex font-normal">
|
||||||
|
<p className="text-subtle ml-1 mr-5 w-32">{`{Scheduler}`}</p>
|
||||||
|
<p className="text-emphasis">{t("scheduler_full_name")}</p>
|
||||||
|
</div>
|
||||||
|
{isNameFieldSplit && (
|
||||||
|
<div className="mb-2.5 flex font-normal">
|
||||||
|
<p className="text-subtle ml-1 mr-5 w-32">{`{Scheduler first name}`}</p>
|
||||||
|
<p className="text-emphasis">{t("scheduler_first_name")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isNameFieldSplit && (
|
||||||
|
<div className="mb-2.5 flex font-normal">
|
||||||
|
<p className="text-subtle ml-1 mr-5 w-32">{`{Scheduler last name}`}</p>
|
||||||
|
<p className="text-emphasis">{t("scheduler_last_name")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-2.5 flex font-normal">
|
||||||
|
<p className="text-subtle ml-1 mr-5 w-32">{`{Location}`}</p>
|
||||||
|
<p className="text-emphasis">{t("location_info")}</p>
|
||||||
|
</div>
|
||||||
|
{event.bookingFields && (
|
||||||
|
<p className="text-subtle mb-2 ml-1 font-medium">{t("booking_question_response_variables")}</p>
|
||||||
|
)}
|
||||||
|
{event.bookingFields &&
|
||||||
|
Object.keys(event.bookingFields).map((bookingfield, index) => (
|
||||||
|
<div key={index} className="mb-2.5 flex font-normal">
|
||||||
|
<p className="text-subtle ml-1 mr-5 w-32">{`{${bookingfield}}`}</p>
|
||||||
|
<p className="text-emphasis capitalize">
|
||||||
|
{event.bookingFields?.[bookingfield]?.toString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ type Props = {
|
|||||||
isUserOrganizationAdmin: boolean;
|
isUserOrganizationAdmin: boolean;
|
||||||
bookerUrl: string;
|
bookerUrl: string;
|
||||||
activeWebhooksNumber: number;
|
activeWebhooksNumber: number;
|
||||||
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type getNavigationProps = {
|
type getNavigationProps = {
|
||||||
@@ -117,7 +118,12 @@ function DeleteDialog({
|
|||||||
eventTypeId,
|
eventTypeId,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: { isManagedEvent: string; eventTypeId: number } & Pick<DialogProps, "open" | "onOpenChange">) {
|
onDelete,
|
||||||
|
}: {
|
||||||
|
isManagedEvent: string;
|
||||||
|
eventTypeId: number;
|
||||||
|
onDelete: () => void;
|
||||||
|
} & Pick<DialogProps, "open" | "onOpenChange">) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -125,6 +131,7 @@ function DeleteDialog({
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.viewer.eventTypes.invalidate();
|
await utils.viewer.eventTypes.invalidate();
|
||||||
showToast(t("event_type_deleted_successfully"), "success");
|
showToast(t("event_type_deleted_successfully"), "success");
|
||||||
|
onDelete();
|
||||||
router.push("/event-types");
|
router.push("/event-types");
|
||||||
onOpenChange?.(false);
|
onOpenChange?.(false);
|
||||||
},
|
},
|
||||||
@@ -181,6 +188,7 @@ function EventTypeSingleLayout({
|
|||||||
isUserOrganizationAdmin,
|
isUserOrganizationAdmin,
|
||||||
bookerUrl,
|
bookerUrl,
|
||||||
activeWebhooksNumber,
|
activeWebhooksNumber,
|
||||||
|
onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const eventTypesLockedByOrg = eventType.team?.parent?.organizationSettings?.lockEventTypeCreationForUsers;
|
const eventTypesLockedByOrg = eventType.team?.parent?.organizationSettings?.lockEventTypeCreationForUsers;
|
||||||
@@ -510,6 +518,7 @@ function EventTypeSingleLayout({
|
|||||||
isManagedEvent={isManagedEvent}
|
isManagedEvent={isManagedEvent}
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EventTypeEmbedDialog />
|
<EventTypeEmbedDialog />
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAu
|
|||||||
isLoading={isDeleting}
|
isLoading={isDeleting}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
areEmailsEnabled={client.areEmailsEnabled}
|
areEmailsEnabled={client.areEmailsEnabled}
|
||||||
|
organizationId={client.organizationId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type OAuthClientCardProps = {
|
|||||||
secret: string;
|
secret: string;
|
||||||
onDelete: (id: string) => Promise<void>;
|
onDelete: (id: string) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
organizationId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OAuthClientCard = ({
|
export const OAuthClientCard = ({
|
||||||
@@ -38,6 +39,7 @@ export const OAuthClientCard = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
isLoading,
|
isLoading,
|
||||||
areEmailsEnabled,
|
areEmailsEnabled,
|
||||||
|
organizationId,
|
||||||
}: OAuthClientCardProps) => {
|
}: OAuthClientCardProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -113,6 +115,21 @@ export const OAuthClientCard = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<div className="font-semibold">Organization Id:</div>
|
||||||
|
<div>{organizationId}</div>
|
||||||
|
<Icon
|
||||||
|
name="clipboard"
|
||||||
|
type="button"
|
||||||
|
className="h-4 w-4 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(organizationId.toString());
|
||||||
|
showToast("Organization id copied to clipboard.", "success");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="border-subtle flex text-sm">
|
<div className="border-subtle flex text-sm">
|
||||||
<span className="font-semibold">Permissions: </span>
|
<span className="font-semibold">Permissions: </span>
|
||||||
{permissions ? <div className="flex">{clientPermissions}</div> : <> Disabled</>}
|
{permissions ? <div className="flex">{clientPermissions}</div> : <> Disabled</>}
|
||||||
@@ -148,11 +165,7 @@ export const OAuthClientCard = ({
|
|||||||
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
|
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button color="destructive" loading={isLoading} disabled={isLoading} onClick={() => onDelete(id)}>
|
||||||
className="bg-red-500 text-white hover:bg-red-600"
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => onDelete(id)}>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const OAuthClientForm = ({
|
|||||||
<input
|
<input
|
||||||
{...register(`${permissionKey}Read`)}
|
{...register(`${permissionKey}Read`)}
|
||||||
id={`${permissionKey}Read`}
|
id={`${permissionKey}Read`}
|
||||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={!!defaultValues}
|
disabled={!!defaultValues}
|
||||||
/>
|
/>
|
||||||
@@ -89,7 +89,7 @@ export const OAuthClientForm = ({
|
|||||||
<input
|
<input
|
||||||
{...register(`${permissionKey}Write`)}
|
{...register(`${permissionKey}Write`)}
|
||||||
id={`${permissionKey}Write`}
|
id={`${permissionKey}Write`}
|
||||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={!!defaultValues}
|
disabled={!!defaultValues}
|
||||||
/>
|
/>
|
||||||
@@ -224,7 +224,7 @@ export const OAuthClientForm = ({
|
|||||||
<input
|
<input
|
||||||
{...register("areEmailsEnabled")}
|
{...register("areEmailsEnabled")}
|
||||||
id="areEmailsEnabled"
|
id="areEmailsEnabled"
|
||||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={isFormDisabled}
|
disabled={isFormDisabled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
|||||||
{...rest}
|
{...rest}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="text-emphasis focus:ring-emphasis dark:text-muted border-default bg-default h-4 w-4 rounded"
|
className="text-emphasis focus:ring-emphasis dark:text-muted border-default bg-default h-4 w-4 rounded transition"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="ms-2 text-sm">{description}</span>
|
<span className="ms-2 text-sm">{description}</span>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { EventTypeAssignedUsers, EventTypeHosts } from "~/event-types/views/event-types-single-view";
|
||||||
|
|
||||||
|
// This function checks if EventType requires assignment.
|
||||||
|
// returns true: if EventType requires assignment but there is no assignment yet done by the user.
|
||||||
|
// returns false: for all other scenarios.
|
||||||
|
export function checkForEmptyAssignment({
|
||||||
|
assignedUsers,
|
||||||
|
hosts,
|
||||||
|
isManagedEventType,
|
||||||
|
assignAllTeamMembers,
|
||||||
|
}: {
|
||||||
|
assignedUsers: EventTypeAssignedUsers;
|
||||||
|
hosts: EventTypeHosts;
|
||||||
|
isManagedEventType: boolean;
|
||||||
|
assignAllTeamMembers: boolean;
|
||||||
|
}): boolean {
|
||||||
|
// If Team-events have assignAllTeamMembers checked, return false as assignemnt is complete.
|
||||||
|
if (assignAllTeamMembers) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For managed eventtype check if assigned users are empty.
|
||||||
|
// For non-managed eventtype check if hosts are empty.
|
||||||
|
if (isManagedEventType ? assignedUsers.length === 0 : hosts.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1574,6 +1574,7 @@ async function createOrg(
|
|||||||
metadata: {
|
metadata: {
|
||||||
...(data.metadata || {}),
|
...(data.metadata || {}),
|
||||||
},
|
},
|
||||||
|
createdByOAuthClient: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1591,6 +1592,7 @@ async function createTeamOutsideOrg(
|
|||||||
...(data.metadata || {}),
|
...(data.metadata || {}),
|
||||||
isOrganization: false,
|
isOrganization: false,
|
||||||
},
|
},
|
||||||
|
createdByOAuthClient: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,24 @@ describe("withEmbedSsr", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => {
|
||||||
|
const withEmbedGetSsr = withEmbedSsr(
|
||||||
|
getServerSidePropsFnGenerator({
|
||||||
|
redirectUrl: "/reschedule",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = getServerSidePropsContextArg({
|
||||||
|
embedRelatedParams: {
|
||||||
|
layout: "week_view",
|
||||||
|
embed: "",
|
||||||
|
"flag.coep": "true",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await withEmbedGetSsr(context);
|
||||||
|
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when redirect destination is absolute, should add /embed to end of the path", () => {
|
describe("when redirect destination is absolute, should add /embed to end of the path", () => {
|
||||||
@@ -206,6 +224,23 @@ describe("withEmbedSsr", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => {
|
||||||
|
const withEmbedGetSsr = withEmbedSsr(
|
||||||
|
getServerSidePropsFnGenerator({
|
||||||
|
redirectUrl: "https://calcom.cal.local/owner",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = getServerSidePropsContextArg({
|
||||||
|
embedRelatedParams: {
|
||||||
|
layout: "week_view",
|
||||||
|
embed: "",
|
||||||
|
"flag.coep": "true",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await withEmbedGetSsr(context);
|
||||||
|
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,6 +268,85 @@ describe("withEmbedSsr", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => {
|
||||||
|
const withEmbedGetSsr = withEmbedSsr(
|
||||||
|
getServerSidePropsFnGenerator({
|
||||||
|
props: {
|
||||||
|
prop1: "value1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = getServerSidePropsContextArg({
|
||||||
|
embedRelatedParams: {
|
||||||
|
layout: "week_view",
|
||||||
|
embed: "",
|
||||||
|
"flag.coep": "true",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await withEmbedGetSsr(context);
|
||||||
|
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add Cross-Origin-Embedder-Policy header when "flag.coep" query param is false', async () => {
|
||||||
|
const withEmbedGetSsr = withEmbedSsr(
|
||||||
|
getServerSidePropsFnGenerator({
|
||||||
|
props: {
|
||||||
|
prop1: "value1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = getServerSidePropsContextArg({
|
||||||
|
embedRelatedParams: {
|
||||||
|
layout: "week_view",
|
||||||
|
embed: "",
|
||||||
|
"flag.coep": "false",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await withEmbedGetSsr(context);
|
||||||
|
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add Cross-Origin-Embedder-Policy header when "flag.coep" query param is not set', async () => {
|
||||||
|
const withEmbedGetSsr = withEmbedSsr(
|
||||||
|
getServerSidePropsFnGenerator({
|
||||||
|
props: {
|
||||||
|
prop1: "value1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = getServerSidePropsContextArg({
|
||||||
|
embedRelatedParams: {
|
||||||
|
layout: "week_view",
|
||||||
|
embed: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await withEmbedGetSsr(context);
|
||||||
|
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add Cross-Origin-Embedder-Policy header when "flag.coep" query param is set to something other than true', async () => {
|
||||||
|
const withEmbedGetSsr = withEmbedSsr(
|
||||||
|
getServerSidePropsFnGenerator({
|
||||||
|
props: {
|
||||||
|
prop1: "value1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = getServerSidePropsContextArg({
|
||||||
|
embedRelatedParams: {
|
||||||
|
layout: "week_view",
|
||||||
|
embed: "",
|
||||||
|
"flag.coep": "abasdf",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await withEmbedGetSsr(context);
|
||||||
|
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when gSSP doesn't have props or redirect ", () => {
|
describe("when gSSP doesn't have props or redirect ", () => {
|
||||||
@@ -254,5 +368,23 @@ describe("withEmbedSsr", () => {
|
|||||||
|
|
||||||
expect(ret).toEqual({ notFound: true });
|
expect(ret).toEqual({ notFound: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should add Cross-Origin-Embedder-Policy header when "flag.coep" query param is true', async () => {
|
||||||
|
const withEmbedGetSsr = withEmbedSsr(
|
||||||
|
getServerSidePropsFnGenerator({
|
||||||
|
notFound: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const context = getServerSidePropsContextArg({
|
||||||
|
embedRelatedParams: {
|
||||||
|
layout: "week_view",
|
||||||
|
embed: "",
|
||||||
|
"flag.coep": "true",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await withEmbedGetSsr(context);
|
||||||
|
expect(context.res.getHeader("Cross-Origin-Embedder-Policy")).toBe("require-corp");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -11,6 +11,10 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
|||||||
const ssrResponse = await getServerSideProps(context);
|
const ssrResponse = await getServerSideProps(context);
|
||||||
const embed = context.query.embed;
|
const embed = context.query.embed;
|
||||||
const layout = context.query.layout;
|
const layout = context.query.layout;
|
||||||
|
const isCOEPEnabled = context.query["flag.coep"] === "true";
|
||||||
|
if (isCOEPEnabled) {
|
||||||
|
context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||||
|
}
|
||||||
|
|
||||||
if ("redirect" in ssrResponse) {
|
if ("redirect" in ssrResponse) {
|
||||||
const destinationUrl = ssrResponse.redirect.destination;
|
const destinationUrl = ssrResponse.redirect.destination;
|
||||||
@@ -44,6 +48,7 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
|||||||
if (!("props" in ssrResponse)) {
|
if (!("props" in ssrResponse)) {
|
||||||
return ssrResponse;
|
return ssrResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...ssrResponse,
|
...ssrResponse,
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
} from "@calcom/ui";
|
} from "@calcom/ui";
|
||||||
import PageWrapper from "@calcom/web/components/PageWrapper";
|
import PageWrapper from "@calcom/web/components/PageWrapper";
|
||||||
import CancelBooking from "@calcom/web/components/booking/CancelBooking";
|
import CancelBooking from "@calcom/web/components/booking/CancelBooking";
|
||||||
|
import RejectBooking from "@calcom/web/components/booking/RejectBooking";
|
||||||
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
|
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
|
||||||
import { timeZone } from "@calcom/web/lib/clock";
|
import { timeZone } from "@calcom/web/lib/clock";
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ const querySchema = z.object({
|
|||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
eventTypeSlug: z.string().optional(),
|
eventTypeSlug: z.string().optional(),
|
||||||
cancel: stringToBoolean,
|
cancel: stringToBoolean,
|
||||||
|
reject: stringToBoolean,
|
||||||
allRemainingBookings: stringToBoolean,
|
allRemainingBookings: stringToBoolean,
|
||||||
changes: stringToBoolean,
|
changes: stringToBoolean,
|
||||||
reschedule: stringToBoolean,
|
reschedule: stringToBoolean,
|
||||||
@@ -114,6 +116,7 @@ export default function Success(props: PageProps) {
|
|||||||
allRemainingBookings,
|
allRemainingBookings,
|
||||||
isSuccessBookingPage,
|
isSuccessBookingPage,
|
||||||
cancel: isCancellationMode,
|
cancel: isCancellationMode,
|
||||||
|
reject: isRejectionMode,
|
||||||
formerTime,
|
formerTime,
|
||||||
email,
|
email,
|
||||||
seatReferenceUid,
|
seatReferenceUid,
|
||||||
@@ -209,6 +212,16 @@ export default function Success(props: PageProps) {
|
|||||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setIsRejectionMode() {
|
||||||
|
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
||||||
|
|
||||||
|
if (_searchParams.get("reject")) {
|
||||||
|
_searchParams.delete("reject");
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
let evtName = eventType.eventName;
|
let evtName = eventType.eventName;
|
||||||
if (eventType.isDynamic && bookingInfo.responses?.title) {
|
if (eventType.isDynamic && bookingInfo.responses?.title) {
|
||||||
evtName = bookingInfo.responses.title as string;
|
evtName = bookingInfo.responses.title as string;
|
||||||
@@ -660,6 +673,7 @@ export default function Success(props: PageProps) {
|
|||||||
{!requiresLoginToUpdate &&
|
{!requiresLoginToUpdate &&
|
||||||
(!needsConfirmation || !userIsOwner) &&
|
(!needsConfirmation || !userIsOwner) &&
|
||||||
isReschedulable &&
|
isReschedulable &&
|
||||||
|
!isRejectionMode &&
|
||||||
(!isCancellationMode ? (
|
(!isCancellationMode ? (
|
||||||
<>
|
<>
|
||||||
<hr className="border-subtle mb-8" />
|
<hr className="border-subtle mb-8" />
|
||||||
@@ -714,6 +728,19 @@ export default function Success(props: PageProps) {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
|
{userIsOwner && !isCancelled && isRejectionMode && (
|
||||||
|
<>
|
||||||
|
<hr className="border-subtle" />
|
||||||
|
<RejectBooking
|
||||||
|
booking={{
|
||||||
|
id: bookingInfo.id,
|
||||||
|
uid: bookingInfo?.uid,
|
||||||
|
recurringEventId: bookingInfo.recurringEventId,
|
||||||
|
}}
|
||||||
|
setIsRejectionMode={setIsRejectionMode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{userIsOwner &&
|
{userIsOwner &&
|
||||||
!needsConfirmation &&
|
!needsConfirmation &&
|
||||||
!isCancellationMode &&
|
!isCancellationMode &&
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { z } from "zod";
|
|||||||
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
||||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||||
import { validateCustomEventName } from "@calcom/core/event";
|
import { validateCustomEventName } from "@calcom/core/event";
|
||||||
|
import {
|
||||||
|
DEFAULT_PROMPT_VALUE,
|
||||||
|
DEFAULT_BEGIN_MESSAGE,
|
||||||
|
} from "@calcom/features/ee/cal-ai-phone/promptTemplates";
|
||||||
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
|
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
|
||||||
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||||
@@ -26,6 +30,7 @@ import { HttpError } from "@calcom/lib/http-error";
|
|||||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
import { SchedulingType } from "@calcom/prisma/enums";
|
||||||
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
@@ -33,47 +38,12 @@ import { trpc } from "@calcom/trpc/react";
|
|||||||
import { Form, showToast } from "@calcom/ui";
|
import { Form, showToast } from "@calcom/ui";
|
||||||
|
|
||||||
import type { AppProps } from "@lib/app-providers";
|
import type { AppProps } from "@lib/app-providers";
|
||||||
|
import { checkForEmptyAssignment } from "@lib/checkForEmptyAssignment";
|
||||||
|
|
||||||
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
|
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
|
||||||
|
|
||||||
import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
|
import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
|
||||||
|
|
||||||
const DEFAULT_PROMPT_VALUE = `## You are helping user set up a call with the support team. The appointment is 15 min long. You are a pleasant and friendly.
|
|
||||||
|
|
||||||
## Style Guardrails
|
|
||||||
Be Concise: Respond succinctly, addressing one topic at most.
|
|
||||||
Embrace Variety: Use diverse language and rephrasing to enhance clarity without repeating content.
|
|
||||||
Be Conversational: Use everyday language, making the chat feel like talking to a friend.
|
|
||||||
Be Proactive: Lead the conversation, often wrapping up with a question or next-step suggestion.
|
|
||||||
Avoid multiple questions in a single response.
|
|
||||||
Get clarity: If the user only partially answers a question, or if the answer is unclear, keep asking to get clarity.
|
|
||||||
Use a colloquial way of referring to the date (like Friday, Jan 14th, or Tuesday, Jan 12th, 2024 at 8am).
|
|
||||||
If you are saying a time like 8:00 AM, just say 8 AM and emit the trailing zeros.
|
|
||||||
|
|
||||||
## Response Guideline
|
|
||||||
Adapt and Guess: Try to understand transcripts that may contain transcription errors. Avoid mentioning \"transcription error\" in the response.
|
|
||||||
Stay in Character: Keep conversations within your role'''s scope, guiding them back creatively without repeating.
|
|
||||||
Ensure Fluid Dialogue: Respond in a role-appropriate, direct manner to maintain a smooth conversation flow.
|
|
||||||
|
|
||||||
## Schedule Rule
|
|
||||||
Current time is {{current_time}}. You only schedule time in current calendar year, you cannot schedule time that'''s in the past.
|
|
||||||
|
|
||||||
## Task Steps
|
|
||||||
1. I am here to learn more about your issue and help schedule an appointment with our support team.
|
|
||||||
2. If {{email}} is not unknown then Use name {{name}} and email {{email}} for creating booking else Ask for user name and email and Confirm the name and email with user by reading it back to user.
|
|
||||||
3. Ask user for \"When would you want to meet with one of our representive\".
|
|
||||||
4. Call function check_availability to check for availability in the user provided time range.
|
|
||||||
- if availability exists, inform user about the availability range (do not repeat the detailed available slot) and ask user to choose from it. Make sure user chose a slot within detailed available slot.
|
|
||||||
- if availability does not exist, ask user to select another time range for the appointment, repeat this step 3.
|
|
||||||
5. Confirm the date and time selected by user: \"Just to confirm, you want to book the appointment at ...\".
|
|
||||||
6. Once confirmed, call function book_appointment to book the appointment.
|
|
||||||
- if booking returned booking detail, it means booking is successful, proceed to step 7.
|
|
||||||
- if booking returned error message, let user know why the booking was not successful, and maybe start over with step 3.
|
|
||||||
7. Inform the user booking is successful, and ask if user have any questions. Answer them if there are any.
|
|
||||||
8. After all questions answered, call function end_call to hang up.`;
|
|
||||||
|
|
||||||
const DEFAULT_BEGIN_MESSAGE = "Hi. How are you doing?";
|
|
||||||
|
|
||||||
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings;
|
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings;
|
||||||
const EventSetupTab = dynamic(() =>
|
const EventSetupTab = dynamic(() =>
|
||||||
import("@components/eventtype/EventSetupTab").then((mod) => mod.EventSetupTab)
|
import("@components/eventtype/EventSetupTab").then((mod) => mod.EventSetupTab)
|
||||||
@@ -144,6 +114,8 @@ const querySchema = z.object({
|
|||||||
|
|
||||||
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
|
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
|
||||||
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
|
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
|
||||||
|
export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"];
|
||||||
|
export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"];
|
||||||
|
|
||||||
export const locationsResolver = (t: TFunction) => {
|
export const locationsResolver = (t: TFunction) => {
|
||||||
return z
|
return z
|
||||||
@@ -226,6 +198,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
|||||||
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
|
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
|
||||||
const [pendingRoute, setPendingRoute] = useState("");
|
const [pendingRoute, setPendingRoute] = useState("");
|
||||||
const leaveWithoutAssigningHosts = useRef(false);
|
const leaveWithoutAssigningHosts = useRef(false);
|
||||||
|
const isTeamEventTypeDeleted = useRef(false);
|
||||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -367,6 +340,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
|||||||
guestCompany: eventType.aiPhoneCallConfig?.guestCompany,
|
guestCompany: eventType.aiPhoneCallConfig?.guestCompany,
|
||||||
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
|
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
|
||||||
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
|
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
|
||||||
|
templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE",
|
||||||
|
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [eventType, periodDates, metadata]);
|
}, [eventType, periodDates, metadata]);
|
||||||
@@ -379,13 +354,15 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
|||||||
// Make it optional because it's not submitted from all tabs of the page
|
// Make it optional because it's not submitted from all tabs of the page
|
||||||
eventName: z
|
eventName: z
|
||||||
.string()
|
.string()
|
||||||
.refine(
|
.superRefine((val, ctx) => {
|
||||||
(val) =>
|
const validationResult = validateCustomEventName(val, bookingFields);
|
||||||
validateCustomEventName(val, t("invalid_event_name_variables"), bookingFields) === true,
|
if (validationResult !== true) {
|
||||||
{
|
ctx.addIssue({
|
||||||
message: t("invalid_event_name_variables"),
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("invalid_event_name_variables", { item: validationResult }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||||
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||||
@@ -400,40 +377,42 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
|||||||
formState: { isDirty: isFormDirty, dirtyFields },
|
formState: { isDirty: isFormDirty, dirtyFields },
|
||||||
} = formMethods;
|
} = formMethods;
|
||||||
|
|
||||||
// useEffect(() => {
|
const onDelete = () => {
|
||||||
// const handleRouteChange = (url: string) => {
|
isTeamEventTypeDeleted.current = true;
|
||||||
// const paths = url.split("/");
|
};
|
||||||
//
|
|
||||||
// // Check if event is managed event type - skip if there is assigned users
|
useEffect(() => {
|
||||||
// const assignedUsers = eventType.children;
|
const handleRouteChange = (url: string) => {
|
||||||
// const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
const paths = url.split("/");
|
||||||
// if (eventType.assignAllTeamMembers) {
|
|
||||||
// return;
|
// If the event-type is deleted, we can't show the empty assignment warning
|
||||||
// } else if (isManagedEventType && assignedUsers.length > 0) {
|
if (isTeamEventTypeDeleted.current) return;
|
||||||
// return;
|
|
||||||
// }
|
if (
|
||||||
//
|
!!team &&
|
||||||
// const hosts = eventType.hosts;
|
!leaveWithoutAssigningHosts.current &&
|
||||||
// if (
|
(url === "/event-types" || paths[1] !== "event-types") &&
|
||||||
// !leaveWithoutAssigningHosts.current &&
|
checkForEmptyAssignment({
|
||||||
// !!team &&
|
assignedUsers: eventType.children,
|
||||||
// (hosts.length === 0 || assignedUsers.length === 0) &&
|
hosts: eventType.hosts,
|
||||||
// (url === "/event-types" || paths[1] !== "event-types")
|
assignAllTeamMembers: eventType.assignAllTeamMembers,
|
||||||
// ) {
|
isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED,
|
||||||
// setIsOpenAssignmentWarnDialog(true);
|
})
|
||||||
// setPendingRoute(url);
|
) {
|
||||||
// router.events.emit(
|
setIsOpenAssignmentWarnDialog(true);
|
||||||
// "routeChangeError",
|
setPendingRoute(url);
|
||||||
// new Error(`Aborted route change to ${url} because none was assigned to team event`)
|
router.events.emit(
|
||||||
// );
|
"routeChangeError",
|
||||||
// throw "Aborted";
|
new Error(`Aborted route change to ${url} because none was assigned to team event`)
|
||||||
// }
|
);
|
||||||
// };
|
throw "Aborted";
|
||||||
// router.events.on("routeChangeStart", handleRouteChange);
|
}
|
||||||
// return () => {
|
};
|
||||||
// router.events.off("routeChangeStart", handleRouteChange);
|
router.events.on("routeChangeStart", handleRouteChange);
|
||||||
// };
|
return () => {
|
||||||
// }, [router]);
|
router.events.off("routeChangeStart", handleRouteChange);
|
||||||
|
};
|
||||||
|
}, [router, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]);
|
||||||
|
|
||||||
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
||||||
const availability = formMethods.watch("availability");
|
const availability = formMethods.watch("availability");
|
||||||
@@ -717,7 +696,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
|||||||
disableBorder={true}
|
disableBorder={true}
|
||||||
currentUserMembership={currentUserMembership}
|
currentUserMembership={currentUserMembership}
|
||||||
bookerUrl={eventType.bookerUrl}
|
bookerUrl={eventType.bookerUrl}
|
||||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
isUserOrganizationAdmin={props.isUserOrganizationAdmin}
|
||||||
|
onDelete={onDelete}>
|
||||||
<Form
|
<Form
|
||||||
form={formMethods}
|
form={formMethods}
|
||||||
id="event-type-form"
|
id="event-type-form"
|
||||||
@@ -837,13 +817,13 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/*<AssignmentWarningDialog*/}
|
<AssignmentWarningDialog
|
||||||
{/* isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}*/}
|
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
|
||||||
{/* setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}*/}
|
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
|
||||||
{/* pendingRoute={pendingRoute}*/}
|
pendingRoute={pendingRoute}
|
||||||
{/* leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}*/}
|
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
|
||||||
{/* id={eventType.id}*/}
|
id={eventType.id}
|
||||||
{/*/>*/}
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -387,6 +387,13 @@ const nextConfig = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
|
// This header can be set safely as it ensures the browser will load the resources even when COEP is set.
|
||||||
|
// But this header must be set only on those resources that are safe to be loaded in a cross-origin context e.g. all embeddable pages's resources
|
||||||
|
const CORP_CROSS_ORIGIN_HEADER = {
|
||||||
|
key: "Cross-Origin-Resource-Policy",
|
||||||
|
value: "cross-origin",
|
||||||
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/auth/:path*",
|
source: "/auth/:path*",
|
||||||
@@ -419,6 +426,26 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/embed/embed.js",
|
||||||
|
headers: [CORP_CROSS_ORIGIN_HEADER],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/:path*/embed",
|
||||||
|
// COEP require-corp header is set conditionally when flag.coep is set to true
|
||||||
|
headers: [CORP_CROSS_ORIGIN_HEADER],
|
||||||
|
},
|
||||||
|
// These resources loads through embed as well, so they need to have CORP_CROSS_ORIGIN_HEADER
|
||||||
|
...[
|
||||||
|
{
|
||||||
|
source: "/api/avatar/:path*",
|
||||||
|
headers: [CORP_CROSS_ORIGIN_HEADER],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/avatar.svg",
|
||||||
|
headers: [CORP_CROSS_ORIGIN_HEADER],
|
||||||
|
},
|
||||||
|
],
|
||||||
...(isOrganizationsEnabled
|
...(isOrganizationsEnabled
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "4.3.3",
|
"version": "4.3.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { NextApiResponse } from "next";
|
|||||||
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
|
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
|
||||||
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
|
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
|
||||||
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
|
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
|
||||||
|
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||||
import getIP from "@calcom/lib/getIP";
|
import getIP from "@calcom/lib/getIP";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
@@ -10,17 +11,20 @@ import logger from "@calcom/lib/logger";
|
|||||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
|
async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
|
||||||
const { token } = signupSchema
|
const { token } = signupSchema
|
||||||
.pick({
|
.pick({
|
||||||
token: true,
|
token: true,
|
||||||
})
|
})
|
||||||
.parse(req.body);
|
.parse(req.body);
|
||||||
|
|
||||||
// Stil allow signups if there is a team invite
|
// Still allow signups if there is a team invite
|
||||||
if (token) return;
|
if (token) return;
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
|
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||||
|
const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
|
||||||
|
|
||||||
|
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: "Signup is disabled",
|
message: "Signup is disabled",
|
||||||
@@ -47,7 +51,7 @@ export default async function handler(req: RequestWithUsernameStatus, res: NextA
|
|||||||
});
|
});
|
||||||
|
|
||||||
ensureReqIsPost(req);
|
ensureReqIsPost(req);
|
||||||
ensureSignupIsEnabled(req);
|
await ensureSignupIsEnabled(req);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Im not sure its worth merging these two handlers. They are different enough to be separate.
|
* Im not sure its worth merging these two handlers. They are different enough to be separate.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("moveUserToMatchingOrg", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not proceed if no matching organization is found", async () => {
|
it("should not proceed if no matching organization is found", async () => {
|
||||||
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeNoMatch();
|
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeNoMatch();
|
||||||
|
|
||||||
await moveUserToMatchingOrg({ email });
|
await moveUserToMatchingOrg({ email });
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ describe("moveUserToMatchingOrg", () => {
|
|||||||
requestedSlug: "requested-test-org",
|
requestedSlug: "requested-test-org",
|
||||||
};
|
};
|
||||||
|
|
||||||
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
|
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization(
|
||||||
org,
|
org,
|
||||||
{ email }
|
{ email }
|
||||||
);
|
);
|
||||||
@@ -65,7 +65,7 @@ describe("moveUserToMatchingOrg", () => {
|
|||||||
requestedSlug: "requested-test-org",
|
requestedSlug: "requested-test-org",
|
||||||
};
|
};
|
||||||
|
|
||||||
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
|
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization(
|
||||||
org,
|
org,
|
||||||
{ email }
|
{ email }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
|
|||||||
|
|
||||||
// TODO: To be unit tested
|
// TODO: To be unit tested
|
||||||
export async function moveUserToMatchingOrg({ email }: { email: string }) {
|
export async function moveUserToMatchingOrg({ email }: { email: string }) {
|
||||||
const org = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email });
|
const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email });
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { ZGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||||
|
import type { TGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
|
||||||
import { fetcher } from "@calcom/lib/retellAIFetcher";
|
import { fetcher } from "@calcom/lib/retellAIFetcher";
|
||||||
import { defaultHandler } from "@calcom/lib/server";
|
import { defaultHandler } from "@calcom/lib/server";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { getRetellLLMSchema } from "@calcom/prisma/zod-utils";
|
|
||||||
import type { TGetRetellLLMSchema } from "@calcom/prisma/zod-utils";
|
|
||||||
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||||
|
|
||||||
dayjs.extend(advancedFormat);
|
dayjs.extend(advancedFormat);
|
||||||
@@ -53,7 +53,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const body = response.data;
|
const body = response.data;
|
||||||
|
|
||||||
const retellLLM = await fetcher(`/get-retell-llm/${body.llm_id}`).then(getRetellLLMSchema.parse);
|
const retellLLM = await fetcher(`/get-retell-llm/${body.llm_id}`).then(ZGetRetellLLMSchema.parse);
|
||||||
|
|
||||||
const { eventTypeId, timezone } = getEventTypeIdFromRetellLLM(retellLLM);
|
const { eventTypeId, timezone } = getEventTypeIdFromRetellLLM(retellLLM);
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { URLSearchParams } from "url";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
|
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||||
|
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||||
import { BookingStatus } from "@calcom/prisma/client";
|
import { BookingStatus } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -20,11 +22,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
const { uid: bookingUid, seatReferenceUid } = z
|
const { uid: bookingUid, seatReferenceUid } = z
|
||||||
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
|
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
|
||||||
.parse(context.query);
|
.parse(context.query);
|
||||||
|
const coepFlag = context.query["flag.coep"];
|
||||||
const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat(
|
const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat(
|
||||||
prisma,
|
prisma,
|
||||||
bookingUid
|
bookingUid
|
||||||
);
|
);
|
||||||
|
|
||||||
const booking = await prisma.booking.findUnique({
|
const booking = await prisma.booking.findUnique({
|
||||||
where: {
|
where: {
|
||||||
uid,
|
uid,
|
||||||
@@ -41,6 +44,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
slug: true,
|
slug: true,
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
|
parentId: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -125,20 +129,27 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
||||||
|
|
||||||
const eventPage = `${
|
const enrichedBookingUser = booking.user
|
||||||
eventType.team
|
? await UserRepository.enrichUserWithItsProfile({ user: booking.user })
|
||||||
? `team/${eventType.team.slug}`
|
: null;
|
||||||
: dynamicEventSlugRef
|
|
||||||
? booking.dynamicGroupSlugRef
|
|
||||||
: booking.user?.username || "rick" /* This shouldn't happen */
|
|
||||||
}/${eventType?.slug}`;
|
|
||||||
const destinationUrl = new URLSearchParams();
|
|
||||||
|
|
||||||
destinationUrl.set("rescheduleUid", seatReferenceUid || bookingUid);
|
const eventUrl = await buildEventUrlFromBooking({
|
||||||
|
eventType,
|
||||||
|
dynamicGroupSlugRef: booking.dynamicGroupSlugRef ?? null,
|
||||||
|
profileEnrichedBookingUser: enrichedBookingUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const destinationUrlSearchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
destinationUrlSearchParams.set("rescheduleUid", seatReferenceUid || bookingUid);
|
||||||
|
|
||||||
|
// TODO: I think we should just forward all the query params here including coep flag
|
||||||
|
if (coepFlag) {
|
||||||
|
destinationUrlSearchParams.set("flag.coep", coepFlag as string);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: `/${eventPage}?${destinationUrl.toString()}${
|
destination: `${eventUrl}?${destinationUrlSearchParams.toString()}${
|
||||||
eventType.seatsPerTimeSlot ? "&bookingUid=null" : ""
|
eventType.seatsPerTimeSlot ? "&bookingUid=null" : ""
|
||||||
}`,
|
}`,
|
||||||
permanent: false,
|
permanent: false,
|
||||||
|
|||||||
@@ -641,8 +641,7 @@ const ProfileForm = ({
|
|||||||
|
|
||||||
{showRemoveAvatarButton && (
|
{showRemoveAvatarButton && (
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="destructive"
|
||||||
className="hover:bg-red-600"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(null);
|
onChange(null);
|
||||||
}}>
|
}}>
|
||||||
@@ -703,6 +702,7 @@ const ProfileForm = ({
|
|||||||
disableLists
|
disableLists
|
||||||
firstRender={firstRender}
|
firstRender={firstRender}
|
||||||
setFirstRender={setFirstRender}
|
setFirstRender={setFirstRender}
|
||||||
|
height="80px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* // For Non-Cal indentities, we merge the values from DB and the user logging in,
|
{/* // For Non-Cal indentities, we merge the values from DB and the user logging in,
|
||||||
@@ -716,8 +716,8 @@ const ProfileForm = ({
|
|||||||
<span className="text-default ml-2 text-sm">{user.identityProviderEmail}</span>
|
<span className="text-default ml-2 text-sm">{user.identityProviderEmail}</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-1 justify-end">
|
<div className="flex flex-1 justify-end">
|
||||||
<Button color="destructive" onClick={onDisconnect} size="sm">
|
<Button color="destructive" onClick={onDisconnect}>
|
||||||
Disconnect
|
{t("disconnect")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -323,7 +323,12 @@ export default function Signup({
|
|||||||
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-12 2xl:px-28">
|
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-12 2xl:px-28">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{errors.apiError && (
|
{errors.apiError && (
|
||||||
<Alert severity="error" message={errors.apiError?.message} data-testid="signup-error-message" />
|
<Alert
|
||||||
|
className="mb-3"
|
||||||
|
severity="error"
|
||||||
|
message={errors.apiError?.message}
|
||||||
|
data-testid="signup-error-message"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="font-cal text-[28px] leading-none ">
|
<h1 className="font-cal text-[28px] leading-none ">
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
|
|||||||
await gotoBookingPage(page);
|
await gotoBookingPage(page);
|
||||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
|
||||||
await page.locator(`[data-fob-field-name="location"] input`).fill("9199999999");
|
await page.locator(`[data-fob-field-name="location"] input`).fill("19199999999");
|
||||||
await bookTimeSlot(page);
|
await bookTimeSlot(page);
|
||||||
|
|
||||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
@@ -204,7 +204,7 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
|
|||||||
await page.locator(`text="Organizer Phone Number"`).click();
|
await page.locator(`text="Organizer Phone Number"`).click();
|
||||||
const locationInputName = "locations[0].hostPhoneNumber";
|
const locationInputName = "locations[0].hostPhoneNumber";
|
||||||
await page.locator(`input[name="${locationInputName}"]`).waitFor();
|
await page.locator(`input[name="${locationInputName}"]`).waitFor();
|
||||||
await page.locator(`input[name="${locationInputName}"]`).fill("9199999999");
|
await page.locator(`input[name="${locationInputName}"]`).fill("19199999999");
|
||||||
|
|
||||||
await saveEventType(page);
|
await saveEventType(page);
|
||||||
await gotoBookingPage(page);
|
await gotoBookingPage(page);
|
||||||
|
|||||||
@@ -709,6 +709,13 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||||||
},
|
},
|
||||||
delete: async () => await prisma.user.delete({ where: { id: store.user.id } }),
|
delete: async () => await prisma.user.delete({ where: { id: store.user.id } }),
|
||||||
confirmPendingPayment: async () => confirmPendingPayment(store.page),
|
confirmPendingPayment: async () => confirmPendingPayment(store.page),
|
||||||
|
getFirstProfile: async () => {
|
||||||
|
return prisma.profile.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -738,6 +745,7 @@ type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
|||||||
schedule?: Schedule;
|
schedule?: Schedule;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
emailDomain?: string;
|
emailDomain?: string;
|
||||||
|
profileUsername?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// creates the actual user in the db.
|
// creates the actual user in the db.
|
||||||
@@ -749,11 +757,12 @@ const createUser = (
|
|||||||
})
|
})
|
||||||
| null
|
| null
|
||||||
): PrismaType.UserUncheckedCreateInput => {
|
): PrismaType.UserUncheckedCreateInput => {
|
||||||
|
const suffixToMakeUsernameUnique = `-${workerInfo.workerIndex}-${Date.now()}`;
|
||||||
// build a unique name for our user
|
// build a unique name for our user
|
||||||
const uname =
|
const uname =
|
||||||
opts?.useExactUsername && opts?.username
|
opts?.useExactUsername && opts?.username
|
||||||
? opts.username
|
? opts.username
|
||||||
: `${opts?.username || "user"}-${workerInfo.workerIndex}-${Date.now()}`;
|
: `${opts?.username || "user"}${suffixToMakeUsernameUnique}`;
|
||||||
|
|
||||||
const emailDomain = opts?.emailDomain || "example.com";
|
const emailDomain = opts?.emailDomain || "example.com";
|
||||||
return {
|
return {
|
||||||
@@ -772,7 +781,11 @@ const createUser = (
|
|||||||
role: opts?.role ?? "USER",
|
role: opts?.role ?? "USER",
|
||||||
twoFactorEnabled: opts?.twoFactorEnabled ?? false,
|
twoFactorEnabled: opts?.twoFactorEnabled ?? false,
|
||||||
disableImpersonation: opts?.disableImpersonation ?? false,
|
disableImpersonation: opts?.disableImpersonation ?? false,
|
||||||
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
|
...getOrganizationRelatedProps({
|
||||||
|
organizationId: opts?.organizationId,
|
||||||
|
role: opts?.roleInOrganization,
|
||||||
|
profileUsername: opts?.profileUsername,
|
||||||
|
}),
|
||||||
schedules:
|
schedules:
|
||||||
opts?.completedOnboarding ?? true
|
opts?.completedOnboarding ?? true
|
||||||
? {
|
? {
|
||||||
@@ -792,9 +805,11 @@ const createUser = (
|
|||||||
function getOrganizationRelatedProps({
|
function getOrganizationRelatedProps({
|
||||||
organizationId,
|
organizationId,
|
||||||
role,
|
role,
|
||||||
|
profileUsername,
|
||||||
}: {
|
}: {
|
||||||
organizationId: number | null | undefined;
|
organizationId: number | null | undefined;
|
||||||
role: MembershipRole | undefined;
|
role: MembershipRole | undefined;
|
||||||
|
profileUsername?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -807,7 +822,7 @@ const createUser = (
|
|||||||
profiles: {
|
profiles: {
|
||||||
create: {
|
create: {
|
||||||
uid: ProfileRepository.generateProfileUid(),
|
uid: ProfileRepository.generateProfileUid(),
|
||||||
username: uname,
|
username: profileUsername ? `${profileUsername}${suffixToMakeUsernameUnique}` : uname,
|
||||||
organization: {
|
organization: {
|
||||||
connect: {
|
connect: {
|
||||||
id: organizationId,
|
id: organizationId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Frame, Page } from "@playwright/test";
|
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
import type { Frame, Page, Request as PlaywrightRequest } from "@playwright/test";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import type { IncomingMessage, ServerResponse } from "http";
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
@@ -352,20 +352,51 @@ export async function fillStripeTestCheckout(page: Page) {
|
|||||||
await page.click(".SubmitButton--complete-Shimmer");
|
await page.click(".SubmitButton--complete-Shimmer");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function goToUrlWithErrorHandling({ page, url }: { page: Page; url: string }) {
|
||||||
|
return new Promise<{ success: boolean; url: string }>(async (resolve) => {
|
||||||
|
const onRequestFailed = (request: PlaywrightRequest) => {
|
||||||
|
const failedToLoadUrl = request.url();
|
||||||
|
console.log("goToUrlWithErrorHandling: Failed to load URL:", failedToLoadUrl);
|
||||||
|
resolve({ success: false, url: failedToLoadUrl });
|
||||||
|
};
|
||||||
|
page.on("requestfailed", onRequestFailed);
|
||||||
|
try {
|
||||||
|
await page.goto(url);
|
||||||
|
} catch (e) {}
|
||||||
|
page.off("requestfailed", onRequestFailed);
|
||||||
|
resolve({ success: true, url: page.url() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Within this function's callback if a non-org domain is opened, it is considered an org domain identfied from `orgSlug`
|
||||||
|
*/
|
||||||
export async function doOnOrgDomain(
|
export async function doOnOrgDomain(
|
||||||
{ orgSlug, page }: { orgSlug: string | null; page: Page },
|
{ orgSlug, page }: { orgSlug: string | null; page: Page },
|
||||||
callback: ({ page }: { page: Page }) => Promise<void>
|
callback: ({
|
||||||
|
page,
|
||||||
|
}: {
|
||||||
|
page: Page;
|
||||||
|
goToUrlWithErrorHandling: (url: string) => ReturnType<typeof goToUrlWithErrorHandling>;
|
||||||
|
}) => Promise<any>
|
||||||
) {
|
) {
|
||||||
if (!orgSlug) {
|
if (!orgSlug) {
|
||||||
throw new Error("orgSlug is not available");
|
throw new Error("orgSlug is not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
page.setExtraHTTPHeaders({
|
page.setExtraHTTPHeaders({
|
||||||
"x-cal-force-slug": orgSlug,
|
"x-cal-force-slug": orgSlug,
|
||||||
});
|
});
|
||||||
await callback({ page });
|
const callbackResult = await callback({
|
||||||
|
page,
|
||||||
|
goToUrlWithErrorHandling: (url: string) => {
|
||||||
|
return goToUrlWithErrorHandling({ page, url });
|
||||||
|
},
|
||||||
|
});
|
||||||
await page.setExtraHTTPHeaders({
|
await page.setExtraHTTPHeaders({
|
||||||
"x-cal-force-slug": "",
|
"x-cal-force-slug": "",
|
||||||
});
|
});
|
||||||
|
return callbackResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory.
|
// When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory.
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { expect } from "@playwright/test";
|
|||||||
import type { createUsersFixture } from "playwright/fixtures/users";
|
import type { createUsersFixture } from "playwright/fixtures/users";
|
||||||
import { uuid } from "short-uuid";
|
import { uuid } from "short-uuid";
|
||||||
|
|
||||||
|
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
import { createNewEventType } from "./lib/testUtils";
|
||||||
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||||
|
|
||||||
function getLabelLocator(field: Locator) {
|
function getLabelLocator(field: Locator) {
|
||||||
@@ -716,3 +718,164 @@ async function expectWebhookToBeCalled(
|
|||||||
|
|
||||||
expect(body).toMatchObject(expectedBody);
|
expect(body).toMatchObject(expectedBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test.describe("Text area min and max characters text", () => {
|
||||||
|
test("Create a new event", async ({ page, users }) => {
|
||||||
|
const eventTitle = `Min Max Characters Test`;
|
||||||
|
const fieldType = fieldTypesConfigMap["textarea"];
|
||||||
|
const MAX_LENGTH = fieldType?.supportsLengthCheck?.maxLength;
|
||||||
|
|
||||||
|
// We create a new event type
|
||||||
|
const user = await users.create();
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
await page.goto("/event-types");
|
||||||
|
|
||||||
|
// We wait until loading is finished
|
||||||
|
await page.waitForSelector('[data-testid="event-types"]');
|
||||||
|
await createNewEventType(page, { eventTitle });
|
||||||
|
await page.waitForSelector(`text=${eventTitle}`);
|
||||||
|
|
||||||
|
// Click on the event type
|
||||||
|
await page.click(`text=${eventTitle}`);
|
||||||
|
await page.waitForSelector(`text=${eventTitle}`);
|
||||||
|
|
||||||
|
// goto the advanced tab
|
||||||
|
await page.click('[href$="tabName=advanced"]');
|
||||||
|
const insertQuestion = async (questionName: string) => {
|
||||||
|
await page.click('[data-testid="add-field"]');
|
||||||
|
const locatorForSelect = page.locator("[id=test-field-type]").nth(0);
|
||||||
|
await locatorForSelect.click();
|
||||||
|
await locatorForSelect.locator(`text="Long Text"`).click();
|
||||||
|
|
||||||
|
await page.fill('[name="name"]', questionName);
|
||||||
|
await page.fill('[name="label"]', questionName);
|
||||||
|
await page.fill('[name="placeholder"]', questionName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveQuestion = async () => {
|
||||||
|
await page.click('[data-testid="field-add-save"]');
|
||||||
|
};
|
||||||
|
const cancelQuestion = async () => {
|
||||||
|
await page.click('[data-testid="dialog-rejection"]');
|
||||||
|
};
|
||||||
|
const minLengthSelector = '[name="minLength"]';
|
||||||
|
const maxLengthSelector = '[name="maxLength"]';
|
||||||
|
const minInput = await page.locator(minLengthSelector);
|
||||||
|
const maxInput = await page.locator(maxLengthSelector);
|
||||||
|
let questionName;
|
||||||
|
await test.step("Add a new field with no min and max characters", async () => {
|
||||||
|
// Add a new field with no min and max characters
|
||||||
|
questionName = "Text area without min & max";
|
||||||
|
await insertQuestion(questionName);
|
||||||
|
await saveQuestion();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Add a new field with min characters only", async () => {
|
||||||
|
// Add a new field with min characters only
|
||||||
|
questionName = "Text area with min = 5";
|
||||||
|
await insertQuestion(questionName);
|
||||||
|
await page.fill(minLengthSelector, "5");
|
||||||
|
await saveQuestion();
|
||||||
|
});
|
||||||
|
await test.step("Add a new field with max characters only", async () => {
|
||||||
|
// Add a new field with max characters only
|
||||||
|
questionName = "Text area with max = 10";
|
||||||
|
await insertQuestion(questionName);
|
||||||
|
await page.fill(maxLengthSelector, "10");
|
||||||
|
await saveQuestion();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Add a new field with min and max characters where min < max", async () => {
|
||||||
|
// Add a new field with min and max characters where min < max
|
||||||
|
questionName = "Text area with min = 5 & max = 10";
|
||||||
|
await insertQuestion(questionName);
|
||||||
|
await page.fill(minLengthSelector, "5");
|
||||||
|
await page.fill(maxLengthSelector, "10");
|
||||||
|
await saveQuestion();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Add a new field with min and max characters where min > max", async () => {
|
||||||
|
// Add a new field with min and max characters where min > max
|
||||||
|
questionName = "Text area with min = 10 & max = 5";
|
||||||
|
await insertQuestion(questionName);
|
||||||
|
await page.fill(minLengthSelector, "10");
|
||||||
|
await page.fill(maxLengthSelector, "5");
|
||||||
|
await saveQuestion();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Try with different inputs and check for validation", async () => {
|
||||||
|
// Expect the native <input> element to show an error message
|
||||||
|
|
||||||
|
let validationMessage = await minInput?.evaluate((input: any) => input?.validationMessage as string);
|
||||||
|
expect(validationMessage?.toString()).toBe("Value must be less than or equal to 5.");
|
||||||
|
|
||||||
|
await page.fill(minLengthSelector, "0");
|
||||||
|
await page.fill(maxLengthSelector, "100000");
|
||||||
|
await saveQuestion();
|
||||||
|
// Expect the native <input> element to show an error message
|
||||||
|
|
||||||
|
validationMessage = await maxInput?.evaluate((input: any) => input?.validationMessage as string);
|
||||||
|
|
||||||
|
expect(validationMessage?.toString()).toBe(
|
||||||
|
`Value must be less than or equal to ${MAX_LENGTH || 1000}.`
|
||||||
|
);
|
||||||
|
await cancelQuestion();
|
||||||
|
// Save the event type
|
||||||
|
await page.locator("[data-testid=update-eventtype]").click();
|
||||||
|
|
||||||
|
// Get the url of data-testid="preview-button"
|
||||||
|
const previewButton = await page.locator('[data-testid="preview-button"]');
|
||||||
|
const previewButtonHref = (await previewButton.getAttribute("href")) ?? "";
|
||||||
|
await page.goto(previewButtonHref);
|
||||||
|
|
||||||
|
// wait until the button with data-testid="time" is visible
|
||||||
|
await page.locator('[data-testid="time"]').isVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("incrementMonth").click();
|
||||||
|
|
||||||
|
// Get first button with data-testid="time"
|
||||||
|
const timeButton = page.locator('[data-testid="time"]').first();
|
||||||
|
await timeButton.click();
|
||||||
|
|
||||||
|
await page.locator('text="Additional notes"');
|
||||||
|
// Form fields:
|
||||||
|
const textAreaWithoutMinMax = page.locator('[name="Text-area-without-min---max"]');
|
||||||
|
const textAreaWithMin5 = page.locator('[name="Text-area-with-min---5"]');
|
||||||
|
const textAreaWithMax10 = page.locator('[name="Text-area-with-max---10"]');
|
||||||
|
const textAreaWithMin5Max10 = page.locator('[name="Text-area-with-min---5---max---10"]');
|
||||||
|
|
||||||
|
// Get button with data-testid="confirm-book-button"
|
||||||
|
const submitForm = async () => await page.locator('[data-testid="confirm-book-button"]').click();
|
||||||
|
await page.fill('[name="name"]', "Booker");
|
||||||
|
await page.fill('[name="email"]', "booker@example.com");
|
||||||
|
await textAreaWithoutMinMax.fill("1234567890");
|
||||||
|
await textAreaWithMin5.fill("1234");
|
||||||
|
await textAreaWithMax10.fill("12345678901");
|
||||||
|
await textAreaWithMin5Max10.fill("1234");
|
||||||
|
await submitForm();
|
||||||
|
// Expect the text: Min. 5 characters to be visible
|
||||||
|
expect(await page.locator(`text=Min. 5 characters required`).isVisible()).toBe(true);
|
||||||
|
|
||||||
|
// update the text area with min 5 to have 5 characters
|
||||||
|
await textAreaWithMin5.fill("12345");
|
||||||
|
await submitForm();
|
||||||
|
|
||||||
|
// Expect the text: Min. 5 characters to still be visible because textAreaWithMin5Max10 has less than 5 characters
|
||||||
|
expect(await page.locator(`text=Min. 5 characters required`).isVisible()).toBe(true);
|
||||||
|
|
||||||
|
// Expect the text: Max. 10 characters to be visible and have value 1234567890
|
||||||
|
expect(await textAreaWithMax10.inputValue()).toBe("1234567890");
|
||||||
|
await submitForm();
|
||||||
|
|
||||||
|
// update the text area with min 5 and max 10 to have 6 characters
|
||||||
|
await textAreaWithMin5Max10.fill("123456");
|
||||||
|
await submitForm();
|
||||||
|
|
||||||
|
// Expect the text: Max. 5 characters to be hidden
|
||||||
|
expect(await page.locator(`text=Min. 5 characters required`).isVisible()).toBe(false);
|
||||||
|
|
||||||
|
await expect(page.locator('text="This meeting is scheduled"')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user