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 |
@@ -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.
|
||||
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"
|
||||
# ***********************************************************************************************************
|
||||
|
||||
|
||||
@@ -1,41 +1,56 @@
|
||||
---
|
||||
name: Feature request
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
about: Suggest a feature or idea
|
||||
title: ""
|
||||
labels: ["✨ feature", "🚨 needs approval"],
|
||||
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
|
||||
labels: ["✨ feature", "🚨 needs approval"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
- 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
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
name: Analyze Build
|
||||
needs: [build]
|
||||
uses: ./.github/workflows/nextjs-bundle-analysis.yml
|
||||
secrets: inherit
|
||||
|
||||
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]
|
||||
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:
|
||||
name: bundle
|
||||
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
|
||||
* format: date-time
|
||||
* description: 'End time of the Event'
|
||||
* rescheduleUid:
|
||||
* type: string
|
||||
* format: UID
|
||||
* description: 'Uid of the booking to reschedule'
|
||||
* responses:
|
||||
* type: object
|
||||
* required:
|
||||
|
||||
@@ -69,6 +69,9 @@ import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validati
|
||||
* ownerId:
|
||||
* type: number
|
||||
* description: ID of the team owner - only admins can set this.
|
||||
* parentId:
|
||||
* type: number
|
||||
* description: ID of the parent organization.
|
||||
* tags:
|
||||
* - teams
|
||||
* responses:
|
||||
@@ -99,7 +102,7 @@ async function postHandler(req: NextApiRequest) {
|
||||
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) {
|
||||
const parentTeam = await prisma.team.findFirst({
|
||||
where: { id: data.parentId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
||||
@@ -107,7 +110,13 @@ async function postHandler(req: NextApiRequest) {
|
||||
if (!parentTeam)
|
||||
throw new HttpError({
|
||||
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,
|
||||
};
|
||||
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
if (!IS_TEAM_BILLING_ENABLED || data.parentId) {
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
...cloneData,
|
||||
@@ -135,7 +144,7 @@ async function postHandler(req: NextApiRequest) {
|
||||
return {
|
||||
team: schemaTeamReadPublic.parse(team),
|
||||
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 { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Booking } from "@calcom/prisma";
|
||||
|
||||
import handler from "../../../pages/api/bookings/_post";
|
||||
|
||||
@@ -157,6 +158,7 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
||||
|
||||
describe("Success", () => {
|
||||
describe("Regular event-type", () => {
|
||||
let createdBooking: Booking;
|
||||
test("Creates one single booking", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
@@ -181,9 +183,43 @@ describe.skipIf(true)("POST /api/bookings", () => {
|
||||
|
||||
await handler(req, res);
|
||||
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
|
||||
|
||||
createdBooking = JSON.parse(res._getData());
|
||||
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", () => {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@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-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -14,8 +14,8 @@ import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
@@ -29,15 +29,8 @@ import { AppController } from "./app.controller";
|
||||
}),
|
||||
|
||||
RedisModule,
|
||||
BullModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
useFactory: async (redisService: RedisService) => ({
|
||||
redis: {
|
||||
host: redisService.redis.options.host,
|
||||
port: redisService.redis.options.port,
|
||||
},
|
||||
}),
|
||||
inject: [RedisService],
|
||||
BullModule.forRoot({
|
||||
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
|
||||
}),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
@@ -45,14 +38,9 @@ import { AppController } from "./app.controller";
|
||||
useFactory: (redisService: RedisService) => ({
|
||||
throttlers: [
|
||||
{
|
||||
name: "short",
|
||||
ttl: seconds(10),
|
||||
limit: 3,
|
||||
},
|
||||
{
|
||||
name: "medium",
|
||||
ttl: seconds(30),
|
||||
limit: 10,
|
||||
name: "long",
|
||||
ttl: seconds(60), // Time to live for the long period in seconds
|
||||
limit: 120, // Maximum number of requests within the long ttl
|
||||
},
|
||||
],
|
||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||
@@ -69,6 +57,10 @@ import { AppController } from "./app.controller";
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
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";
|
||||
|
||||
describe("Bookings Endpoints", () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
getBookingInfo,
|
||||
handleCancelBooking,
|
||||
getBookingForReschedule,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
} from "@calcom/platform-libraries-0.0.22";
|
||||
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
|
||||
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 {
|
||||
@IsBoolean()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
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()
|
||||
export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
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 { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import {
|
||||
EventTypesPublic,
|
||||
eventTypeBookingFields,
|
||||
eventTypeLocations,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
} from "@calcom/platform-libraries-0.0.22";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
describe("Event types Endpoints", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import {
|
||||
updateEventType,
|
||||
EventTypesPublic,
|
||||
getEventTypesPublic,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
} from "@calcom/platform-libraries-0.0.22";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getEventTypeById,
|
||||
transformApiEventTypeBookingFields,
|
||||
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 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 { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.21";
|
||||
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.21";
|
||||
import { dynamicEvent } 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.22";
|
||||
import { dynamicEvent } from "@calcom/platform-libraries-0.0.22";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
transformApiEventTypeBookingFields,
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
parseRecurringEvent,
|
||||
TransformedLocationsSchema,
|
||||
BookingFieldsSchema,
|
||||
} from "@calcom/platform-libraries-0.0.21";
|
||||
} from "@calcom/platform-libraries-0.0.22";
|
||||
|
||||
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
|
||||
type DatabaseEventType = EventType & EventTypeRelations;
|
||||
|
||||
@@ -22,4 +22,7 @@ export class MeOutput {
|
||||
|
||||
@IsString()
|
||||
timeZone!: string;
|
||||
|
||||
@IsInt()
|
||||
organizationId!: number | null;
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
transformApiScheduleOverrides,
|
||||
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 { 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 { 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";
|
||||
|
||||
@Catch(TRPCError)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
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 { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { ExecutionContext, HttpException } from "@nestjs/common";
|
||||
@@ -17,11 +18,14 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.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 { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
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";
|
||||
|
||||
describe("ApiAuthStrategy", () => {
|
||||
@@ -33,11 +37,17 @@ describe("ApiAuthStrategy", () => {
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
const validApiKeyEmail = "api-key-user-email@example.com";
|
||||
const validAccessTokenEmail = "access-token-user-email@example.com";
|
||||
const validOAuthEmail = "oauth-user@example.com";
|
||||
|
||||
let validApiKeyUser: User;
|
||||
let validAccessTokenUser: User;
|
||||
let validOAuthUser: User;
|
||||
let module: TestingModule;
|
||||
|
||||
beforeAll(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [
|
||||
@@ -46,6 +56,7 @@ describe("ApiAuthStrategy", () => {
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
ProfilesModule,
|
||||
],
|
||||
providers: [
|
||||
MockedRedisService,
|
||||
@@ -71,6 +82,7 @@ describe("ApiAuthStrategy", () => {
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
||||
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
validApiKeyUser = await userRepositoryFixture.create({
|
||||
email: validApiKeyEmail,
|
||||
@@ -78,6 +90,18 @@ describe("ApiAuthStrategy", () => {
|
||||
validAccessTokenUser = await userRepositoryFixture.create({
|
||||
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 = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
@@ -94,22 +118,9 @@ describe("ApiAuthStrategy", () => {
|
||||
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);
|
||||
await expect(user).toBeDefined();
|
||||
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.id).toEqual(validAccessTokenUser.id);
|
||||
});
|
||||
|
||||
it("should return user associated with valid api key", async () => {
|
||||
@@ -117,22 +128,15 @@ describe("ApiAuthStrategy", () => {
|
||||
now.setDate(now.getDate() + 1);
|
||||
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);
|
||||
await expect(user).toBeDefined();
|
||||
if (user) expect(user.id).toEqual(validApiKeyUser.id);
|
||||
expect(user).toBeDefined();
|
||||
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 () => {
|
||||
@@ -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 = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
@@ -179,8 +231,11 @@ describe("ApiAuthStrategy", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(validApiKeyEmail);
|
||||
await userRepositoryFixture.deleteByEmail(validAccessTokenEmail);
|
||||
await oAuthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await userRepositoryFixture.delete(validApiKeyUser.id);
|
||||
await userRepositoryFixture.delete(validAccessTokenUser.id);
|
||||
await userRepositoryFixture.delete(validOAuthUser.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
module.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
|
||||
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 { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
||||
@@ -10,7 +12,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
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()
|
||||
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 tokensRepository: TokensRepository,
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly apiKeyRepository: ApiKeyRepository
|
||||
private readonly apiKeyRepository: ApiKeyRepository,
|
||||
private readonly oauthRepository: OAuthClientRepository,
|
||||
private readonly profilesRepository: ProfilesRepository
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async authenticate(request: Request) {
|
||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
||||
if (!authString) {
|
||||
return this.error(new UnauthorizedException("No Authorization header provided"));
|
||||
try {
|
||||
const { params } = request;
|
||||
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 {
|
||||
const user = isApiKey(authString, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? await this.apiKeyStrategy(authString)
|
||||
|
||||
@@ -4,3 +4,5 @@ export enum PlatformPlan {
|
||||
SCALE = "SCALE",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
|
||||
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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()
|
||||
export class EmailService {
|
||||
|
||||
+269
-3
@@ -14,10 +14,11 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
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 { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.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 { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
@@ -78,9 +79,13 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let postResponseData: CreateUserResponse;
|
||||
|
||||
const platformAdminEmail = "platform-sensei@mail.com";
|
||||
let platformAdmin: User;
|
||||
|
||||
const userEmail = "oauth-client-user@gmail.com";
|
||||
const userTimeZone = "Europe/Rome";
|
||||
|
||||
@@ -98,9 +103,20 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -188,10 +204,16 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
// note(Lauris): to determine count see default event types created in EventTypesService.createUserDefaultEventTypes
|
||||
expect(defaultEventTypes?.length).toEqual(4);
|
||||
expect(
|
||||
defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.thirtyMinutes.length)
|
||||
defaultEventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
|
||||
).toBeTruthy();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -277,6 +299,250 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
} catch (e) {
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
+3
-2
@@ -1,11 +1,11 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
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 { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { 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 { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
@@ -37,7 +37,7 @@ import { Pagination } from "@calcom/platform-types";
|
||||
path: "/v2/oauth-clients/:clientId/users",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(OAuthClientCredentialsGuard)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@DocsTags("Managed users")
|
||||
export class OAuthClientUsersController {
|
||||
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}`
|
||||
);
|
||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||
console.log("asap createUser client", JSON.stringify(client, null, 2));
|
||||
|
||||
const isPlatformManaged = true;
|
||||
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 * as request from "supertest";
|
||||
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 { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
@@ -58,6 +59,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
let usersRepositoryFixtures: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
let organization: Team;
|
||||
@@ -83,11 +85,19 @@ describe("OAuthFlow Endpoints", () => {
|
||||
oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
user = await usersRepositoryFixtures.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
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 { 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 { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
|
||||
import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input";
|
||||
@@ -137,7 +137,7 @@ export class OAuthFlowController {
|
||||
|
||||
@Post("/refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(OAuthClientCredentialsGuard)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async refreshAccessToken(
|
||||
@Param("clientId") clientId: 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 { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.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 { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
@@ -36,12 +36,12 @@ import { Global, Module } from "@nestjs/common";
|
||||
],
|
||||
providers: [
|
||||
OAuthClientRepository,
|
||||
OAuthClientCredentialsGuard,
|
||||
TokensRepository,
|
||||
OAuthFlowService,
|
||||
OAuthClientUsersService,
|
||||
OrganizationsTeamsService,
|
||||
],
|
||||
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
||||
exports: [OAuthClientRepository, OAuthClientCredentialsGuard],
|
||||
exports: [OAuthClientRepository],
|
||||
})
|
||||
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 { 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 { CreateManagedUserInput } from "@/modules/users/inputs/create-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 { 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()
|
||||
export class OAuthClientUsersService {
|
||||
@@ -15,7 +16,8 @@ export class OAuthClientUsersService {
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
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(
|
||||
@@ -78,6 +80,8 @@ export class OAuthClientUsersService {
|
||||
user.defaultScheduleId = defaultSchedule.id;
|
||||
}
|
||||
|
||||
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
|
||||
|
||||
return {
|
||||
user,
|
||||
tokens: {
|
||||
|
||||
+28
-8
@@ -1,7 +1,9 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
@@ -32,6 +34,7 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
GetTeamEventTypesQuery_2024_06_14,
|
||||
SkipTakePagination,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
@@ -40,13 +43,13 @@ import {
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@DocsTags("Organizations Event Types")
|
||||
export class OrganizationsEventTypesController {
|
||||
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||
@Post("/teams/:teamId/event-types")
|
||||
async createTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@@ -68,7 +71,8 @@ export class OrganizationsEventTypesController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||
@Get("/teams/:teamId/event-types/:eventTypeId")
|
||||
async getTeamEventType(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@@ -86,10 +90,22 @@ export class OrganizationsEventTypesController {
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@UseGuards(IsOrgGuard, IsTeamInOrg)
|
||||
@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);
|
||||
|
||||
return {
|
||||
@@ -99,6 +115,8 @@ export class OrganizationsEventTypesController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||
@Get("/teams/event-types")
|
||||
async getTeamsEventTypes(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -114,7 +132,8 @@ export class OrganizationsEventTypesController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||
@Patch("/teams/:teamId/event-types/:eventTypeId")
|
||||
async updateTeamEventType(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@@ -136,7 +155,8 @@ export class OrganizationsEventTypesController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||
@Delete("/teams/:teamId/event-types/:eventTypeId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteTeamEventType(
|
||||
|
||||
+6
-8
@@ -248,17 +248,15 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
options: ["javascript", "python", "cobol"],
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
schedulingType: "collective",
|
||||
hosts: [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
mandatory: true,
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
userId: teammate2.id,
|
||||
mandatory: false,
|
||||
priority: "low",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -274,6 +272,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const data = responseBody.data;
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
expect(data.schedulingType).toEqual("COLLECTIVE");
|
||||
evaluateHost(body.hosts[0], data.hosts[0]);
|
||||
evaluateHost(body.hosts[1], data.hosts[1]);
|
||||
|
||||
@@ -389,7 +388,6 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
console.log("asap responseBody.data", JSON.stringify(responseBody.data, null, 2));
|
||||
expect(data.length).toEqual(2);
|
||||
|
||||
const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
|
||||
@@ -431,8 +429,6 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
|
||||
{
|
||||
userId: teammate1.id,
|
||||
mandatory: true,
|
||||
priority: "medium",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -603,7 +599,9 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
await userRepositoryFixture.deleteByEmail(teammate2.email);
|
||||
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(falseTestTeam.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await organizationsRepositoryFixture.delete(falseTestOrg.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
+8
-1
@@ -1,7 +1,9 @@
|
||||
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 { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.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",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||
@DocsTags("Organizations Memberships")
|
||||
export class OrganizationsMembershipsController {
|
||||
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getAllMemberships(
|
||||
@@ -66,6 +69,7 @@ export class OrganizationsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async createMembership(
|
||||
@@ -80,6 +84,7 @@ export class OrganizationsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Get("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -91,6 +96,7 @@ export class OrganizationsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Delete("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -107,6 +113,7 @@ export class OrganizationsMembershipsController {
|
||||
|
||||
@UseGuards(IsMembershipInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateMembership(
|
||||
|
||||
+9
-1
@@ -1,7 +1,9 @@
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
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 { 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 { RolesGuard } from "@/modules/auth/guards/roles/roles.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",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||
@DocsTags("Organizations Schedules")
|
||||
export class OrganizationsSchedulesController {
|
||||
constructor(
|
||||
@@ -47,6 +49,7 @@ export class OrganizationsSchedulesController {
|
||||
) {}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/schedules")
|
||||
async getOrganizationSchedules(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -63,6 +66,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Post("/users/:userId/schedules")
|
||||
async createUserSchedule(
|
||||
@@ -78,6 +82,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Get("/users/:userId/schedules/:scheduleId")
|
||||
async getUserSchedule(
|
||||
@@ -93,6 +98,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Get("/users/:userId/schedules")
|
||||
async getUserSchedules(
|
||||
@@ -107,6 +113,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Patch("/users/:userId/schedules/:scheduleId")
|
||||
async updateUserSchedule(
|
||||
@@ -123,6 +130,7 @@ export class OrganizationsSchedulesController {
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@Delete("/users/:userId/schedules/:scheduleId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
+8
-1
@@ -1,6 +1,8 @@
|
||||
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 { 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 { RolesGuard } from "@/modules/auth/guards/roles/roles.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",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard)
|
||||
@DocsTags("Organizations Teams")
|
||||
export class OrganizationsTeamsMembershipsController {
|
||||
constructor(
|
||||
@@ -50,6 +52,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
@ApiOperation({ summary: "Get all the memberships of a team of an organization." })
|
||||
@UseGuards()
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getAllOrgTeamMemberships(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -75,6 +78,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
@ApiOperation({ summary: "Get the membership of an organization's team by ID" })
|
||||
@UseGuards()
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getOrgTeamMembership(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -93,6 +97,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
|
||||
@@ -113,6 +118,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update the membership of an organization's team by ID" })
|
||||
@@ -135,6 +141,7 @@ export class OrganizationsTeamsMembershipsController {
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@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 * as request from "supertest";
|
||||
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 { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
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 { Team } from "@calcom/prisma/client";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Team Endpoints", () => {
|
||||
describe("User Authentication - User is Org Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
let team: Team;
|
||||
let team2: Team;
|
||||
let teamCreatedViaApi: Team;
|
||||
let teamCreatedViaApi2: Team;
|
||||
|
||||
const userEmail = "org-admin-teams-controller-e2e@api.com";
|
||||
let user: User;
|
||||
@@ -45,7 +46,6 @@ describe("Organizations Team Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
org = await teamsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
@@ -85,7 +85,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(teamsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
@@ -147,6 +147,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
teamCreatedViaApi.id
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
|
||||
await teamsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -214,7 +239,6 @@ describe("Organizations Team Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: TeamRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
@@ -234,7 +258,6 @@ describe("Organizations Team Endpoints", () => {
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
@@ -243,7 +266,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
org = await teamsRepositoryFixture.create({
|
||||
name: "Test Organization",
|
||||
isOrganization: true,
|
||||
});
|
||||
@@ -274,7 +297,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(teamsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
@@ -317,7 +340,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await teamsRepositoryFixture.delete(org.id);
|
||||
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 { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
|
||||
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
|
||||
import {
|
||||
OrgMeTeamOutputDto,
|
||||
OrgMeTeamsOutputResponseDto,
|
||||
OrgTeamOutputDto,
|
||||
OrgTeamOutputResponseDto,
|
||||
OrgTeamsOutputResponseDto,
|
||||
} from "@/modules/organizations/outputs/organization-team.output";
|
||||
@@ -27,11 +29,13 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
Headers,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
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 { Team } from "@calcom/prisma/client";
|
||||
|
||||
@@ -39,7 +43,7 @@ import { Team } from "@calcom/prisma/client";
|
||||
path: "/v2/organizations/:orgId/teams",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||
@DocsTags("Organizations Teams")
|
||||
export class OrganizationsTeamsController {
|
||||
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
||||
@@ -47,6 +51,7 @@ export class OrganizationsTeamsController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get all the teams of an organization." })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getAllTeams(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
@@ -62,6 +67,7 @@ export class OrganizationsTeamsController {
|
||||
@Get("/me")
|
||||
@ApiOperation({ summary: "Get the organization's teams user is a member of" })
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getMyTeams(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() queryParams: SkipTakePagination,
|
||||
@@ -88,6 +94,7 @@ export class OrganizationsTeamsController {
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/:teamId")
|
||||
@ApiOperation({ summary: "Get a team of the organization by ID." })
|
||||
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
|
||||
@@ -99,6 +106,7 @@ export class OrganizationsTeamsController {
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Delete("/:teamId")
|
||||
@ApiOperation({ summary: "Delete a team of the organization by ID." })
|
||||
async deleteTeam(
|
||||
@@ -114,12 +122,13 @@ export class OrganizationsTeamsController {
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Patch("/:teamId")
|
||||
@ApiOperation({ summary: "Update a team of the organization by ID." })
|
||||
async updateTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: CreateOrgTeamDto
|
||||
@Body() body: UpdateOrgTeamDto
|
||||
): Promise<OrgTeamOutputResponseDto> {
|
||||
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
|
||||
return {
|
||||
@@ -130,13 +139,18 @@ export class OrganizationsTeamsController {
|
||||
|
||||
@Post()
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiOperation({ summary: "Create a team for an organization." })
|
||||
async createTeam(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Body() body: CreateOrgTeamDto,
|
||||
@GetUser() user: UserWithProfile
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Headers(X_CAL_CLIENT_ID) oAuthClientId?: string
|
||||
): 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 {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
|
||||
|
||||
+7
-1
@@ -1,8 +1,10 @@
|
||||
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 { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
||||
@@ -39,7 +41,7 @@ import { Team } from "@calcom/prisma/client";
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard)
|
||||
@UseGuards(IsOrgGuard)
|
||||
@DocsTags("Organizations Users")
|
||||
export class OrganizationsUsersController {
|
||||
@@ -47,6 +49,7 @@ export class OrganizationsUsersController {
|
||||
|
||||
@Get()
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getOrganizationsUsers(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: GetOrganizationsUsersInput
|
||||
@@ -66,6 +69,7 @@ export class OrganizationsUsersController {
|
||||
|
||||
@Post()
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async createOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetOrg() org: Team,
|
||||
@@ -85,6 +89,7 @@ export class OrganizationsUsersController {
|
||||
|
||||
@Patch("/:userId")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
async updateOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -101,6 +106,7 @@ export class OrganizationsUsersController {
|
||||
|
||||
@Delete("/:userId")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
async deleteOrganizationUser(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
|
||||
+164
-3
@@ -8,6 +8,7 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
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 { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.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 { 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("Member role", () => {
|
||||
@@ -29,7 +30,6 @@ describe("Organizations Users Endpoints", () => {
|
||||
const userEmail = "member1@org.com";
|
||||
let user: User;
|
||||
let org: Team;
|
||||
let membership: Membership;
|
||||
|
||||
beforeAll(async () => {
|
||||
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();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -344,4 +344,165 @@ describe("Organizations Users Endpoints", () => {
|
||||
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()
|
||||
@IsString()
|
||||
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 { Expose, Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Length,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
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";
|
||||
}
|
||||
import { OrgTeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
|
||||
@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) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
|
||||
+29
-1
@@ -1,4 +1,5 @@
|
||||
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 { 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) {
|
||||
return this.dbRead.prisma.team.delete({
|
||||
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({
|
||||
data: { ...data },
|
||||
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 { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
HostPriority,
|
||||
SchedulingType,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -30,7 +31,9 @@ export class InputOrganizationsEventTypesService {
|
||||
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
hosts: assignAllTeamMembers ? await this.getAllTeamMembers(teamId) : this.transformInputHosts(hosts),
|
||||
hosts: assignAllTeamMembers
|
||||
? await this.getAllTeamMembers(teamId, inputEventType.schedulingType)
|
||||
: this.transformInputHosts(hosts, inputEventType.schedulingType),
|
||||
assignAllTeamMembers,
|
||||
metadata,
|
||||
};
|
||||
@@ -46,6 +49,11 @@ export class InputOrganizationsEventTypesService {
|
||||
const { hosts, assignAllTeamMembers, ...rest } = inputEventType;
|
||||
|
||||
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 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
|
||||
hosts: !children
|
||||
? assignAllTeamMembers
|
||||
? await this.getAllTeamMembers(teamId)
|
||||
: this.transformInputHosts(hosts)
|
||||
? await this.getAllTeamMembers(teamId, dbEventType.schedulingType)
|
||||
: this.transformInputHosts(hosts, dbEventType.schedulingType)
|
||||
: undefined,
|
||||
assignAllTeamMembers,
|
||||
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 isFixed = schedulingType === "COLLECTIVE" ? true : false;
|
||||
|
||||
return membersIds.map((id) => ({
|
||||
userId: id,
|
||||
isFixed: false,
|
||||
isFixed,
|
||||
priority: 2,
|
||||
}));
|
||||
}
|
||||
|
||||
transformInputHosts(inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) {
|
||||
transformInputHosts(
|
||||
inputHosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined,
|
||||
schedulingType: SchedulingType | null
|
||||
) {
|
||||
if (!inputHosts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const defaultMandatory = false;
|
||||
const defaultPriority = "medium";
|
||||
const defaultIsFixed = false;
|
||||
|
||||
return inputHosts.map((host) => ({
|
||||
userId: host.userId,
|
||||
isFixed: host.mandatory || defaultMandatory,
|
||||
priority: getPriorityValue(host.priority || defaultPriority),
|
||||
isFixed: schedulingType === "COLLECTIVE" ? true : host.mandatory || defaultIsFixed,
|
||||
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 { 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 {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
@@ -98,6 +98,19 @@ export class OrganizationsEventTypesService {
|
||||
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) {
|
||||
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 { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
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 DatabaseEventType = EventType & EventTypeRelations;
|
||||
@@ -47,7 +49,8 @@ type Input = Pick<
|
||||
export class OutputOrganizationsEventTypesService {
|
||||
constructor(
|
||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
|
||||
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository
|
||||
private readonly organizationEventTypesRepository: OrganizationsEventTypesRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async getResponseTeamEventType(databaseEventType: Input) {
|
||||
@@ -60,7 +63,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
const hosts =
|
||||
databaseEventType.schedulingType === "MANAGED"
|
||||
? await this.getManagedEventTypeHosts(databaseEventType.id)
|
||||
: this.transformHosts(databaseEventType.hosts);
|
||||
: await this.transformHosts(databaseEventType.hosts, databaseEventType.schedulingType);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
@@ -74,25 +77,41 @@ export class OutputOrganizationsEventTypesService {
|
||||
|
||||
async getManagedEventTypeHosts(eventTypeId: number) {
|
||||
const children = await this.organizationEventTypesRepository.getEventTypeChildren(eventTypeId);
|
||||
const hostsIds: number[] = [];
|
||||
const transformedHosts: TeamEventTypeResponseHost[] = [];
|
||||
for (const child of children) {
|
||||
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[]) {
|
||||
if (!hosts) return [];
|
||||
async transformHosts(
|
||||
databaseHosts: Host[],
|
||||
schedulingType: SchedulingType | null
|
||||
): Promise<TeamEventTypeResponseHost[]> {
|
||||
if (!schedulingType) return [];
|
||||
|
||||
return hosts.map((host) => {
|
||||
return {
|
||||
userId: host.userId,
|
||||
mandatory: host.isFixed,
|
||||
priority: getPriorityLabel(host.priority || 2),
|
||||
};
|
||||
});
|
||||
const transformedHosts: TeamEventTypeResponseHost[] = [];
|
||||
const databaseUsers = await this.usersRepository.findByIds(databaseHosts.map((host) => host.userId));
|
||||
|
||||
for (const databaseHost of databaseHosts) {
|
||||
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 { 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 { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries-0.0.22";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsService {
|
||||
constructor(
|
||||
@@ -31,16 +34,58 @@ export class OrganizationsTeamsService {
|
||||
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);
|
||||
return team;
|
||||
}
|
||||
|
||||
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") {
|
||||
await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", true);
|
||||
await this.membershipsRepository.createMembership(team.id, user.id, "OWNER", !!autoAcceptCreator);
|
||||
}
|
||||
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 { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
|
||||
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 { Injectable, ConflictException } from "@nestjs/common";
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsUsersService {
|
||||
constructor(
|
||||
private readonly organizationsUsersRepository: OrganizationsUsersRepository,
|
||||
private readonly organizationsTeamsService: OrganizationsTeamsService,
|
||||
private readonly emailService: EmailService
|
||||
) {}
|
||||
|
||||
@@ -77,6 +79,8 @@ export class OrganizationsUsersService {
|
||||
updateUserBody
|
||||
);
|
||||
|
||||
await this.organizationsTeamsService.addUserToTeamEvents(user.id, org.id);
|
||||
|
||||
// Need to send email to new user to create password
|
||||
await this.emailService.sendSignupToOrganizationEmail({
|
||||
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 { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.21";
|
||||
import type { AvailableSlotsType } 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.22";
|
||||
import { RemoveSelectedSlotInput, ReserveSlotInput } 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 { 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";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
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";
|
||||
|
||||
@Controller({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.21";
|
||||
import type { CityTimezones } 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.22";
|
||||
|
||||
@Injectable()
|
||||
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) {
|
||||
return this.dbRead.prisma.user.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -962,6 +962,14 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-cal-client-id",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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}": {
|
||||
"get": {
|
||||
"operationId": "OrganizationsTeamsController_getTeam",
|
||||
@@ -1075,7 +1134,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"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": {
|
||||
"201": {
|
||||
"description": "",
|
||||
@@ -1262,6 +1331,16 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
@@ -2301,6 +2380,16 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
@@ -5100,6 +5189,29 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5120,6 +5232,69 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5180,6 +5355,10 @@
|
||||
"weekStart": {
|
||||
"type": "string",
|
||||
"default": "Sunday"
|
||||
},
|
||||
"autoAcceptCreator": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -5329,6 +5508,57 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5377,6 +5607,52 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6012,9 +6288,12 @@
|
||||
"freq"
|
||||
]
|
||||
},
|
||||
"Host": {
|
||||
"TeamEventTypeResponseHost": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"userId": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -6028,6 +6307,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"userId"
|
||||
]
|
||||
},
|
||||
@@ -6145,7 +6425,7 @@
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Host"
|
||||
"$ref": "#/components/schemas/TeamEventTypeResponseHost"
|
||||
}
|
||||
},
|
||||
"assignAllTeamMembers": {
|
||||
@@ -6467,6 +6747,26 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6764,6 +7064,66 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7033,6 +7393,10 @@
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string"
|
||||
},
|
||||
"organizationId": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7042,7 +7406,8 @@
|
||||
"timeFormat",
|
||||
"defaultScheduleId",
|
||||
"weekStart",
|
||||
"timeZone"
|
||||
"timeZone",
|
||||
"organizationId"
|
||||
]
|
||||
},
|
||||
"GetMeOutput": {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { EventType } from "@prisma/client";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class EventTypesRepositoryFixture {
|
||||
private primaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
@@ -26,6 +28,9 @@ export class EventTypesRepositoryFixture {
|
||||
where: {
|
||||
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"]) {
|
||||
return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } });
|
||||
}
|
||||
|
||||
@@ -23,4 +23,13 @@ export class TeamRepositoryFixture {
|
||||
async delete(teamId: Team["id"]) {
|
||||
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> => {
|
||||
const { embed, layout } = context.query;
|
||||
|
||||
const isCOEPEnabled = context.query["flag.coep"] === "true";
|
||||
if (isCOEPEnabled) {
|
||||
context.res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
}
|
||||
try {
|
||||
const props = await getData(context);
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
|
||||
<input
|
||||
id={`${id}`}
|
||||
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"
|
||||
/>
|
||||
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
|
||||
|
||||
@@ -27,10 +27,6 @@ import type { ActionType } from "@calcom/ui";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -43,7 +39,6 @@ import {
|
||||
MeetingTimeInTimezones,
|
||||
showToast,
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
|
||||
@@ -75,19 +70,12 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
onSuccess: () => {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
@@ -122,7 +110,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
reason: "",
|
||||
};
|
||||
/**
|
||||
* 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",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
onClick: () => {
|
||||
setRejectionDialogIsOpen(true);
|
||||
},
|
||||
href: `/booking/${booking.uid}?reject=true`,
|
||||
icon: "ban",
|
||||
disabled: mutation.isPending,
|
||||
},
|
||||
@@ -347,38 +333,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||
<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">
|
||||
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col transition sm:flex-row">
|
||||
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
|
||||
<Link href={bookingLink}>
|
||||
<div className="cursor-pointer py-4">
|
||||
@@ -721,7 +676,7 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
<button
|
||||
data-testid="guest"
|
||||
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 ? (
|
||||
<s>
|
||||
{name || email} <Icon name="eye-off" className="inline h-4" />
|
||||
@@ -839,7 +794,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
<button
|
||||
data-testid="more-guests"
|
||||
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 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -900,7 +855,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
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 })}
|
||||
</button>
|
||||
</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 type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useFormContext, Controller } from "react-hook-form";
|
||||
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 type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { AIPhoneSettingSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Button,
|
||||
@@ -17,9 +21,9 @@ import {
|
||||
SettingsToggle,
|
||||
Divider,
|
||||
TextField,
|
||||
TextAreaField,
|
||||
PhoneInput,
|
||||
showToast,
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
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 { t } = useLocale();
|
||||
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const [calApiKey, setCalApiKey] = useState("");
|
||||
|
||||
const createCallMutation = trpc.viewer.organizations.createPhoneCall.useMutation({
|
||||
@@ -98,43 +156,25 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
}
|
||||
},
|
||||
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 () => {
|
||||
try {
|
||||
const values = formMethods.getValues("aiPhoneCallConfig");
|
||||
const { templateType } = values;
|
||||
|
||||
const data = await AIPhoneSettingSchema.parseAsync({
|
||||
generalPrompt: values.generalPrompt,
|
||||
beginMessage: values.beginMessage,
|
||||
enabled: values.enabled,
|
||||
guestName: values.guestName,
|
||||
guestEmail: values.guestEmail.trim().length ? values.guestEmail : undefined,
|
||||
guestCompany: values.guestCompany.trim().length ? values.guestCompany : undefined,
|
||||
const schema = getTemplateFieldsSchema({ templateType });
|
||||
|
||||
const data = schema.parse({
|
||||
...values,
|
||||
guestEmail: values.guestEmail && values.guestEmail.trim().length ? values.guestEmail : undefined,
|
||||
guestCompany:
|
||||
values.guestCompany && values.guestCompany.trim().length ? values.guestCompany : undefined,
|
||||
guestName: values.guestName && values.guestName.trim().length ? values.guestName : undefined,
|
||||
eventTypeId: eventType.id,
|
||||
numberToCall: values.numberToCall,
|
||||
yourPhoneNumber: values.yourPhoneNumber,
|
||||
calApiKey,
|
||||
});
|
||||
|
||||
@@ -144,6 +184,15 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const fieldName = err.issues?.[0]?.path?.[0];
|
||||
const message = err.issues?.[0]?.message;
|
||||
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 {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
}
|
||||
@@ -157,68 +206,89 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
<Label>{t("your_phone_number")}</Label>
|
||||
<Controller
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("your_phone_number")}
|
||||
id="aiPhoneCallConfig.yourPhoneNumber"
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("your_phone_number")}
|
||||
id="aiPhoneCallConfig.yourPhoneNumber"
|
||||
name="aiPhoneCallConfig.yourPhoneNumber"
|
||||
value={value}
|
||||
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>
|
||||
<Controller
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
render={({ field: { onChange, value, name }, fieldState: { error } }) => {
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("phone_number")}
|
||||
id="aiPhoneCallConfig.numberToCall"
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<PhoneInput
|
||||
required
|
||||
placeholder={t("phone_number")}
|
||||
id="aiPhoneCallConfig.numberToCall"
|
||||
name="aiPhoneCallConfig.numberToCall"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
}}
|
||||
/>
|
||||
{error?.message && <ErrorMessage message={error.message} fieldName={name} />}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TemplateFields />
|
||||
|
||||
<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
|
||||
type="text"
|
||||
hint="For eg:- cal_live_0123.."
|
||||
@@ -232,26 +302,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
/>
|
||||
|
||||
<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
|
||||
disabled={createCallMutation.isPending}
|
||||
@@ -268,34 +318,6 @@ const AISettings = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
and learn how to build workflows.
|
||||
</small> */}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,45 +53,63 @@ const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
||||
type="text"
|
||||
placeholder={placeHolder_}
|
||||
{...register("customEventName", {
|
||||
validate: (value) =>
|
||||
validateCustomEventName(value, t("invalid_event_name_variables"), event.bookingFields),
|
||||
validate: (value) => {
|
||||
const validationResult = validateCustomEventName(value, event.bookingFields);
|
||||
return typeof validationResult === "string"
|
||||
? t("invalid_event_name_variables", { item: validationResult })
|
||||
: validationResult;
|
||||
},
|
||||
})}
|
||||
className="mb-0"
|
||||
/>
|
||||
<div className="pt-6 text-sm">
|
||||
<div className="bg-subtle mb-6 rounded-md p-2">
|
||||
<h1 className="text-emphasis mb-2 ml-1 font-medium">{t("available_variables")}</h1>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<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="scroll-bar h-[216px] overflow-y-auto">
|
||||
<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-emphasis">{t("scheduler_first_name")}</p>
|
||||
<p className="text-subtle ml-1 mr-5 w-32">{`{Event type title}`}</p>
|
||||
<p className="text-emphasis">{t("event_name_info")}</p>
|
||||
</div>
|
||||
)}
|
||||
{isNameFieldSplit && (
|
||||
<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-emphasis">{t("scheduler_last_name")}</p>
|
||||
<p className="text-subtle ml-1 mr-5 w-32">{`{Organiser}`}</p>
|
||||
<p className="text-emphasis">{t("your_full_name")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-1 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-28">{`{Location}`}</p>
|
||||
<p className="text-emphasis">{t("location_info")}</p>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="text-subtle ml-1 mr-5 w-32">{`{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-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>
|
||||
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||
|
||||
@@ -53,6 +53,7 @@ type Props = {
|
||||
isUserOrganizationAdmin: boolean;
|
||||
bookerUrl: string;
|
||||
activeWebhooksNumber: number;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
type getNavigationProps = {
|
||||
@@ -117,7 +118,12 @@ function DeleteDialog({
|
||||
eventTypeId,
|
||||
open,
|
||||
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 { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -125,6 +131,7 @@ function DeleteDialog({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.eventTypes.invalidate();
|
||||
showToast(t("event_type_deleted_successfully"), "success");
|
||||
onDelete();
|
||||
router.push("/event-types");
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
@@ -181,6 +188,7 @@ function EventTypeSingleLayout({
|
||||
isUserOrganizationAdmin,
|
||||
bookerUrl,
|
||||
activeWebhooksNumber,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const { t } = useLocale();
|
||||
const eventTypesLockedByOrg = eventType.team?.parent?.organizationSettings?.lockEventTypeCreationForUsers;
|
||||
@@ -510,6 +518,7 @@ function EventTypeSingleLayout({
|
||||
isManagedEvent={isManagedEvent}
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
<EventTypeEmbedDialog />
|
||||
|
||||
@@ -46,6 +46,7 @@ export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAu
|
||||
isLoading={isDeleting}
|
||||
onDelete={handleDelete}
|
||||
areEmailsEnabled={client.areEmailsEnabled}
|
||||
organizationId={client.organizationId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -22,6 +22,7 @@ type OAuthClientCardProps = {
|
||||
secret: string;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
organizationId: number;
|
||||
};
|
||||
|
||||
export const OAuthClientCard = ({
|
||||
@@ -38,6 +39,7 @@ export const OAuthClientCard = ({
|
||||
onDelete,
|
||||
isLoading,
|
||||
areEmailsEnabled,
|
||||
organizationId,
|
||||
}: OAuthClientCardProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -113,6 +115,21 @@ export const OAuthClientCard = ({
|
||||
/>
|
||||
</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">
|
||||
<span className="font-semibold">Permissions: </span>
|
||||
{permissions ? <div className="flex">{clientPermissions}</div> : <> Disabled</>}
|
||||
@@ -148,11 +165,7 @@ export const OAuthClientCard = ({
|
||||
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-red-500 text-white hover:bg-red-600"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => onDelete(id)}>
|
||||
<Button color="destructive" loading={isLoading} disabled={isLoading} onClick={() => onDelete(id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@ export const OAuthClientForm = ({
|
||||
<input
|
||||
{...register(`${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"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
@@ -89,7 +89,7 @@ export const OAuthClientForm = ({
|
||||
<input
|
||||
{...register(`${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"
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
@@ -224,7 +224,7 @@ export const OAuthClientForm = ({
|
||||
<input
|
||||
{...register("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"
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
|
||||
@@ -48,7 +48,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
{...rest}
|
||||
ref={ref}
|
||||
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>
|
||||
<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: {
|
||||
...(data.metadata || {}),
|
||||
},
|
||||
createdByOAuthClient: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1591,6 +1592,7 @@ async function createTeamOutsideOrg(
|
||||
...(data.metadata || {}),
|
||||
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", () => {
|
||||
@@ -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 ", () => {
|
||||
@@ -254,5 +368,23 @@ describe("withEmbedSsr", () => {
|
||||
|
||||
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 embed = context.query.embed;
|
||||
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) {
|
||||
const destinationUrl = ssrResponse.redirect.destination;
|
||||
@@ -44,6 +48,7 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
||||
if (!("props" in ssrResponse)) {
|
||||
return ssrResponse;
|
||||
}
|
||||
|
||||
return {
|
||||
...ssrResponse,
|
||||
props: {
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
} from "@calcom/ui";
|
||||
import PageWrapper from "@calcom/web/components/PageWrapper";
|
||||
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 { timeZone } from "@calcom/web/lib/clock";
|
||||
|
||||
@@ -78,6 +79,7 @@ const querySchema = z.object({
|
||||
email: z.string().optional(),
|
||||
eventTypeSlug: z.string().optional(),
|
||||
cancel: stringToBoolean,
|
||||
reject: stringToBoolean,
|
||||
allRemainingBookings: stringToBoolean,
|
||||
changes: stringToBoolean,
|
||||
reschedule: stringToBoolean,
|
||||
@@ -114,6 +116,7 @@ export default function Success(props: PageProps) {
|
||||
allRemainingBookings,
|
||||
isSuccessBookingPage,
|
||||
cancel: isCancellationMode,
|
||||
reject: isRejectionMode,
|
||||
formerTime,
|
||||
email,
|
||||
seatReferenceUid,
|
||||
@@ -209,6 +212,16 @@ export default function Success(props: PageProps) {
|
||||
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;
|
||||
if (eventType.isDynamic && bookingInfo.responses?.title) {
|
||||
evtName = bookingInfo.responses.title as string;
|
||||
@@ -660,6 +673,7 @@ export default function Success(props: PageProps) {
|
||||
{!requiresLoginToUpdate &&
|
||||
(!needsConfirmation || !userIsOwner) &&
|
||||
isReschedulable &&
|
||||
!isRejectionMode &&
|
||||
(!isCancellationMode ? (
|
||||
<>
|
||||
<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 &&
|
||||
!needsConfirmation &&
|
||||
!isCancellationMode &&
|
||||
|
||||
@@ -15,6 +15,10 @@ import { z } from "zod";
|
||||
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||
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 { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
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 { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -33,47 +38,12 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import { Form, showToast } from "@calcom/ui";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
import { checkForEmptyAssignment } from "@lib/checkForEmptyAssignment";
|
||||
|
||||
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
|
||||
|
||||
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;
|
||||
const EventSetupTab = dynamic(() =>
|
||||
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 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) => {
|
||||
return z
|
||||
@@ -226,6 +198,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
||||
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
|
||||
const [pendingRoute, setPendingRoute] = useState("");
|
||||
const leaveWithoutAssigningHosts = useRef(false);
|
||||
const isTeamEventTypeDeleted = useRef(false);
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
@@ -367,6 +340,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
||||
guestCompany: eventType.aiPhoneCallConfig?.guestCompany,
|
||||
yourPhoneNumber: eventType.aiPhoneCallConfig?.yourPhoneNumber,
|
||||
numberToCall: eventType.aiPhoneCallConfig?.numberToCall,
|
||||
templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE",
|
||||
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
|
||||
},
|
||||
};
|
||||
}, [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
|
||||
eventName: z
|
||||
.string()
|
||||
.refine(
|
||||
(val) =>
|
||||
validateCustomEventName(val, t("invalid_event_name_variables"), bookingFields) === true,
|
||||
{
|
||||
message: t("invalid_event_name_variables"),
|
||||
.superRefine((val, ctx) => {
|
||||
const validationResult = validateCustomEventName(val, bookingFields);
|
||||
if (validationResult !== true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("invalid_event_name_variables", { item: validationResult }),
|
||||
});
|
||||
}
|
||||
)
|
||||
})
|
||||
.optional(),
|
||||
length: 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 },
|
||||
} = formMethods;
|
||||
|
||||
// useEffect(() => {
|
||||
// const handleRouteChange = (url: string) => {
|
||||
// const paths = url.split("/");
|
||||
//
|
||||
// // Check if event is managed event type - skip if there is assigned users
|
||||
// const assignedUsers = eventType.children;
|
||||
// const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
||||
// if (eventType.assignAllTeamMembers) {
|
||||
// return;
|
||||
// } else if (isManagedEventType && assignedUsers.length > 0) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// const hosts = eventType.hosts;
|
||||
// if (
|
||||
// !leaveWithoutAssigningHosts.current &&
|
||||
// !!team &&
|
||||
// (hosts.length === 0 || assignedUsers.length === 0) &&
|
||||
// (url === "/event-types" || paths[1] !== "event-types")
|
||||
// ) {
|
||||
// setIsOpenAssignmentWarnDialog(true);
|
||||
// setPendingRoute(url);
|
||||
// router.events.emit(
|
||||
// "routeChangeError",
|
||||
// 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]);
|
||||
const onDelete = () => {
|
||||
isTeamEventTypeDeleted.current = true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleRouteChange = (url: string) => {
|
||||
const paths = url.split("/");
|
||||
|
||||
// If the event-type is deleted, we can't show the empty assignment warning
|
||||
if (isTeamEventTypeDeleted.current) return;
|
||||
|
||||
if (
|
||||
!!team &&
|
||||
!leaveWithoutAssigningHosts.current &&
|
||||
(url === "/event-types" || paths[1] !== "event-types") &&
|
||||
checkForEmptyAssignment({
|
||||
assignedUsers: eventType.children,
|
||||
hosts: eventType.hosts,
|
||||
assignAllTeamMembers: eventType.assignAllTeamMembers,
|
||||
isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED,
|
||||
})
|
||||
) {
|
||||
setIsOpenAssignmentWarnDialog(true);
|
||||
setPendingRoute(url);
|
||||
router.events.emit(
|
||||
"routeChangeError",
|
||||
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, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]);
|
||||
|
||||
const appsMetadata = formMethods.getValues("metadata")?.apps;
|
||||
const availability = formMethods.watch("availability");
|
||||
@@ -717,7 +696,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
||||
disableBorder={true}
|
||||
currentUserMembership={currentUserMembership}
|
||||
bookerUrl={eventType.bookerUrl}
|
||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}>
|
||||
isUserOrganizationAdmin={props.isUserOrganizationAdmin}
|
||||
onDelete={onDelete}>
|
||||
<Form
|
||||
form={formMethods}
|
||||
id="event-type-form"
|
||||
@@ -837,13 +817,13 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/*<AssignmentWarningDialog*/}
|
||||
{/* isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}*/}
|
||||
{/* setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}*/}
|
||||
{/* pendingRoute={pendingRoute}*/}
|
||||
{/* leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}*/}
|
||||
{/* id={eventType.id}*/}
|
||||
{/*/>*/}
|
||||
<AssignmentWarningDialog
|
||||
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
|
||||
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
|
||||
pendingRoute={pendingRoute}
|
||||
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
|
||||
id={eventType.id}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -387,6 +387,13 @@ const nextConfig = {
|
||||
};
|
||||
},
|
||||
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 [
|
||||
{
|
||||
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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.3.3",
|
||||
"version": "4.3.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NextApiResponse } from "next";
|
||||
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
|
||||
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
|
||||
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 getIP from "@calcom/lib/getIP";
|
||||
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 { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
|
||||
async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
|
||||
const { token } = signupSchema
|
||||
.pick({
|
||||
token: true,
|
||||
})
|
||||
.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 (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({
|
||||
statusCode: 403,
|
||||
message: "Signup is disabled",
|
||||
@@ -47,7 +51,7 @@ export default async function handler(req: RequestWithUsernameStatus, res: NextA
|
||||
});
|
||||
|
||||
ensureReqIsPost(req);
|
||||
ensureSignupIsEnabled(req);
|
||||
await ensureSignupIsEnabled(req);
|
||||
|
||||
/**
|
||||
* 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 () => {
|
||||
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeNoMatch();
|
||||
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeNoMatch();
|
||||
|
||||
await moveUserToMatchingOrg({ email });
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("moveUserToMatchingOrg", () => {
|
||||
requestedSlug: "requested-test-org",
|
||||
};
|
||||
|
||||
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
|
||||
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization(
|
||||
org,
|
||||
{ email }
|
||||
);
|
||||
@@ -65,7 +65,7 @@ describe("moveUserToMatchingOrg", () => {
|
||||
requestedSlug: "requested-test-org",
|
||||
};
|
||||
|
||||
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
|
||||
organizationScenarios.OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail.fakeReturnOrganization(
|
||||
org,
|
||||
{ email }
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
|
||||
|
||||
// TODO: To be unit tested
|
||||
export async function moveUserToMatchingOrg({ email }: { email: string }) {
|
||||
const org = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email });
|
||||
const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email });
|
||||
|
||||
if (!org) {
|
||||
return;
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
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 { defaultHandler } from "@calcom/lib/server";
|
||||
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";
|
||||
|
||||
dayjs.extend(advancedFormat);
|
||||
@@ -53,7 +53,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import { URLSearchParams } from "url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/client";
|
||||
|
||||
@@ -20,11 +22,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { uid: bookingUid, seatReferenceUid } = z
|
||||
.object({ uid: z.string(), seatReferenceUid: z.string().optional() })
|
||||
.parse(context.query);
|
||||
|
||||
const coepFlag = context.query["flag.coep"];
|
||||
const { uid, seatReferenceUid: maybeSeatReferenceUid } = await maybeGetBookingUidFromSeat(
|
||||
prisma,
|
||||
bookingUid
|
||||
);
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
@@ -41,6 +44,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
slug: true,
|
||||
team: {
|
||||
select: {
|
||||
parentId: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
@@ -125,20 +129,27 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
||||
|
||||
const eventPage = `${
|
||||
eventType.team
|
||||
? `team/${eventType.team.slug}`
|
||||
: dynamicEventSlugRef
|
||||
? booking.dynamicGroupSlugRef
|
||||
: booking.user?.username || "rick" /* This shouldn't happen */
|
||||
}/${eventType?.slug}`;
|
||||
const destinationUrl = new URLSearchParams();
|
||||
const enrichedBookingUser = booking.user
|
||||
? await UserRepository.enrichUserWithItsProfile({ user: booking.user })
|
||||
: null;
|
||||
|
||||
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 {
|
||||
redirect: {
|
||||
destination: `/${eventPage}?${destinationUrl.toString()}${
|
||||
destination: `${eventUrl}?${destinationUrlSearchParams.toString()}${
|
||||
eventType.seatsPerTimeSlot ? "&bookingUid=null" : ""
|
||||
}`,
|
||||
permanent: false,
|
||||
|
||||
@@ -641,8 +641,7 @@ const ProfileForm = ({
|
||||
|
||||
{showRemoveAvatarButton && (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="hover:bg-red-600"
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
}}>
|
||||
@@ -703,6 +702,7 @@ const ProfileForm = ({
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
height="80px"
|
||||
/>
|
||||
</div>
|
||||
{/* // 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>
|
||||
)}
|
||||
<div className="flex flex-1 justify-end">
|
||||
<Button color="destructive" onClick={onDisconnect} size="sm">
|
||||
Disconnect
|
||||
<Button color="destructive" onClick={onDisconnect}>
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
</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">
|
||||
{/* Header */}
|
||||
{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">
|
||||
<h1 className="font-cal text-[28px] leading-none ">
|
||||
|
||||
@@ -190,7 +190,7 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
|
||||
await gotoBookingPage(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 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();
|
||||
const locationInputName = "locations[0].hostPhoneNumber";
|
||||
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 gotoBookingPage(page);
|
||||
|
||||
@@ -709,6 +709,13 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
},
|
||||
delete: async () => await prisma.user.delete({ where: { id: store.user.id } }),
|
||||
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;
|
||||
password?: string | null;
|
||||
emailDomain?: string;
|
||||
profileUsername?: string;
|
||||
};
|
||||
|
||||
// creates the actual user in the db.
|
||||
@@ -749,11 +757,12 @@ const createUser = (
|
||||
})
|
||||
| null
|
||||
): PrismaType.UserUncheckedCreateInput => {
|
||||
const suffixToMakeUsernameUnique = `-${workerInfo.workerIndex}-${Date.now()}`;
|
||||
// build a unique name for our user
|
||||
const uname =
|
||||
opts?.useExactUsername && opts?.username
|
||||
? opts.username
|
||||
: `${opts?.username || "user"}-${workerInfo.workerIndex}-${Date.now()}`;
|
||||
: `${opts?.username || "user"}${suffixToMakeUsernameUnique}`;
|
||||
|
||||
const emailDomain = opts?.emailDomain || "example.com";
|
||||
return {
|
||||
@@ -772,7 +781,11 @@ const createUser = (
|
||||
role: opts?.role ?? "USER",
|
||||
twoFactorEnabled: opts?.twoFactorEnabled ?? false,
|
||||
disableImpersonation: opts?.disableImpersonation ?? false,
|
||||
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
|
||||
...getOrganizationRelatedProps({
|
||||
organizationId: opts?.organizationId,
|
||||
role: opts?.roleInOrganization,
|
||||
profileUsername: opts?.profileUsername,
|
||||
}),
|
||||
schedules:
|
||||
opts?.completedOnboarding ?? true
|
||||
? {
|
||||
@@ -792,9 +805,11 @@ const createUser = (
|
||||
function getOrganizationRelatedProps({
|
||||
organizationId,
|
||||
role,
|
||||
profileUsername,
|
||||
}: {
|
||||
organizationId: number | null | undefined;
|
||||
role: MembershipRole | undefined;
|
||||
profileUsername?: string;
|
||||
}) {
|
||||
if (!organizationId) {
|
||||
return null;
|
||||
@@ -807,7 +822,7 @@ const createUser = (
|
||||
profiles: {
|
||||
create: {
|
||||
uid: ProfileRepository.generateProfileUid(),
|
||||
username: uname,
|
||||
username: profileUsername ? `${profileUsername}${suffixToMakeUsernameUnique}` : uname,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organizationId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Frame, Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Frame, Page, Request as PlaywrightRequest } from "@playwright/test";
|
||||
import { createHash } from "crypto";
|
||||
import EventEmitter from "events";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
@@ -352,20 +352,51 @@ export async function fillStripeTestCheckout(page: Page) {
|
||||
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(
|
||||
{ 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) {
|
||||
throw new Error("orgSlug is not available");
|
||||
}
|
||||
|
||||
page.setExtraHTTPHeaders({
|
||||
"x-cal-force-slug": orgSlug,
|
||||
});
|
||||
await callback({ page });
|
||||
const callbackResult = await callback({
|
||||
page,
|
||||
goToUrlWithErrorHandling: (url: string) => {
|
||||
return goToUrlWithErrorHandling({ page, url });
|
||||
},
|
||||
});
|
||||
await page.setExtraHTTPHeaders({
|
||||
"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.
|
||||
|
||||
@@ -3,11 +3,13 @@ import { expect } from "@playwright/test";
|
||||
import type { createUsersFixture } from "playwright/fixtures/users";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { createNewEventType } from "./lib/testUtils";
|
||||
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
function getLabelLocator(field: Locator) {
|
||||
@@ -716,3 +718,164 @@ async function expectWebhookToBeCalled(
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user