Compare commits

...
Author SHA1 Message Date
emrysal 67af8b4973 chore: release v5.6.2
Post release / release (push) Canceled after 0s
2025-08-20 01:36:25 +00:00
Volnei Munhoz 7794f2596e chore: deprecated prisma default export (#23199) 2025-08-19 19:42:10 +00:00
167cf6e352 feat: add team schedules (#22954)
* feat: add team schedules

* fixup: implement feedback for teams controller

* cleanup unused functions

* fixup: implement PR feedback

* fixup: move over teams schedule controller inside of teams module

* remove unused param and update summary

* fixup: move over teams schedules controller inside of teams module

* update e2e tests

* add team schedule module

* update teams schedules module

* fixup: update guards

* init tests for teams schedules controller

* implement code rabbit feedback

* fixup: implement PR feedback

* fixup: remove getTeamSchedulesByUserIds

---------

Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-19 17:03:55 +03:00
Lauris SkraucisandSomay Chauhan 3d778ba481 fix: get organization bookings bookingUid parameter (#23186)
* fix: get organization bookings bookingUid parameter

* chore: bump @calcom/platform-libraries from 0.0.315 to 0.0.318

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-08-19 15:46:40 +02:00
b8f0000b59 feat: hide member filter for users with only MEMBER role (#23097)
- Conditionally disable userId column filter based on isTeamAdminOrOwner flag
- Prevents MEMBER-only users from encountering backend permission errors
- Users with ADMIN/OWNER role in any team can still access the filter

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-19 22:28:02 +09:00
Somay Chauhan 618ef63017 fix: Availability atom handleFormSubmit callbacks not triggering (#23174)
* fix: form submit callbacks trigger

* added changelog

* fix: type-error
2025-08-19 17:46:26 +05:30
Morgan ae8e633716 fix: correct impossible logical condition in Office365 calendar error handling #23151 (#23185) 2025-08-19 12:10:28 +00:00
Somay Chauhan 4a23832388 refactor: update microsoft platform slug to office365 for consistency (#23179)
* refactor: update Microsoft platform slug to office365 for consistency

* Update server.test.ts
2025-08-19 15:04:47 +03:00
Benny Joo 02e486d757 perf: og image caching v1 (#23180)
* perf: og image caching v1

* fix

* fix
2025-08-19 08:54:39 -03:00
Anik Dhabal Babu af6e24f433 fix: error oening date override input dialog (#23182) 2025-08-19 11:00:33 +00:00
Joe Au-Yeung f9e24f51a7 refactor: Extract <CalVideoSettings /> from `<Locations /> (#23113)
* Create `CalVideoSettings` type

* Create `LocationOptionContainer`

* Extract cal video settings to `CalVideoSettings`

* Refactor `<Locations />` component to use `<CalVideoSettings />`

* Unify naming

* Move location folder back under components

* Type fixes

* Delete console.log
2025-08-19 10:24:28 +00:00
Morgan 9e9f815809 refactor: replace static methods of HostRepository and PrismaAttributeRepository (#23175) 2025-08-19 07:59:16 +00:00
Lauris Skraucis a91561f3c2 fix: allow org admin to accept/reject booking (#23164)
* fix: allow org admin to accept/reject booking

* chore: bump platform libraries
2025-08-19 12:10:24 +05:30
Joe Au-YeungandHariom Balhara ff79064ca7 fix: Managed event types - children inherit team's appearance settings (#23116)
* Get parent event type team's appearance settings

* Use style profile to get brand colours

* Update packages/features/eventtypes/lib/getPublicEvent.ts

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-19 05:52:27 +00:00
Radhey MugdalandKartik Saini 1a573e1619 feat: v2 api hidden event types (#22715)
* feat: v2 api hidden event types

* feat: Add 'hidden' property to Input type in output.service.ts

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-19 11:07:12 +05:30
沙什瓦特 511be7386c Fixed scroll issue while configuring workflow (#23142) 2025-08-19 11:03:54 +05:30
emrysal 2a0991c259 chore: release v5.6.1
Post release / release (push) Canceled after 0s
2025-08-18 23:55:48 +00:00
Eunjae LeeandDevin AI 2e454c9723 refactor: rename insights routing service files (#22917)
- Rename insightsRoutingDI.ts -> InsightsRoutingDIService.ts
- Rename insightsRoutingBase.ts -> InsightsRoutingBaseService.ts
- Update all import statements to use new file names
- No functional changes, only file renames and import updates

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-18 15:15:21 +00:00
Anik Dhabal Babu 7e6d94c8e0 fix: team admin/owner can't change others member role (#23161)
* fix: team admin/owner can't change member role

* update

* Update role-management.factory.ts

* address review
2025-08-18 14:57:22 +00:00
Volnei Munhoz e18b790ccd chore: Upgrade Turborepo from 1 to 2.5 (#23101)
* upgrade turborepo from 1 to 2.5

* Fix tsconfig.json paths path

* chore: fix root path on tsconfig.paths

* chore: add baseUrl to tsconfig.json

* chore: tsconfig.json issue

* try: CI without tsconfig paths

* chore: add only local paths on tsconfig.json

* chore: Fix docs

* Fix missing env var on turbo.json
2025-08-18 11:17:27 -03:00
Morgan 0274417ad1 fix: api v2 workflow controller trigger validation (#23159) 2025-08-18 16:15:40 +03:00
a8a3a93ace fix: redirect to existing payment session on paid booking retry (#23096)
* fix: redirect to existing payment page on booking retry

- Extract existing payment UID from unpaid bookings
- Return paymentUid and paymentId when payment form should be shown
- Prevents payment form bypass by redirecting to original payment session
- Maintains payment continuity without creating duplicate bookings

Fixes issue where canceled payments showed success page on rebooking

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* test: add unit test for payment retry scenario

- Test verifies existing payment UID is returned on booking retry
- Ensures no duplicate bookings are created in database
- Validates paymentRequired flag is set correctly for unpaid bookings
- Covers the payment redirect fix in handleNewBooking.ts

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* update

* fix: restore calendar event deletion for round robin reassignment

- Add deleteEventsAndMeetings call back to EventManager.reschedule() when changedOrganizer is true
- Add unit test to verify calendar deletion during round robin reassignment
- Fixes regression where calendar events weren't deleted from original host's calendar

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Update roundRobinReassignment.test.ts

* Update EventManager.ts

* test: update payment retry test title to single descriptive line

- Changed from numbered list format to concise single line description
- Test functionality remains unchanged and passes successfully

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-18 11:44:40 +00:00
88bbab216e fix: Consistently remove a member from organization/sub-team/team - through all the APIs and webapp actions (#22806)
* Fix membership deletion handling and add tests

* fixes

* revert api-v2 specific changes

* Add more tests

* refactor: Move removeMember function into TeamService as private static methods

- Moved removeMember and all related helper functions from separate file into TeamService class
- Made all functions private static methods instead of exporting them
- Deleted the original removeMember.ts file since it's no longer needed
- Updated imports to use the new location
- All integration tests pass successfully

* refactor: Rename memberId to userId in TeamService methods

- Renamed memberId parameter to userId in removeMember private method
- Renamed memberIds parameter to userIds in removeMembers public method
- Updated all calls to removeMembers to use userIds instead of memberIds
- Parameter names now accurately reflect that they are user IDs, not membership IDs

* test: add service unit tests and simplify e2e tests for membership deletion

- Add unit tests for membership deletion services
- Remove redundant deletion behavior tests from controllers
- Keep only happy path tests in e2e controller tests
- Fix unused imports and variables

* package.json version

* fix unit teswtes

* No specs file

* fix unit tests

* fix: Add platform-libraries build step to E2E API v2 workflow

The E2E API v2 tests were failing with TypeScript compilation errors because
TeamService imports from @calcom/platform-libraries require the package to be
built first to generate the dist/ folder with compiled exports.

This adds the same build step that was added to unit-tests.yml to resolve
the module resolution errors.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Making tests clearer and easier to undestand

* chore: bump platform libs

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: cal.com <morgan@cal.com>
2025-08-18 08:38:32 -03:00
Chinmay Patil f74434a282 fix: show temporarily out-of-office message on all screen sizes (#23130) 2025-08-18 16:56:49 +05:30
Joe Au-Yeung ed6f9d8252 fix: Add cal.eu to IS_CALCOM (#23111)
* Add cal.eu to `IS_CALCOM`

* Create `getCalcomUrl` function

* Use `getCalcomURl` in generate metadata

* Use `getCalcomURl` in `buildCanonical`

* Linting fix
2025-08-18 16:04:48 +05:30
Benny Joo a19ccab16a perf: use promise.all in /apps/installed (#23146) 2025-08-18 10:11:22 +05:30
Benny Joo 77369a13f1 chore: remove unused orgMigrations files (#23056) 2025-08-17 22:18:14 -03:00
Anik Dhabal Babu 8231356d2a test: E2E test to verify event multiple duration works correctly (#23144)
* fix: multiple booking duration isn't working

* followup test
2025-08-17 20:26:14 +01:00
Anik Dhabal Babu 469a942b5a fix: multiple booking duration isn't working (#23140) 2025-08-17 17:08:27 +00:00
keithwillcode 0afe6cf5d1 chore: release v5.6.0
Post release / release (push) Canceled after 0s
2025-08-15 15:32:31 +00:00
106 changed files with 3103 additions and 1089 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
fix: Availability atom handleFormSubmit callbacks not triggering
+2 -2
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+1 -1
View File
@@ -199,7 +199,7 @@ There's some settings that you'll need to setup.
Under Vercel > Your API Project > Settings
In General > Build & Development Settings
BUILD COMMAND: `yarn turbo run build --scope=@calcom/api --include-dependencies --no-deps`
BUILD COMMAND: `yarn turbo run build --filter=@calcom/api --no-deps`
OUTPUT DIRECTORY: `apps/api/.next`
In Git > Ignored Build Step
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.318",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -15,11 +15,11 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.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";
import { randomString } from "test/utils/randomString";
@@ -36,6 +36,7 @@ import {
CreateBookingInput_2024_08_13,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
} from "@calcom/platform-types";
@@ -50,9 +51,9 @@ describe("Managed user bookings 2024-08-13", () => {
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
const platformAdminEmail = `managed-users-bookings-2024-08-13-admin-${randomString()}@api.com`;
let platformAdmin: User;
@@ -67,6 +68,10 @@ describe("Managed user bookings 2024-08-13", () => {
let thirdManagedUser: CreateManagedUserData;
let firstManagedUserEventTypeId: number;
let eventTypeRequiresConfirmationId: number;
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
let orgAdminManagedUser: CreateManagedUserData;
let firstManagedUserBookingsCount = 0;
let secondManagedUserBookingsCount = 0;
@@ -85,9 +90,9 @@ describe("Managed user bookings 2024-08-13", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
@@ -95,6 +100,13 @@ describe("Managed user bookings 2024-08-13", () => {
name: `oauth-client-users-organization-${randomString()}`,
isPlatform: true,
isOrganization: true,
platformBilling: {
create: {
customerId: "cus_999",
plan: "SCALE",
subscriptionId: "sub_999",
},
},
});
oAuthClient = await createOAuthClient(organization.id);
@@ -243,6 +255,59 @@ describe("Managed user bookings 2024-08-13", () => {
thirdManagedUser = responseBody.data;
});
it(`should create org admin managed user`, async () => {
const requestBody: CreateManagedUserInput = {
email: orgAdminManagedUserEmail,
timeZone: managedUsersTimeZone,
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
name: "Org Admin Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toEqual(
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
orgAdminManagedUser = responseBody.data;
await request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/memberships`)
.set("x-cal-client-id", oAuthClient.id)
.set("x-cal-secret-key", oAuthClient.secret)
.send({
userId: orgAdminManagedUser.user.id,
role: "ADMIN",
accepted: true,
})
.expect(201);
});
it("should create an event type requiring confirmation for first managed user", async () => {
const eventTypeRequiresConfirmation = await eventTypesRepositoryFixture.create(
{
title: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
slug: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
length: 60,
requiresConfirmation: true,
},
firstManagedUser.user.id
);
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
});
describe("bookings using original emails", () => {
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
const body: CreateBookingInput_2024_08_13 = {
@@ -543,10 +608,175 @@ describe("Managed user bookings 2024-08-13", () => {
});
});
describe("booking confirmation by org admin", () => {
it("should allow org admin managed user to confirm booking using access token", async () => {
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
user: {
connect: {
id: firstManagedUser.user.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)),
title: "Booking requiring confirmation",
uid: `booking-requiring-confirmation-${randomString()}`,
eventType: {
connect: {
id: eventTypeRequiresConfirmationId,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
status: "PENDING",
responses: {
name: secondManagedUser.user.name,
email: secondManagedUserEmail,
},
attendees: {
create: {
email: secondManagedUserEmail,
name: secondManagedUser.user.name!,
locale: secondManagedUser.user.locale,
timeZone: secondManagedUser.user.timeZone,
},
},
});
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(200);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const bookingData = responseBody.data as BookingOutput_2024_08_13;
expect(bookingData.status).toEqual("accepted");
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
const confirmedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
expect(confirmedBooking?.status).toEqual("ACCEPTED");
});
it("should allow org admin managed user to reject booking using access token", async () => {
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
user: {
connect: {
id: firstManagedUser.user.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 16, 0, 0)),
title: "Booking requiring rejection",
uid: `booking-requiring-rejection-${randomString()}`,
eventType: {
connect: {
id: eventTypeRequiresConfirmationId,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
status: "PENDING",
responses: {
name: secondManagedUser.user.name,
email: secondManagedUserEmail,
},
attendees: {
create: {
email: secondManagedUserEmail,
name: secondManagedUser.user.name!,
locale: secondManagedUser.user.locale,
timeZone: secondManagedUser.user.timeZone,
},
},
});
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(200);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const bookingData = responseBody.data as BookingOutput_2024_08_13;
expect(bookingData.status).toEqual("rejected");
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
const rejectedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
expect(rejectedBooking?.status).toEqual("REJECTED");
});
it("should return unauthorized when org admin tries to confirm regular user's booking", async () => {
const regularUser = await userRepositoryFixture.create({
email: `managed-user-bookings-2024-08-13-regular-user-${randomString()}@api.com`,
name: "Regular User",
});
const regularUserEventType = await eventTypesRepositoryFixture.create(
{
title: `regular-user-event-type-requires-confirmation-${randomString()}`,
slug: `regular-user-event-type-requires-confirmation-${randomString()}`,
length: 60,
requiresConfirmation: true,
},
regularUser.id
);
const regularUserBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: regularUser.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 17, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 18, 0, 0)),
title: "Regular user booking requiring confirmation",
uid: `regular-user-booking-${randomString()}`,
eventType: {
connect: {
id: regularUserEventType.id,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
status: "PENDING",
responses: {
name: "External Attendee",
email: "external@example.com",
},
attendees: {
create: {
email: "external@example.com",
name: "External Attendee",
locale: "en",
timeZone: "Europe/Rome",
},
},
});
await request(app.getHttpServer())
.post(`/v2/bookings/${regularUserBooking.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(401);
await userRepositoryFixture.delete(regularUser.id);
});
});
afterAll(async () => {
await userRepositoryFixture.delete(firstManagedUser.user.id);
await userRepositoryFixture.delete(secondManagedUser.user.id);
await userRepositoryFixture.delete(thirdManagedUser.user.id);
await userRepositoryFixture.delete(orgAdminManagedUser.user.id);
await userRepositoryFixture.delete(platformAdmin.id);
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -702,6 +702,7 @@ export class InputBookingsService_2024_08_13 {
beforeUpdatedDate: queryParams.beforeUpdatedAt,
afterCreatedDate: queryParams.afterCreatedAt,
beforeCreatedDate: queryParams.beforeCreatedAt,
bookingUid: queryParams.bookingUid,
};
}
@@ -2147,6 +2147,51 @@ describe("Event types Endpoints", () => {
});
});
describe("EventType Hidden Property", () => {
let createdEventTypeId: number;
it("should create an event type with hidden=true", async () => {
const createPayload = {
title: "Hidden Event",
slug: "hidden-event",
lengthInMinutes: 30,
hidden: true,
};
const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(createPayload)
.expect(201);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(createPayload.title);
expect(createdEventType.slug).toEqual(createPayload.slug);
expect(createdEventType.lengthInMinutes).toEqual(createPayload.lengthInMinutes);
expect(createdEventType.hidden).toBe(true);
createdEventTypeId = createdEventType.id;
});
it("should update the hidden property to false", async () => {
const updatePayload = {
hidden: false,
};
const response = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventTypeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(updatePayload)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
+expect(updatedEventType.hidden).toBe(false);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -89,6 +89,7 @@ type Input = Pick<
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
| "calVideoSettings"
| "hidden"
>;
@Injectable()
@@ -127,6 +128,7 @@ export class OutputEventTypesService_2024_06_14 {
hideCalendarEventDetails,
hideOrganizerEmail,
calVideoSettings,
hidden,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
@@ -203,6 +205,7 @@ export class OutputEventTypesService_2024_06_14 {
hideCalendarEventDetails,
hideOrganizerEmail,
calVideoSettings,
hidden,
};
}
@@ -229,4 +229,19 @@ export class SchedulesRepository_2024_06_11 {
},
});
}
async getSchedulesByUserIds(userIds: number[], skip: number, take: number) {
return this.dbRead.prisma.schedule.findMany({
where: {
userId: {
in: userIds,
},
},
include: {
availability: true,
},
skip,
take,
});
}
}
@@ -11,6 +11,7 @@ import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsSchedulesModule } from "@/modules/teams/schedules/teams-schedules.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
@@ -38,6 +39,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
OrganizationsRoutingFormsModule,
VerifiedResourcesModule,
RouterModule,
TeamsSchedulesModule,
],
})
export class EndpointsModule implements NestModule {
@@ -66,6 +66,8 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
let orgEventTypeId2: number;
let nonOrgEventTypeId: number;
let collectiveOrgBookingUid: string;
beforeAll(async () => {
const moduleRef = await withApiAuth(
orgUserEmail2,
@@ -91,12 +93,17 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "team orgs booking 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
oAuthClient = await createOAuthClient(organization.id);
nonOrgUser1 = await userRepositoryFixture.create({
email: nonOrgUserEmail1,
@@ -315,6 +322,7 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
collectiveOrgBookingUid = data.uid;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
@@ -669,6 +677,125 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
);
});
});
describe("get by bookingUid param", () => {
it("should get a specific booking by bookingUid query param", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${collectiveOrgBookingUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(responseDataIsBooking(data[0])).toBe(true);
if (responseDataIsBooking(data[0])) {
const booking: BookingOutput_2024_08_13 = data[0];
expect(booking.uid).toEqual(collectiveOrgBookingUid);
expect(booking.eventTypeId).toEqual(orgEventTypeId);
expect(booking.status).toEqual("accepted");
expect(booking.attendees.length).toEqual(2);
expect(booking.attendees[0].name).toEqual("alice");
expect(booking.attendees[0].email).toEqual("alice@gmail.com");
} else {
throw new Error("Expected single booking but received different response type");
}
});
});
it("should return empty array for non-existent booking UID query param", async () => {
const nonExistentUid = "non-existent-booking-uid";
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${nonExistentUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(0);
});
});
it("should return empty array for booking UID not belonging to the organization", async () => {
const regularUser = await userRepositoryFixture.create({
email: `org-bookings-regular-user-${Math.floor(Math.random() * 10000)}@api.com`,
name: "Regular User",
});
const regularUserEventType = await eventTypesRepositoryFixture.create(
{
title: `regular-user-event-type-${Math.floor(Math.random() * 10000)}`,
slug: `regular-user-event-type-${Math.floor(Math.random() * 10000)}`,
length: 60,
bookingFields: [],
locations: [],
},
regularUser.id
);
const regularUserBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: regularUser.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 15, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 15, 14, 0, 0)),
title: "Regular user booking",
uid: `regular-user-booking-${Math.floor(Math.random() * 10000)}`,
eventType: {
connect: {
id: regularUserEventType.id,
},
},
location: "https://meet.google.com/regular-user",
customInputs: {},
metadata: {},
status: "ACCEPTED",
responses: {
name: "Regular Attendee",
email: "regular@example.com",
},
attendees: {
create: {
email: "regular@example.com",
name: "Regular Attendee",
locale: "en",
timeZone: "Europe/Rome",
},
},
});
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${regularUserBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(0);
await userRepositoryFixture.delete(regularUser.id);
});
});
});
});
async function createOAuthClient(organizationId: number) {
@@ -74,6 +74,7 @@ type Input = Pick<
| "hideOrganizerEmail"
| "team"
| "calVideoSettings"
| "hidden"
>;
@Injectable()
@@ -2,6 +2,8 @@ import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inpu
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
@@ -62,10 +64,24 @@ export class OrganizationsMembershipService {
}
async deleteOrgMembership(organizationId: number, membershipId: number) {
const membership = await this.organizationsMembershipRepository.deleteOrgMembership(
// Get the membership first to get the userId
const membership = await this.organizationsMembershipRepository.findOrgMembership(
organizationId,
membershipId
);
if (!membership) {
throw new NotFoundException(
`Membership with id ${membershipId} within organization id ${organizationId} not found`
);
}
await TeamService.removeMembers({
teamIds: [organizationId],
userIds: [membership.userId],
isOrg: true,
});
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
@@ -1,5 +1,9 @@
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
@@ -18,13 +22,10 @@ import { OrganizationAttributesService } from "@/modules/organizations/attribute
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
@@ -38,7 +39,6 @@ import { OrganizationsMembershipOutputService } from "@/modules/organizations/me
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
@@ -65,6 +65,7 @@ import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
@@ -102,7 +103,6 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsService,
MembershipsRepository,
OrganizationsSchedulesService,
OrganizationSchedulesRepository,
OrganizationsUsersRepository,
OrganizationsUsersService,
EmailService,
@@ -144,6 +144,9 @@ import { Module } from "@nestjs/common";
TeamWorkflowsService,
WorkflowsInputService,
WorkflowsOutputService,
TeamsSchedulesService,
SchedulesService_2024_06_11,
InputSchedulesService_2024_06_11,
],
exports: [
OrganizationsService,
@@ -1,5 +1,5 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
@@ -8,8 +8,8 @@ import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsSchedulesService {
constructor(
private readonly organizationSchedulesService: OrganizationSchedulesRepository,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly usersRepository: UsersRepository
) {}
@@ -17,7 +17,7 @@ export class OrganizationsSchedulesService {
const users = await this.usersRepository.getOrganizationUsers(organizationId);
const usersIds = users.map((user) => user.id);
const schedules = await this.organizationSchedulesService.getSchedulesByUserIds(usersIds, skip, take);
const schedules = await this.schedulesRepository.getSchedulesByUserIds(usersIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
@@ -20,7 +20,6 @@ import {
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
@@ -59,7 +58,6 @@ export class OrganizationsTeamsMembershipsController {
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private teamsEventTypesService: TeamsEventTypesService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@@ -127,8 +125,6 @@ export class OrganizationsTeamsMembershipsController {
membershipId
);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
@@ -3,6 +3,8 @@ import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/member
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsMembershipsService {
constructor(
@@ -58,11 +60,21 @@ export class OrganizationsTeamsMembershipsService {
}
async deleteOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) {
const teamMembership = await this.organizationsTeamsMembershipsRepository.deleteOrgTeamMembershipById(
// First get the membership to get the userId
const teamMembership = await this.organizationsTeamsMembershipsRepository.findOrgTeamMembership(
organizationId,
teamId,
membershipId
);
if (!teamMembership) {
throw new NotFoundException(
`Membership with id ${membershipId} not found in team ${teamId} of organization ${organizationId}`
);
}
await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false });
return teamMembership;
}
}
@@ -14,11 +14,12 @@ 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 { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard";
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId",
@@ -29,7 +30,31 @@ import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsSchedulesController {
constructor(private schedulesService: SchedulesService_2024_06_11) {}
constructor(
private schedulesService: SchedulesService_2024_06_11,
private teamsSchedulesService: TeamsSchedulesService
) {}
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/schedules")
@DocsTags("Orgs / Teams / Schedules")
@ApiOperation({ summary: "Get all team member schedules" })
async getTeamSchedules(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@@ -18,6 +18,7 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import { ApiSuccessResponse, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import { Team, Schedule } from "@calcom/prisma/client";
@@ -36,6 +37,8 @@ describe("Organizations Teams Schedules Endpoints", () => {
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
let userSchedule: Schedule;
let user2Schedule: Schedule;
const userEmail = `organizations-teams-schedules-admin-${randomString()}@api.com`;
@@ -73,6 +76,16 @@ describe("Organizations Teams Schedules Endpoints", () => {
username: userEmail2,
});
userSchedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
name: `organizations-teams-schedules-user-schedule-${randomString()}`,
timeZone: "America/New_York",
});
user2Schedule = await scheduleRepositoryFixture.create({
user: {
connect: {
@@ -214,6 +227,32 @@ describe("Organizations Teams Schedules Endpoints", () => {
});
});
it("should get all the schedules of members in a team", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules`)
.expect(200)
.then((response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(2);
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
expect(userOneSchedule).toBeDefined();
expect(userTwoSchedule).toBeDefined();
expect(userOneSchedule?.id).toEqual(userSchedule.id);
expect(userOneSchedule?.name).toEqual(userSchedule.name);
expect(userOneSchedule?.timeZone).toEqual(userSchedule.timeZone);
expect(userTwoSchedule?.id).toEqual(user2Schedule.id);
expect(userTwoSchedule?.name).toEqual(user2Schedule.name);
expect(userOneSchedule?.timeZone).toEqual(user2Schedule.timeZone);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
@@ -12,7 +12,12 @@ import {
EMAIL,
WorkflowEmailAttendeeStepDto,
} from "@/modules/workflows/inputs/workflow-step.input";
import { BEFORE_EVENT, DAY } from "@/modules/workflows/inputs/workflow-trigger.input";
import {
BEFORE_EVENT,
DAY,
OnAfterEventTriggerDto,
OnBeforeEventTriggerDto,
} from "@/modules/workflows/inputs/workflow-trigger.input";
// Adjust path if needed
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
import { INestApplication } from "@nestjs/common";
@@ -255,6 +260,10 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
"+37255555555"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
const trigger = sampleCreateWorkflowDto.trigger as OnBeforeEventTriggerDto;
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
createdWorkflowId = responseBody.data.id;
createdWorkflow = responseBody.data;
});
@@ -330,6 +339,13 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
expect(step1).toBeDefined();
const partialUpdateDto: Partial<CreateWorkflowDto> = {
name: updatedName,
trigger: {
type: "afterEvent",
offset: {
unit: "minute",
value: 10,
},
},
steps: step1 ? [{ ...step1, sender: "updatedSender" } as WorkflowEmailAttendeeStepDto] : [],
};
expect(createdWorkflowId).toBeDefined();
@@ -348,6 +364,10 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
step1 && expect(responseBody.data.steps[0].id).toEqual(step1.id);
expect(responseBody.data.steps[0].sender).toEqual("updatedSender");
expect(responseBody.data.steps[1]?.id).toBeUndefined();
const trigger = partialUpdateDto.trigger as OnAfterEventTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
});
});
@@ -156,17 +156,4 @@ export class TeamsEventTypesService {
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.teamsEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.teamsEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
}
}
@@ -3,7 +3,6 @@ import { API_KEY_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
@@ -45,10 +44,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
export class TeamsMembershipsController {
private logger = new Logger("TeamsMembershipsController");
constructor(
private teamsMembershipsService: TeamsMembershipsService,
private teamsEventTypesService: TeamsEventTypesService
) {}
constructor(private teamsMembershipsService: TeamsMembershipsService) {}
@Roles("TEAM_ADMIN")
@Post("/")
@@ -127,7 +123,6 @@ export class TeamsMembershipsController {
membershipId,
body
);
if (!currentMembership.accepted && updatedMembership.accepted) {
try {
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
@@ -151,8 +146,6 @@ export class TeamsMembershipsController {
): Promise<DeleteTeamMembershipOutput> {
const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
@@ -3,6 +3,8 @@ import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/up
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
@Injectable()
export class TeamsMembershipsService {
constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {}
@@ -41,10 +43,15 @@ export class TeamsMembershipsService {
}
async deleteTeamMembership(teamId: number, membershipId: number) {
const teamMembership = await this.teamsMembershipsRepository.deleteTeamMembershipById(
teamId,
membershipId
);
// First get the membership to get the userId
const teamMembership = await this.teamsMembershipsRepository.findTeamMembership(teamId, membershipId);
if (!teamMembership) {
throw new NotFoundException(`Membership with id ${membershipId} not found in team ${teamId}`);
}
await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false });
return teamMembership;
}
}
@@ -0,0 +1,41 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/teams/:teamId/schedules",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, RolesGuard)
@DocsTags("Teams / Schedules")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
export class TeamsSchedulesController {
constructor(private teamsSchedulesService: TeamsSchedulesService) {}
@Roles("TEAM_ADMIN")
@Get("/")
@ApiOperation({
summary: "Get all team member schedules",
})
async getTeamSchedules(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
}
@@ -0,0 +1,143 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.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";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
import { Team, Schedule } from "@calcom/prisma/client";
describe("Teams Schedules Endpoints", () => {
describe("User Authentication - User is Team Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let nonOrgTeam: Team;
let userSchedule: Schedule;
let user2Schedule: Schedule;
const userEmail = `teams-schedules-admin-${randomString()}@api.com`;
const userEmail2 = `teams-schedules-member-${randomString()}@api.com`;
let user: User;
let user2: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
});
userSchedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
name: `teams-schedules-user-schedule-${randomString()}`,
timeZone: "America/New_York",
});
user2Schedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user2.id,
},
},
name: `teams-schedules-user2-schedule-${randomString()}`,
timeZone: "America/New_York",
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: `teams-schedules-non-org-team-${randomString()}`,
isOrganization: false,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: nonOrgTeam.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: nonOrgTeam.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should get all the schedules of members in a team", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${nonOrgTeam.id}/schedules`)
.expect(200)
.then((response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.length).toEqual(2);
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
expect(userOneSchedule).toBeDefined();
expect(userTwoSchedule).toBeDefined();
expect(userOneSchedule?.id).toEqual(userSchedule.id);
expect(userOneSchedule?.name).toEqual(userSchedule.name);
expect(userOneSchedule?.timeZone).toEqual(userSchedule.timeZone);
expect(userTwoSchedule?.id).toEqual(user2Schedule.id);
expect(userTwoSchedule?.name).toEqual(user2Schedule.name);
expect(userOneSchedule?.timeZone).toEqual(user2Schedule.timeZone);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await teamsRepositoryFixture.delete(nonOrgTeam.id);
await app.close();
});
});
});
@@ -0,0 +1,28 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { Injectable } from "@nestjs/common";
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class TeamsSchedulesService {
constructor(
private readonly teamsRepository: TeamsRepository,
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11
) {}
async getTeamSchedules(teamId: number, skip = 0, take = 250) {
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
for (const schedule of schedules) {
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
}
return responseSchedules;
}
}
@@ -0,0 +1,43 @@
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { TeamsSchedulesController } from "@/modules/teams/schedules/controllers/teams-schedules.controller";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { TeamsController } from "@/modules/teams/teams/controllers/teams.controller";
import { TeamsService } from "@/modules/teams/teams/services/teams.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, RedisModule],
providers: [
TeamsRepository,
TeamsService,
TeamsMembershipsRepository,
TeamsMembershipsService,
OutputSchedulesService_2024_06_11,
OrganizationSchedulesRepository,
StripeService,
UsersRepository,
AppsRepository,
CredentialsRepository,
OrganizationsRepository,
TeamsSchedulesService,
OrganizationsTeamsRepository,
SchedulesRepository_2024_06_11,
],
controllers: [TeamsController, TeamsSchedulesController],
exports: [TeamsRepository],
})
export class TeamsSchedulesModule {}
@@ -1,7 +1,7 @@
import { ApiProperty } from "@nestjs/swagger";
import { TimeUnit, WorkflowTriggerEvents } from "@prisma/client";
import { Type } from "class-transformer";
import { IsNumber, ValidateNested } from "class-validator";
import { IsIn, IsNumber, IsString, ValidateNested } from "class-validator";
export const BEFORE_EVENT = "beforeEvent";
export const EVENT_CANCELLED = "eventCancelled";
@@ -68,6 +68,8 @@ export class WorkflowTriggerOffsetDto {
value!: number;
@ApiProperty({ description: "Unit for the offset time", example: HOUR })
@IsString()
@IsIn(TIME_UNITS)
unit!: TimeUnitType;
}
@@ -75,6 +77,8 @@ export class BaseWorkflowTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([WORKFLOW_TRIGGER_TYPES])
type!: WorkflowTriggerType;
}
@@ -82,6 +86,8 @@ export class OnCreationTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([NEW_EVENT])
type: typeof NEW_EVENT = NEW_EVENT;
}
@@ -89,12 +95,16 @@ export class OnRescheduleTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([RESCHEDULE_EVENT])
type: typeof RESCHEDULE_EVENT = RESCHEDULE_EVENT;
}
export class OnCancelTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([EVENT_CANCELLED])
type: typeof EVENT_CANCELLED = EVENT_CANCELLED;
}
@@ -113,6 +123,8 @@ export class OnBeforeEventTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: BEFORE_EVENT,
})
@IsString()
@IsIn([BEFORE_EVENT])
type: typeof BEFORE_EVENT = BEFORE_EVENT;
}
@@ -121,6 +133,8 @@ export class OnAfterEventTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_EVENT,
})
@IsString()
@IsIn([AFTER_EVENT])
type: typeof AFTER_EVENT = AFTER_EVENT;
}
@@ -129,6 +143,8 @@ export class OnAfterCalVideoGuestsNoShowTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
})
@IsString()
@IsIn([AFTER_GUESTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_GUESTS_CAL_VIDEO_NO_SHOW = AFTER_GUESTS_CAL_VIDEO_NO_SHOW;
}
@@ -137,5 +153,7 @@ export class OnAfterCalVideoHostsNoShowTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
})
@IsString()
@IsIn([AFTER_HOSTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_HOSTS_CAL_VIDEO_NO_SHOW = AFTER_HOSTS_CAL_VIDEO_NO_SHOW;
}
@@ -59,4 +59,8 @@ export class EventTypesRepositoryFixture {
async delete(eventTypeId: EventType["id"]) {
return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } });
}
async findById(eventTypeId: EventType["id"]) {
return this.prismaReadClient.eventType.findUnique({ where: { id: eventTypeId } });
}
}
@@ -35,4 +35,8 @@ export class MembershipRepositoryFixture {
await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } });
return membership;
}
async findById(membershipId: Membership["id"]) {
return this.prismaReadClient.membership.findUnique({ where: { id: membershipId } });
}
}
@@ -45,11 +45,13 @@ const InstalledAppsWrapper = async ({ params }: PageProps) => {
createRouterCaller(appsRouter),
]);
const connectedCalendars = await calendarsCaller.connectedCalendars();
const installedCalendars = await appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
});
const [connectedCalendars, installedCalendars] = await Promise.all([
calendarsCaller.connectedCalendars(),
appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
}),
]);
return (
<InstalledApps
@@ -5,6 +5,7 @@ import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { TeamMembersView } from "~/teams/team-members-view";
@@ -36,8 +37,10 @@ const getCachedTeamRoles = unstable_cache(
const getCachedTeamAttributes = unstable_cache(
async (organizationId?: number) => {
if (!organizationId) return [];
const attributeRepo = new PrismaAttributeRepository(prisma);
try {
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
return [];
}
@@ -4,6 +4,7 @@ import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { MembersView } from "~/members/members-view";
@@ -19,7 +20,9 @@ export const generateMetadata = async () =>
const getCachedAttributes = unstable_cache(
async (orgId: number) => {
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId });
const attributeRepo = new PrismaAttributeRepository(prisma);
return await attributeRepo.findAllByOrgIdWithOptions({ orgId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
@@ -1,4 +1,3 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
+2 -1
View File
@@ -5,6 +5,7 @@ import { getLocale } from "@calcom/features/auth/lib/getLocale";
import type { AppImageProps, MeetingImageProps } from "@calcom/lib/OgImages";
import { constructAppImage, constructGenericImage, constructMeetingImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { getTranslation } from "@calcom/lib/server/i18n";
import { truncateOnWord } from "@calcom/lib/text";
@@ -32,7 +33,7 @@ const _generateMetadataWithoutImage = async (
const description = getDescription(t);
const titleSuffix = `| ${APP_NAME}`;
const displayedTitle = title.includes(titleSuffix) || hideBranding ? title : `${title} ${titleSuffix}`;
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
const metadataBase = new URL(IS_CALCOM ? getCalcomUrl() : WEBAPP_URL);
return {
title: title.length === 0 ? APP_NAME : displayedTitle,
+18 -3
View File
@@ -87,7 +87,10 @@ async function handler(req: NextRequest) {
return new Response(img.body, {
status: 200,
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch (error) {
if (error instanceof ZodError) {
@@ -116,7 +119,13 @@ async function handler(req: NextRequest) {
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch (error) {
if (error instanceof ZodError) {
return new Response(
@@ -144,7 +153,13 @@ async function handler(req: NextRequest) {
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch (error) {
if (error instanceof ZodError) {
return new Response(
+2 -1
View File
@@ -9,6 +9,7 @@ import Script from "next/script";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { IconSprites } from "@calcom/ui/components/icon";
@@ -64,7 +65,7 @@ function PageWrapper(props: AppProps) {
// Set canonical to https://cal.com or self-hosted URL
canonical={
IS_CALCOM
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
? buildCanonical({ path, origin: getCalcomUrl() }) // cal.com & .dev
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
}
{...seoConfig.defaultNextSeo}
@@ -2,9 +2,11 @@ import { useMemo } from "react";
import { useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { TLocationOptions } from "@calcom/features/eventtypes/components/Locations";
import type { TEventTypeLocation } from "@calcom/features/eventtypes/components/Locations";
import Locations from "@calcom/features/eventtypes/components/Locations";
import type {
TLocationOptions,
TEventTypeLocation,
} from "@calcom/features/eventtypes/components/locations/Locations";
import Locations from "@calcom/features/eventtypes/components/locations/Locations";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import type { SingleValueLocationOption } from "@calcom/features/form/components/LocationSelect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -1,34 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {
error: null,
migrated: null,
userId: session.user.id,
username: session.user.username,
},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -156,7 +156,7 @@ export function AvailabilityList({ availabilities }: AvailabilityListProps) {
))}
</ul>
</div>
<div className="text-default mb-16 mt-4 hidden text-center text-sm md:block">
<div className="text-default mb-16 mt-4 block text-center text-sm">
{t("temporarily_out_of_office")}{" "}
<Link href="settings/my-account/out-of-office" className="underline">
{t("add_a_redirect")}
@@ -180,7 +180,7 @@ function BookingsContent({ status }: BookingsProps) {
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: true,
enableColumnFilter: user?.isTeamAdminOrOwner ?? false,
enableSorting: false,
cell: () => null,
meta: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.18",
"version": "5.6.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+29 -8
View File
@@ -479,7 +479,6 @@ test.describe("Bookings", () => {
const filterItemsConfig = [
{ key: "eventTypeId", name: "Event Type", testId: "add-filter-item-eventTypeId" },
{ key: "teamId", name: "Team", testId: "add-filter-item-teamId" },
{ key: "userId", name: "Member", testId: "add-filter-item-userId" },
{ key: "attendeeName", name: "Attendees Name", testId: "add-filter-item-attendeeName" },
{ key: "attendeeEmail", name: "Attendee Email", testId: "add-filter-item-attendeeEmail" },
{ key: "dateRange", name: "Date Range", testId: "add-filter-item-dateRange" },
@@ -488,8 +487,16 @@ test.describe("Bookings", () => {
const getFilterItemLocator = (page: Page, testId: string) => page.locator(`[data-testid="${testId}"]`);
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
const setup = async ({
isAdmin,
page,
users,
}: {
isAdmin: boolean;
page: Page;
users: Fixtures["users"];
}) => {
const user = isAdmin ? await users.create(undefined, { hasTeam: true }) : await users.create();
await user.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
@@ -498,9 +505,10 @@ test.describe("Bookings", () => {
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await expect(page.locator(searchInputSelector)).toBeVisible();
});
};
test("should show all filter items initially and after clearing search", async ({ page }) => {
test("should show all filter items initially and after clearing search", async ({ page, users }) => {
await setup({ isAdmin: false, page, users });
const searchInput = page.locator(searchInputSelector);
// Initial check: all defined filter items should be visible
@@ -524,7 +532,18 @@ test.describe("Bookings", () => {
}
});
test("search should be case-insensitive", async ({ page }) => {
test("should show admin-only filter", async ({ page, users }) => {
await setup({ isAdmin: true, page, users });
await expect(
getFilterItemLocator(page, "add-filter-item-userId"),
`Item "Member" should be visible initially`
).toBeVisible();
});
test("search should be case-insensitive", async ({ page, users }) => {
await setup({ isAdmin: true, page, users });
const searchInput = page.locator(searchInputSelector);
// Search for "member" (lowercase)
@@ -534,7 +553,8 @@ test.describe("Bookings", () => {
await expect(getFilterItemLocator(page, "add-filter-item-teamId")).toBeHidden();
});
test("should individually find each filter item by its full name", async ({ page }) => {
test("should individually find each filter item by its full name", async ({ page, users }) => {
await setup({ isAdmin: false, page, users });
const searchInput = page.locator(searchInputSelector);
for (const targetItem of filterItemsConfig) {
@@ -550,7 +570,8 @@ test.describe("Bookings", () => {
}
});
test("should show no items for a non-matching search term", async ({ page }) => {
test("should show no items for a non-matching search term", async ({ page, users }) => {
await setup({ isAdmin: false, page, users });
const searchInput = page.locator(searchInputSelector);
const nonExistentTerm = "NonExistentFilterXYZ123";
await searchInput.fill(nonExistentTerm);
@@ -6,6 +6,7 @@ import { test } from "./lib/fixtures";
import {
bookTimeSlot,
confirmReschedule,
confirmBooking,
doOnOrgDomain,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
@@ -98,6 +99,58 @@ test("dynamic booking info prefilled by query params", async ({ page, users }) =
activeState = await listItemLocator.getAttribute("data-active");
expect(activeState).toEqual("false");
});
test("multiple duration selection updates event length correctly", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await test.step("update event title to include duration placeholder", async () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
await page.click(`text=Multiple duration`);
await page.waitForSelector('[data-testid="event-title"]');
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
"aria-current",
"page"
);
await page.getByTestId("vertical-tab-event_advanced_tab_title").click();
await page.fill('[name="eventName"]', "{Event duration} event btwn {Organiser} {Scheduler}");
await page.locator('[data-testid="update-eventtype"]').click();
await page.waitForResponse("/api/trpc/eventTypes/update?batch=1");
});
await page.goto(`/${user.username}/multiple-duration`);
await page.locator('[data-testid="multiple-choice-30mins"]').waitFor({ state: "visible" });
await test.step("verify default 30min duration is selected", async () => {
const duration30 = page.getByTestId("multiple-choice-30mins");
const activeState = await duration30.getAttribute("data-active");
expect(activeState).toEqual("true");
});
await test.step("book with 90min duration and verify title", async () => {
await page.getByTestId("multiple-choice-90mins").click();
const duration90 = page.getByTestId("multiple-choice-90mins");
const activeState = await duration90.getAttribute("data-active");
expect(activeState).toEqual("true");
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "Test User");
await page.fill('[name="email"]', "test@example.com");
await confirmBooking(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const bookingTitle = page.locator('[data-testid="booking-title"]');
await expect(bookingTitle).toBeVisible();
const titleText = await bookingTitle.textContent();
expect(titleText).toContain("90");
});
});
// eslint-disable-next-line playwright/no-skipped-test
test.skip("it contains the right event details", async ({ page }) => {
const response = await page.goto(`http://acme.cal.local:3000/owner1+member1`);
+1 -1
View File
@@ -76,7 +76,7 @@ Sometimes, yarn install might fail during deployment on Vercel, in which case, y
<Step title="Override the build command">
Override to:
```
cd ../.. && yarn build --include-dependencies --no-deps
cd ../.. && yarn build --no-deps
```
</Step>
<Step title="Hit Deploy">
+2 -2
View File
@@ -33,8 +33,8 @@ RUN set -eux; \
apt-get clean && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives && \
yarn config set httpTimeout 1200000 && \
npx turbo prune --scope=@calcom/web --docker && \
npx turbo prune --scope=@calcom/api --docker && \
npx turbo prune --filter=@calcom/web --docker && \
npx turbo prune --filter=@calcom/api --docker && \
yarn install && \
yarn turbo run build --filter=@calcom/api
+13 -13
View File
@@ -28,23 +28,23 @@
"edit-app-template": "yarn app-store edit-template",
"delete-app-template": "yarn app-store delete-template",
"build": "turbo run build --filter=@calcom/web...",
"build:ai": "turbo run build --scope=\"@calcom/ai\"",
"build:ai": "turbo run build --filter=\"@calcom/ai\"",
"clean": "find . -name node_modules -o -name .next -o -name .turbo -o -name dist -type d -prune | xargs rm -rf",
"db-deploy": "turbo run db-deploy",
"db-seed": "turbo run db-seed",
"db-studio": "yarn prisma studio",
"deploy": "turbo run deploy",
"dev:all": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\" --scope=\"@calcom/console\"",
"dev:ai": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/ai\"",
"dev:api": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\"",
"dev:api:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/console\"",
"dev:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/console\"",
"dev:swagger": "turbo run dev --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/swagger\"",
"dev:website": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\"",
"dev": "turbo run dev --scope=\"@calcom/web\"",
"build-storybook": "turbo run build --scope=\"@calcom/storybook\"",
"dev:all": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/website\" --filter=\"@calcom/console\"",
"dev:ai": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/ai\"",
"dev:api": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\"",
"dev:api:console": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/console\"",
"dev:console": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/console\"",
"dev:swagger": "turbo run dev --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/swagger\"",
"dev:website": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/website\"",
"dev": "turbo run dev --filter=\"@calcom/web\"",
"build-storybook": "turbo run build --filter=\"@calcom/storybook\"",
"dx": "turbo run dx",
"i-dev": "infisical run -- turbo run dev --scope=\"@calcom/web\"",
"i-dev": "infisical run -- turbo run dev --filter=\"@calcom/web\"",
"i-dx": "infisical run -- turbo run dx",
"i-gen-web-example-env": "infisical secrets generate-example-env --tags=web > .env.example",
"i-gen-app-store-example-env": "infisical secrets generate-example-env --tags=appstore > .env.appStore.example",
@@ -61,7 +61,7 @@
"pre-commit": "lint-staged",
"predev": "echo 'Checking env files'",
"prisma": "yarn workspace @calcom/prisma prisma",
"start": "turbo run start --scope=\"@calcom/web\"",
"start": "turbo run start --filter=\"@calcom/web\"",
"tdd": "vitest watch",
"e2e": "NEXT_PUBLIC_IS_E2E=1 yarn playwright test --project=@calcom/web",
"e2e:app-store": "QUICK=true yarn playwright test --project=@calcom/app-store",
@@ -124,7 +124,7 @@
"date-fns-tz": "^3.2.0",
"eslint": "^8.34.0",
"p-limit": "^6.2.0",
"turbo": "^1.10.1"
"turbo": "^2.5.5"
},
"resolutions": {
"types-ramda": "0.29.4",
@@ -661,22 +661,30 @@ export default class Office365CalendarService implements Calendar {
);
};
private handleErrorJsonOffice365Calendar = <Type>(response: Response): Promise<Type | string> => {
private async handleErrorJsonOffice365Calendar<Type>(response: Response): Promise<Type | string> {
if (response.headers.get("content-encoding") === "gzip") {
return response.text();
}
if (response.status === 204) {
return new Promise((resolve) => resolve({} as Type));
return {} as Type;
}
if (!response.ok && response.status < 200 && response.status >= 300) {
response.json().then(console.log);
throw Error(response.statusText);
if (!response.ok) {
let errorBody: string | object;
try {
errorBody = await response.json();
} catch (e) {
errorBody = await response.text();
}
this.log.error(
`handleErrorJsonOffice365Calendar: Office365 API request failed with status ${response.status}`,
errorBody
);
}
return response.json();
};
}
async getMainTimeZone(): Promise<string> {
try {
@@ -55,7 +55,7 @@ export const EventMeta = ({
timeZones,
children,
selectedTimeslot,
roundRobinHideOrgAndTeam
roundRobinHideOrgAndTeam,
}: {
event?: Pick<
BookerEvent,
@@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useShouldShowArrows } from "@calcom/features/apps/components/AllApps";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
@@ -45,7 +45,7 @@ export const EventDuration = ({
const { t } = useLocale();
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
const isPlatform = useIsPlatform();
const [selectedDuration, setSelectedDuration, state] = useBookerStore((state) => [
const [selectedDuration, setSelectedDuration, state] = useBookerStoreContext((state) => [
state.selectedDuration,
state.setSelectedDuration,
state.state,
@@ -550,6 +550,9 @@ async function handler(
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
const bookingRepo = new BookingRepository(prisma);
const requiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
const existingBooking = await bookingRepo.getValidBookingFromEventTypeForAttendee({
eventTypeId,
bookerEmail,
@@ -559,13 +562,20 @@ async function handler(
});
if (existingBooking) {
const hasPayments = existingBooking.payment.length > 0;
const isPaidBooking = existingBooking.paid || !hasPayments;
const shouldShowPaymentForm = requiresPayment && !isPaidBooking;
const firstPayment = shouldShowPaymentForm ? existingBooking.payment[0] : undefined;
const bookingResponse = {
...existingBooking,
user: {
...existingBooking.user,
email: null,
},
paymentRequired: false,
paymentRequired: shouldShowPaymentForm,
seatReferenceUid: "",
};
@@ -574,8 +584,8 @@ async function handler(
luckyUsers: bookingResponse.userId ? [bookingResponse.userId] : [],
isDryRun,
...(isDryRun ? { troubleshooterData } : {}),
paymentUid: undefined,
paymentId: undefined,
paymentUid: firstPayment?.uid,
paymentId: firstPayment?.id,
};
}
}
@@ -7,6 +7,8 @@
*
* They don't intend to test what the apps logic should do, but rather test if the apps are called with the correct data. For testing that, once should write tests within each app.
*/
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
import {
createBookingScenario,
getDate,
@@ -3277,6 +3279,122 @@ describe("handleNewBooking", () => {
},
timeout
);
test(
`Payment retry scenario - should return existing payment UID and prevent duplicate bookings when retrying canceled payment`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential(), getStripeAppCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
title: "Paid Event",
description: "It's a test Paid Event",
slotInterval: 30,
requiresConfirmation: false,
metadata: {
apps: {
stripe: {
price: 100,
enabled: true,
currency: "usd",
},
},
},
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["stripe-payment"]],
});
await createBookingScenario(scenarioData);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const { paymentUid } = mockPaymentApp({
metadataLookupKey: "stripe",
appStoreLookupKey: "stripepayment",
});
mockCalendarToHaveNoBusySlots("googlecalendar");
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const firstBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expect(firstBooking).toEqual(
expect.objectContaining({
paymentUid: expect.any(String),
paymentRequired: true,
uid: expect.any(String),
})
);
const firstBookingInDb = await prismaMock.booking.findUnique({
where: { uid: firstBooking.uid },
include: { payment: true },
});
expect(firstBookingInDb).toBeTruthy();
expect(firstBookingInDb?.payment).toHaveLength(1);
expect(firstBookingInDb?.paid).toBe(false);
const secondBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expect(secondBooking.uid).toBe(firstBooking.uid);
expect(secondBooking.paymentUid).toBe(firstBooking.paymentUid);
expect(secondBooking.paymentRequired).toBe(true);
const bookingsInDb = await prismaMock.booking.findMany({
where: {
eventTypeId: 1,
attendees: {
some: {
email: booker.email,
},
},
},
include: { payment: true },
});
expect(bookingsInDb).toHaveLength(1);
expect(bookingsInDb[0].payment).toHaveLength(1);
expect(bookingsInDb[0].uid).toBe(firstBooking.uid);
},
timeout
);
});
});
@@ -1,11 +1,7 @@
import removeMember from "@calcom/features/ee/teams/lib/removeMember";
import { TeamService } from "@calcom/lib/server/service/teamService";
const removeUserFromOrg = async ({ userId, orgId }: { userId: number; orgId: number }) => {
return removeMember({
memberId: userId,
teamId: orgId,
isOrg: true,
});
return TeamService.removeMembers({ teamIds: [orgId], userIds: [userId], isOrg: true });
};
export default removeUserFromOrg;
@@ -1,141 +0,0 @@
import logger from "@calcom/lib/logger";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import { deleteWorkfowRemindersOfRemovedMember } from "./deleteWorkflowRemindersOfRemovedMember";
const log = logger.getSubLogger({ prefix: ["removeMember"] });
const removeMember = async ({
memberId,
teamId,
isOrg,
}: {
memberId: number;
teamId: number;
isOrg: boolean;
}) => {
const [membership] = await prisma.$transaction([
prisma.membership.delete({
where: {
userId_teamId: { userId: memberId, teamId: teamId },
},
include: {
user: true,
team: true,
},
}),
// remove user as host from team events associated with this membership
prisma.host.deleteMany({
where: {
userId: memberId,
eventType: {
teamId: teamId,
},
},
}),
]);
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
organizationSettings: true,
id: true,
metadata: true,
activeOrgWorkflows: true,
parentId: true,
},
});
const foundUser = await prisma.user.findUnique({
where: { id: memberId },
select: {
id: true,
movedToProfileId: true,
email: true,
username: true,
completedOnboarding: true,
teams: {
select: {
team: {
select: {
id: true,
parentId: true,
},
},
},
},
},
});
if (!team || !foundUser) throw new TRPCError({ code: "NOT_FOUND" });
if (isOrg) {
log.debug("Removing a member from the organization");
// Deleting membership from all child teams
// Delete all sub-team memberships where this team is the organization
await prisma.membership.deleteMany({
where: {
team: {
parentId: teamId,
},
userId: membership.userId,
},
});
const userToDeleteMembershipOf = foundUser;
const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({
userId: userToDeleteMembershipOf.id,
organizationId: team.id,
});
if (
userToDeleteMembershipOf.username &&
userToDeleteMembershipOf.movedToProfileId === profileToDelete?.id
) {
log.debug("Cleaning up tempOrgRedirect for user", userToDeleteMembershipOf.username);
await prisma.tempOrgRedirect.deleteMany({
where: {
from: userToDeleteMembershipOf.username,
},
});
}
await prisma.$transaction([
prisma.user.update({
where: { id: membership.userId },
data: { organizationId: null },
}),
ProfileRepository.delete({
userId: membership.userId,
organizationId: team.id,
}),
prisma.host.deleteMany({
where: {
userId: memberId,
eventType: {
team: {
parentId: teamId,
},
},
},
}),
]);
}
// Deleted managed event types from this team from this member
await prisma.eventType.deleteMany({
where: { parent: { teamId: teamId }, userId: membership.userId },
});
await deleteWorkfowRemindersOfRemovedMember(team, memberId, isOrg);
return { membership };
};
export default removeMember;
@@ -109,6 +109,15 @@ function WorkflowPage({ workflow: workflowId }: PageProps) {
const isPending = isPendingWorkflow || isPendingEventTypes;
useEffect(() => {
requestAnimationFrame(() => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
});
}, [isPending]);
useEffect(() => {
if (!isPending) {
setFormData(workflow);
@@ -0,0 +1,143 @@
import { ErrorMessage } from "@hookform/error-message";
import { useFormContext, Controller } from "react-hook-form";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import type { CalVideoSettings as CalVideoSettingsType } from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { UpgradeTeamsBadge } from "@calcom/ui/components/badge";
import { TextField } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import LocationSettingsContainer from "./LocationSettingsContainer";
const CalVideoSettings = ({ calVideoSettings }: { calVideoSettings?: CalVideoSettingsType }) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const isPlatform = useIsPlatform();
return (
<LocationSettingsContainer>
<Controller
name="calVideoSettings.disableRecordingForGuests"
defaultValue={!!calVideoSettings?.disableRecordingForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_recording_for_guests")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
<Controller
name="calVideoSettings.disableRecordingForOrganizer"
defaultValue={!!calVideoSettings?.disableRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_recording_for_organizer")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
{!isPlatform && (
<Controller
name="calVideoSettings.enableAutomaticRecordingForOrganizer"
defaultValue={!!calVideoSettings?.enableAutomaticRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("enable_automatic_recording")}
labelClassName="text-sm"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
)}
<Controller
name="calVideoSettings.enableAutomaticTranscription"
defaultValue={!!calVideoSettings?.enableAutomaticTranscription}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("enable_automatic_transcription")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForGuests"
defaultValue={!!calVideoSettings?.disableTranscriptionForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_guests")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
)}
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForOrganizer"
defaultValue={!!calVideoSettings?.disableTranscriptionForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_organizer")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
)}
<TextField
label={t("enter_redirect_url_on_exit_description")}
defaultValue={calVideoSettings?.redirectUrlOnExit || ""}
data-testid="calVideoSettings.redirectUrlOnExit"
containerClassName="mt-4"
className="leading-6"
{...formMethods.register("calVideoSettings.redirectUrlOnExit", {
setValueAs: (v) => (!v || v.trim() === "" ? null : v),
})}
/>
<ErrorMessage
errors={formMethods.formState.errors?.calVideoSettings}
name="redirectUrlOnExit"
className={classNames("text-error text-sm")}
as="div"
id="calVideoSettings.redirectUrlOnExit-error"
/>
</LocationSettingsContainer>
);
};
export default CalVideoSettings;
@@ -0,0 +1,11 @@
const LocationSettingsContainer = ({ children }: { children: React.ReactNode }) => {
return (
<div className="bg-muted mt-2 space-y-2 rounded-lg p-4">
<div className="w-full">
<div className="flex flex-col gap-4">{children}</div>
</div>
</div>
);
};
export default LocationSettingsContainer;
@@ -25,14 +25,13 @@ import ServerTrans from "@calcom/lib/components/ServerTrans";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { UpgradeTeamsBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { TextField } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Input } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import CalVideoSettings from "./CalVideoSettings";
export type TEventTypeLocation = Pick<EventTypeSetupProps["eventType"], "locations" | "calVideoSettings">;
export type TLocationOptions = Pick<EventTypeSetupProps, "locationOptions">["locationOptions"];
export type TDestinationCalendar = { integration: string } | null;
@@ -342,132 +341,7 @@ const Locations: React.FC<LocationsProps> = ({
)}
</div>
{isCalVideo && !isPlatform && (
<div className="bg-muted mt-2 space-y-2 rounded-lg p-4">
<div className="w-full">
<div className="flex flex-col gap-4">
<Controller
name="calVideoSettings.disableRecordingForGuests"
defaultValue={!!eventType.calVideoSettings?.disableRecordingForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_recording_for_guests")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
<Controller
name="calVideoSettings.disableRecordingForOrganizer"
defaultValue={!!eventType.calVideoSettings?.disableRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_recording_for_organizer")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
{!isPlatform && (
<Controller
name="calVideoSettings.enableAutomaticRecordingForOrganizer"
defaultValue={!!eventType.calVideoSettings?.enableAutomaticRecordingForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("enable_automatic_recording")}
labelClassName="text-sm"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
)}
<Controller
name="calVideoSettings.enableAutomaticTranscription"
defaultValue={!!eventType.calVideoSettings?.enableAutomaticTranscription}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("enable_automatic_transcription")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForGuests"
defaultValue={!!eventType.calVideoSettings?.disableTranscriptionForGuests}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_guests")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
)}
{!isPlatform && (
<Controller
name="calVideoSettings.disableTranscriptionForOrganizer"
defaultValue={!!eventType.calVideoSettings?.disableTranscriptionForOrganizer}
render={({ field: { onChange, value } }) => {
return (
<SettingsToggle
title={t("disable_transcription_for_organizer")}
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
Badge={<UpgradeTeamsBadge checkForActiveStatus />}
/>
);
}}
/>
)}
<TextField
label={t("enter_redirect_url_on_exit_description")}
defaultValue={eventType.calVideoSettings?.redirectUrlOnExit || ""}
data-testid="calVideoSettings.redirectUrlOnExit"
containerClassName="mt-4"
className="leading-6"
{...formMethods.register("calVideoSettings.redirectUrlOnExit", {
setValueAs: (v) => (!v || v.trim() === "" ? null : v),
})}
/>
<ErrorMessage
errors={formMethods.formState.errors?.calVideoSettings}
name="redirectUrlOnExit"
className={classNames("text-error text-sm")}
as="div"
id="calVideoSettings.redirectUrlOnExit-error"
/>
</div>
</div>
</div>
)}
{isCalVideo && !isPlatform && <CalVideoSettings />}
{eventLocationType?.organizerInputType && (
<div className="mt-2 space-y-2">
@@ -5,8 +5,6 @@ import type { MultiValue } from "react-select";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { LocationCustomClassNames } from "@calcom/features/eventtypes/components/Locations";
import Locations from "@calcom/features/eventtypes/components/Locations";
import type {
EventTypeSetupProps,
InputClassNames,
@@ -28,6 +26,9 @@ import { Select } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Skeleton } from "@calcom/ui/components/skeleton";
import type { LocationCustomClassNames } from "../../locations/Locations";
import Locations from "../../locations/Locations";
export type EventSetupTabCustomClassNames = {
wrapper?: string;
titleSection?: {
@@ -153,6 +153,17 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => {
hidden: true,
assignAllTeamMembers: true,
rescheduleWithSameRoundRobinHost: true,
parent: {
select: {
team: {
select: {
theme: true,
brandColor: true,
darkBrandColor: true,
},
},
},
},
} satisfies Prisma.EventTypeSelect;
};
@@ -866,10 +877,11 @@ type GetProfileFromEventInput = Omit<Event, "hosts"> & {
export function getProfileFromEvent(event: GetProfileFromEventInput) {
const { team, subsetOfHosts: hosts, owner } = event;
const nonTeamprofile = hosts?.[0]?.user || owner;
const profile = team || nonTeamprofile;
const nonTeamProfile = hosts?.[0]?.user || owner;
const profile = team || nonTeamProfile;
if (!profile) throw new Error("Event has no owner");
const styleProfile = team || event.parent?.team || nonTeamProfile;
const username = "username" in profile ? profile.username : team?.slug;
const weekStart = hosts?.[0]?.user?.weekStart || owner?.weekStart || "Monday";
const eventMetaData = eventTypeMetaDataSchemaWithTypedApps.parse(event.metadata || {});
@@ -882,11 +894,11 @@ export function getProfileFromEvent(event: GetProfileFromEventInput) {
image: team
? getOrgOrTeamAvatar(team)
: getUserAvatarUrl({
avatarUrl: nonTeamprofile?.avatarUrl,
avatarUrl: nonTeamProfile?.avatarUrl,
}),
brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor,
theme: profile.theme,
brandColor: styleProfile.brandColor,
darkBrandColor: styleProfile.darkBrandColor,
theme: styleProfile.theme,
bookerLayouts: bookerLayoutsSchema.parse(
eventMetaData?.bookerLayouts ||
(userMetaData && "defaultBookerLayouts" in userMetaData ? userMetaData.defaultBookerLayouts : null)
+11 -9
View File
@@ -168,15 +168,7 @@ export type FormValues = {
restrictionScheduleId: number | null;
useBookerTimezone: boolean;
restrictionScheduleName: string | null;
calVideoSettings?: {
disableRecordingForOrganizer?: boolean;
disableRecordingForGuests?: boolean;
enableAutomaticTranscription?: boolean;
enableAutomaticRecordingForOrganizer?: boolean;
disableTranscriptionForGuests?: boolean;
disableTranscriptionForOrganizer?: boolean;
redirectUrlOnExit?: string;
};
calVideoSettings?: CalVideoSettings;
maxActiveBookingPerBookerOfferReschedule: boolean;
};
@@ -243,3 +235,13 @@ export interface EventTypePlatformWrapperRef {
validateForm: () => Promise<FormValidationResult>;
handleFormSubmit: (callbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) => void;
}
export interface CalVideoSettings {
disableRecordingForOrganizer?: boolean;
disableRecordingForGuests?: boolean;
enableAutomaticTranscription?: boolean;
enableAutomaticRecordingForOrganizer?: boolean;
disableTranscriptionForGuests?: boolean;
disableTranscriptionForOrganizer?: boolean;
redirectUrlOnExit?: string;
}
@@ -110,13 +110,15 @@ describe("RoleManagementFactory", () => {
it("should allow role change when user has permission", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).resolves.not.toThrow();
await expect(
manager.checkPermissionToChangeRole(userId, organizationId, "org")
).resolves.not.toThrow();
});
it("should throw UNAUTHORIZED when user lacks permission", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(
await expect(manager.checkPermissionToChangeRole(userId, organizationId, "org")).rejects.toThrow(
new RoleManagementError(
"You do not have permission to change roles",
RoleManagementErrorCode.UNAUTHORIZED
@@ -193,13 +195,15 @@ describe("RoleManagementFactory", () => {
customRoleId: null,
});
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).resolves.not.toThrow();
await expect(
manager.checkPermissionToChangeRole(userId, organizationId, "org")
).resolves.not.toThrow();
});
it("should throw UNAUTHORIZED when user is not owner", async () => {
vi.mocked(isOrganisationAdmin).mockResolvedValue(false);
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(
await expect(manager.checkPermissionToChangeRole(userId, organizationId, "org")).rejects.toThrow(
new RoleManagementError(
"Only owners or admin can update roles",
RoleManagementErrorCode.UNAUTHORIZED
@@ -1,5 +1,6 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -10,7 +11,7 @@ import { RoleService } from "./role.service";
interface IRoleManager {
isPBACEnabled: boolean;
checkPermissionToChangeRole(userId: number, organizationId: number): Promise<void>;
checkPermissionToChangeRole(userId: number, targetId: number, scope: "org" | "team"): Promise<void>;
assignRole(
userId: number,
organizationId: number,
@@ -29,11 +30,11 @@ class PBACRoleManager implements IRoleManager {
private readonly permissionCheckService: PermissionCheckService
) {}
async checkPermissionToChangeRole(userId: number, organizationId: number): Promise<void> {
async checkPermissionToChangeRole(userId: number, targetId: number, scope: "org" | "team"): Promise<void> {
const hasPermission = await this.permissionCheckService.checkPermission({
userId,
teamId: organizationId,
permission: "organization.changeMemberRole",
teamId: targetId,
permission: scope === "team" ? "team.changeMemberRole" : "organization.changeMemberRole",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
@@ -95,9 +96,11 @@ class PBACRoleManager implements IRoleManager {
class LegacyRoleManager implements IRoleManager {
public isPBACEnabled = false;
async checkPermissionToChangeRole(userId: number, organizationId: number): Promise<void> {
const membership = await isOrganisationAdmin(userId, organizationId);
async checkPermissionToChangeRole(userId: number, targetId: number, scope: "org" | "team"): Promise<void> {
const membership =
scope === "team"
? !!(await isTeamAdmin(userId, targetId))
: !!(await isOrganisationAdmin(userId, targetId));
// Only OWNER/ADMIN can update role
if (!membership) {
throw new RoleManagementError(
@@ -3,6 +3,7 @@ import { useForm } from "react-hook-form";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { BookerStoreProvider } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { yyyymmdd } from "@calcom/lib/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -136,19 +137,21 @@ const DateOverrideForm = ({
className="p-6 sm:flex sm:p-0 xl:flex-row">
<div className="sm:border-subtle w-full sm:border-r sm:p-4 sm:pr-6 md:p-8">
<DialogHeader title={t("date_overrides_dialog_title")} />
<DatePicker
excludedDates={excludedDates}
weekStart={weekStart}
selected={selectedDates}
onChange={(day) => {
if (day) onDateChange(day);
}}
onMonthChange={(newMonth) => {
setBrowsingDate(newMonth);
}}
browsingDate={browsingDate}
locale={isLocaleReady ? i18n.language : "en"}
/>
<BookerStoreProvider>
<DatePicker
excludedDates={excludedDates}
weekStart={weekStart}
selected={selectedDates}
onChange={(day) => {
if (day) onDateChange(day);
}}
onMonthChange={(newMonth) => {
setBrowsingDate(newMonth);
}}
browsingDate={browsingDate}
locale={isLocaleReady ? i18n.language : "en"}
/>
</BookerStoreProvider>
</div>
<div className="relative mt-8 flex w-full flex-col sm:mt-0 sm:p-4 md:p-8">
{selectedDates[0] ? (
+2 -1
View File
@@ -45,7 +45,8 @@ export const IS_CALCOM =
(new URL(WEBAPP_URL).hostname.endsWith("cal.com") ||
new URL(WEBAPP_URL).hostname.endsWith("cal.dev") ||
new URL(WEBAPP_URL).hostname.endsWith("cal.qa") ||
new URL(WEBAPP_URL).hostname.endsWith("cal-staging.com"));
new URL(WEBAPP_URL).hostname.endsWith("cal-staging.com") ||
new URL(WEBAPP_URL).hostname.endsWith("cal.eu"));
export const CONSOLE_URL =
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") ||
@@ -213,21 +213,6 @@ describe("getAllDelegationCredentialsForUserIncludeServiceAccountKey", () => {
buildDelegationCredentialGoogleMeetCredential(),
]);
});
it("should return empty array for non-Google platforms(as they are not supported yet)", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(
buildMockDelegationCredential({
workspacePlatform: {
name: "Microsoft",
slug: "microsoft",
},
})
);
const result = await getAllDelegationCredentialsForUserIncludeServiceAccountKey({ user: mockUser });
expect(result).toEqual([]);
});
});
describe("buildAllCredentials", () => {
@@ -5,8 +5,8 @@ import type {
InsightsRoutingServicePublicOptions,
InsightsRoutingServiceFilterOptions,
InsightsRoutingBaseService,
} from "@calcom/lib/server/service/insightsRoutingBase";
import type { InsightsRoutingService } from "@calcom/lib/server/service/insightsRoutingDI";
} from "@calcom/lib/server/service/InsightsRoutingBaseService";
import type { InsightsRoutingService } from "@calcom/lib/server/service/InsightsRoutingDIService";
import { prismaModule } from "@calcom/prisma/prisma.module";
import { insightsRoutingModule } from "../modules/InsightsRouting";
+2 -2
View File
@@ -1,7 +1,7 @@
import { createModule } from "@evyweb/ioctopus";
import type { IInsightsRoutingService } from "@calcom/lib/server/service/insightsRoutingDI";
import { InsightsRoutingService } from "@calcom/lib/server/service/insightsRoutingDI";
import type { IInsightsRoutingService } from "@calcom/lib/server/service/InsightsRoutingDIService";
import { InsightsRoutingService } from "@calcom/lib/server/service/InsightsRoutingDIService";
import { DI_TOKENS } from "../tokens";
+8
View File
@@ -0,0 +1,8 @@
import { WEBAPP_URL, IS_CALCOM } from "./constants";
export const getCalcomUrl = () => {
if (IS_CALCOM) {
return new URL(WEBAPP_URL).hostname.endsWith("cal.eu") ? "https://cal.eu" : "https://cal.com";
}
return WEBAPP_URL;
};
@@ -1,14 +1,16 @@
import prisma from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
export class PrismaAttributeRepository {
static async findManyByNamesAndOrgIdIncludeOptions({
constructor(private prismaClient: PrismaClient) {}
async findManyByNamesAndOrgIdIncludeOptions({
attributeNames,
orgId,
}: {
attributeNames: string[];
orgId: number;
}) {
return prisma.attribute.findMany({
return this.prismaClient.attribute.findMany({
where: {
name: { in: attributeNames, mode: "insensitive" },
teamId: orgId,
@@ -25,9 +27,9 @@ export class PrismaAttributeRepository {
});
}
static async findManyByOrgId({ orgId }: { orgId: number }) {
async findManyByOrgId({ orgId }: { orgId: number }) {
// It should be a faster query because of lesser number of attributes record and index on teamId
const result = await prisma.attribute.findMany({
const result = await this.prismaClient.attribute.findMany({
where: {
teamId: orgId,
},
@@ -43,8 +45,8 @@ export class PrismaAttributeRepository {
return result;
}
static async findAllByOrgIdWithOptions({ orgId }: { orgId: number }) {
return await prisma.attribute.findMany({
async findAllByOrgIdWithOptions({ orgId }: { orgId: number }) {
return await this.prismaClient.attribute.findMany({
where: {
teamId: orgId,
},
@@ -828,6 +828,7 @@ export class BookingRepository {
attendees: true,
references: true,
user: true,
payment: true,
},
});
}
+5 -3
View File
@@ -1,8 +1,10 @@
import prisma from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
export class HostRepository {
static async updateHostsSchedule(userId: number, oldScheduleId: number, newScheduleId: number) {
return await prisma.host.updateMany({
constructor(private prismaClient: PrismaClient) {}
async updateHostsSchedule(userId: number, oldScheduleId: number, newScheduleId: number) {
return await this.prismaClient.host.updateMany({
where: {
userId,
scheduleId: oldScheduleId,
@@ -4,7 +4,7 @@ import {
InsightsRoutingBaseService,
type InsightsRoutingServicePublicOptions,
type InsightsRoutingServiceFilterOptions,
} from "./insightsRoutingBase";
} from "./InsightsRoutingBaseService";
export interface IInsightsRoutingService {
prisma: typeof readonlyPrisma;
@@ -7,7 +7,7 @@ import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsRoutingBaseService as InsightsRoutingService } from "../../service/insightsRoutingBase";
import { InsightsRoutingBaseService as InsightsRoutingService } from "../../service/InsightsRoutingBaseService";
// SQL condition constants for testing
const NOTHING_CONDITION = Prisma.sql`1=0`;
File diff suppressed because it is too large Load Diff
+263 -6
View File
@@ -1,16 +1,52 @@
import { Prisma } from "@prisma/client";
import { TeamBilling } from "@calcom/features/ee/billing/teams";
import removeMember from "@calcom/features/ee/teams/lib/removeMember";
import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember";
import { deleteDomain } from "@calcom/lib/domainManager/organization";
import logger from "@calcom/lib/logger";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import prisma from "@calcom/prisma";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
const log = logger.getSubLogger({ prefix: ["TeamService"] });
type MembershipWithRelations = Pick<
Membership,
"id" | "userId" | "teamId" | "role" | "accepted" | "disableImpersonation"
>;
type TeamWithSettings = {
id: number;
isOrganization: boolean | null;
organizationSettings: unknown;
metadata: unknown;
activeOrgWorkflows: unknown;
parentId: number | null;
};
type UserWithTeams = {
id: number;
movedToProfileId: number | null;
email: string;
username: string | null;
completedOnboarding: boolean;
teams: {
team: {
id: number;
parentId: number | null;
};
}[];
};
export type RemoveMemberResult = {
membership: MembershipWithRelations;
};
export class TeamService {
/**
* Deletes a team and all its associated data in a safe, transactional order.
@@ -45,15 +81,23 @@ export class TeamService {
return deletedTeam;
}
static async removeMembers(teamIds: number[], memberIds: number[], isOrg = false) {
const deleteMembershipPromises = [];
static async removeMembers({
teamIds,
userIds,
isOrg = false,
}: {
teamIds: number[];
userIds: number[];
isOrg?: boolean;
}) {
const deleteMembershipPromises: Promise<RemoveMemberResult>[] = [];
for (const memberId of memberIds) {
for (const userId of userIds) {
for (const teamId of teamIds) {
deleteMembershipPromises.push(
removeMember({
TeamService.removeMember({
teamId,
memberId,
userId,
isOrg,
})
);
@@ -121,4 +165,217 @@ export class TeamService {
const teamBilling = await TeamBilling.findAndInit(teamId);
return teamBilling.publish();
}
private static async removeMember({
userId,
teamId,
isOrg,
}: {
userId: number;
teamId: number;
isOrg: boolean;
}) {
const membership = await TeamService.fetchMembershipOrThrow(userId, teamId);
const team = await TeamService.fetchTeamOrThrow(teamId);
const user = await TeamService.fetchUserOrThrow(userId);
if (isOrg) {
log.debug("Removing a member from the organization");
await TeamService.removeFromOrganization(membership, team, user);
} else {
log.debug("Removing a member from a team");
await TeamService.removeFromTeam(membership, teamId);
}
await deleteWorkfowRemindersOfRemovedMember(team, userId, isOrg);
return { membership };
}
// TODO: Needs to be moved to repository
private static async fetchMembershipOrThrow(
userId: number,
teamId: number
): Promise<MembershipWithRelations> {
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: { userId: userId, teamId: teamId },
},
select: {
id: true,
userId: true,
teamId: true,
role: true,
accepted: true,
disableImpersonation: true,
},
});
if (!membership) {
throw new TRPCError({ code: "NOT_FOUND", message: "Membership not found" });
}
return membership;
}
// TODO: Needs to be moved to repository
private static async fetchTeamOrThrow(teamId: number): Promise<TeamWithSettings> {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
organizationSettings: true,
id: true,
metadata: true,
activeOrgWorkflows: true,
parentId: true,
},
});
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
}
return team;
}
// TODO: Needs to be moved to repository
private static async fetchUserOrThrow(userId: number): Promise<UserWithTeams> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
movedToProfileId: true,
email: true,
username: true,
completedOnboarding: true,
teams: {
select: {
team: {
select: {
id: true,
parentId: true,
},
},
},
},
},
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
return user;
}
// TODO: Needs to be moved to repository
private static async cleanupTempOrgRedirect(user: UserWithTeams, team: TeamWithSettings) {
const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({
userId: user.id,
organizationId: team.id,
});
if (user.username && user.movedToProfileId === profileToDelete?.id) {
log.debug("Cleaning up tempOrgRedirect for user", user.username);
await prisma.tempOrgRedirect.deleteMany({
where: {
from: user.username,
},
});
}
}
private static async removeFromOrganization(
membership: MembershipWithRelations,
team: TeamWithSettings,
user: UserWithTeams
) {
await TeamService.cleanupTempOrgRedirect(user, team);
const newUsername = generateNewUsername(user);
await prisma.$transaction([
// Remove user from all sub-teams event type hosts
prisma.host.deleteMany({
where: {
userId: membership.userId,
eventType: {
team: {
parentId: team.id,
},
},
},
}),
// Delete managed child events in sub-teams
prisma.eventType.deleteMany({
where: {
userId: membership.userId,
parent: {
team: {
parentId: team.id,
},
},
},
}),
// Remove organizationId from the user
prisma.user.update({
where: { id: membership.userId },
data: {
organizationId: null,
username: newUsername,
},
}),
// Delete the profile of the user from the organization
ProfileRepository.delete({
userId: membership.userId,
organizationId: team.id,
}),
// Delete all sub-team memberships where this team is the organization
prisma.membership.deleteMany({
where: {
team: {
parentId: team.id,
},
userId: membership.userId,
},
}),
// Delete the membership of the user from the organization
prisma.membership.delete({
where: {
userId_teamId: { userId: membership.userId, teamId: team.id },
},
}),
]);
// Generate new username for user leaving organization
function generateNewUsername(user: UserWithTeams): string | null {
// We ensure that new username would be unique across all users in the global namespace outside any organization
return user.username != null ? `${user.username}-${user.id}` : null;
}
}
// Remove member from regular team
private static async removeFromTeam(membership: MembershipWithRelations, teamId: number) {
await prisma.$transaction([
// Remove user from all team event types' hosts
prisma.host.deleteMany({
where: {
userId: membership.userId,
eventType: {
teamId: teamId,
},
},
}),
// Deleted managed event types from this team for this member
prisma.eventType.deleteMany({
where: { parent: { teamId: teamId }, userId: membership.userId },
}),
// Delete the membership of the user from the team
prisma.membership.delete({
where: {
userId_teamId: { userId: membership.userId, teamId: teamId },
},
}),
]);
}
}
@@ -37,7 +37,8 @@ const findAttributesByName = async ({
orgId: number;
attributeNames: AttributeName[];
}) => {
const attributesFromDb = await PrismaAttributeRepository.findManyByNamesAndOrgIdIncludeOptions({
const attributeRepo = new PrismaAttributeRepository(prisma);
const attributesFromDb = await attributeRepo.findManyByNamesAndOrgIdIncludeOptions({
attributeNames,
orgId,
});
@@ -216,9 +216,11 @@ async function _getOrgMembershipToUserIdForTeam({ orgId, teamId }: { orgId: numb
}
async function _queryAllData({ orgId, teamId }: { orgId: number; teamId: number }) {
const attributeRepo = new PrismaAttributeRepository(prisma);
const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] = await Promise.all([
_getOrgMembershipToUserIdForTeam({ orgId, teamId }),
PrismaAttributeRepository.findManyByOrgId({ orgId }),
attributeRepo.findManyByOrgId({ orgId }),
]);
const orgMembershipIds = Array.from(orgMembershipToUserIdForTeamMembers.keys());
@@ -124,6 +124,7 @@ type AvailabilitySettingsProps = {
isEventTypesFetching?: boolean;
handleBulkEditDialogToggle: () => void;
};
callbacksRef?: React.MutableRefObject<{ onSuccess?: () => void; onError?: (error: Error) => void }>;
};
const DeleteDialogButton = ({
@@ -306,6 +307,7 @@ export const AvailabilitySettings = forwardRef<AvailabilitySettingsFormRef, Avai
bulkUpdateModalProps,
allowSetToDefault = true,
allowDelete = true,
callbacksRef,
} = props;
const [openSidebar, setOpenSidebar] = useState(false);
const { t, i18n } = useLocale();
@@ -336,11 +338,9 @@ export const AvailabilitySettings = forwardRef<AvailabilitySettingsFormRef, Avai
const saveButtonRef = useRef<HTMLButtonElement>(null);
const callbacksRef = useRef<{ onSuccess?: () => void; onError?: (error: Error) => void }>({});
const handleFormSubmit = useCallback(
(customCallbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) => {
if (customCallbacks) {
if (callbacksRef && customCallbacks) {
callbacksRef.current = customCallbacks;
}
@@ -350,14 +350,14 @@ export const AvailabilitySettings = forwardRef<AvailabilitySettingsFormRef, Avai
form.handleSubmit(async (data) => {
try {
await handleSubmit(data);
callbacksRef.current?.onSuccess?.();
callbacksRef?.current?.onSuccess?.();
} catch (error) {
callbacksRef.current?.onError?.(error as Error);
callbacksRef?.current?.onError?.(error as Error);
}
})();
}
},
[form, handleSubmit]
[form, handleSubmit, callbacksRef]
);
const validateForm = useCallback(async () => {
@@ -39,7 +39,7 @@ export type AvailabilitySettingsPlatformWrapperProps = {
};
export const AvailabilitySettingsPlatformWrapper = forwardRef<
AvailabilitySettingsFormRef,
AvailabilitySettingsFormRef,
AvailabilitySettingsPlatformWrapperProps
>(function AvailabilitySettingsPlatformWrapper(props, ref) {
const {
@@ -202,6 +202,7 @@ AvailabilitySettingsFormRef,
allowDelete={allowDelete}
allowSetToDefault={allowSetToDefault}
onFormStateChange={onFormStateChange}
callbacksRef={callbacksRef}
/>
</AtomsWrapper>
);
+1
View File
@@ -120,4 +120,5 @@ export { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
export { verifyPhoneNumber, sendVerificationCode };
export { TeamService } from "@calcom/lib/server/service/teamService";
export { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
@@ -446,6 +446,11 @@ class BaseCreateEventTypeInput {
type: CalVideoSettings,
})
calVideoSettings?: CalVideoSettings;
@IsOptional()
@IsBoolean()
@DocsPropertyOptional()
hidden?: boolean;
}
export class CreateEventTypeInput_2024_06_14 extends BaseCreateEventTypeInput {
@IsOptional()
@@ -414,6 +414,11 @@ class BaseUpdateEventTypeInput {
type: CalVideoSettings,
})
calVideoSettings?: CalVideoSettings;
@IsOptional()
@IsBoolean()
@DocsPropertyOptional()
hidden?: boolean;
}
export class UpdateEventTypeInput_2024_06_14 extends BaseUpdateEventTypeInput {
@IsOptional()
@@ -439,6 +439,11 @@ class BaseEventTypeOutput_2024_06_14 {
type: CalVideoSettings,
})
calVideoSettings?: CalVideoSettings | null;
@IsOptional()
@IsBoolean()
@DocsProperty()
hidden?: boolean;
}
export class TeamEventTypeResponseHost extends TeamEventTypeHostInput {
Binary file not shown.
+3
View File
@@ -90,6 +90,9 @@ type OmitPrismaClient = Omit<
// we cant pass tx to functions as types miss match since we have a custom prisma client https://github.com/prisma/prisma/discussions/20924#discussioncomment-10077649
export type PrismaTransaction = OmitPrismaClient;
/**
* @deprecated Use named export `prisma` instead
*/
export default prisma;
export * from "./selects";
@@ -1,4 +1,5 @@
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
@@ -19,8 +20,9 @@ const listHandler = async (opts: GetOptions) => {
message: "You need to be apart of an organization to use this feature",
});
}
const attributeRepo = new PrismaAttributeRepository(prisma);
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: org.id });
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: org.id });
};
export default listHandler;
@@ -1,9 +1,9 @@
import { HostRepository } from "@calcom/lib/server/repository/host";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../../types";
import { updateHostsWithNewDefaultSchedule } from "../util";
import type { TDeleteInputSchema } from "./delete.schema";
type DeleteOptions = {
@@ -15,6 +15,7 @@ type DeleteOptions = {
export const deleteHandler = async ({ input, ctx }: DeleteOptions) => {
const { user } = ctx;
const hostRepo = new HostRepository(prisma);
const scheduleToDelete = await prisma.schedule.findUnique({
where: {
@@ -46,7 +47,7 @@ export const deleteHandler = async ({ input, ctx }: DeleteOptions) => {
// to throw the error if there arent any other schedules
if (!scheduleToSetAsDefault) throw new TRPCError({ code: "BAD_REQUEST" });
await updateHostsWithNewDefaultSchedule(user.id, input.scheduleId, scheduleToSetAsDefault.id);
await hostRepo.updateHostsSchedule(user.id, input.scheduleId, scheduleToSetAsDefault.id);
await prisma.user.update({
where: {
@@ -57,7 +58,7 @@ export const deleteHandler = async ({ input, ctx }: DeleteOptions) => {
},
});
} else if (user.defaultScheduleId) {
await updateHostsWithNewDefaultSchedule(user.id, input.scheduleId, user.defaultScheduleId);
await hostRepo.updateHostsSchedule(user.id, input.scheduleId, user.defaultScheduleId);
}
await prisma.schedule.delete({
@@ -1,13 +1,9 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { HostRepository } from "@calcom/lib/server/repository/host";
import { PrismaClient } from "@calcom/prisma";
import {
getDefaultScheduleId,
hasDefaultSchedule,
setupDefaultSchedule,
updateHostsWithNewDefaultSchedule,
} from "./util";
import { getDefaultScheduleId, hasDefaultSchedule, setupDefaultSchedule } from "./util";
vi.mock("@calcom/prisma", () => {
const mockPrisma = {
@@ -31,9 +27,11 @@ vi.mock("@calcom/prisma", () => {
describe("Availability Utils", () => {
let prisma: PrismaClient;
let hostRepo: HostRepository;
beforeEach(() => {
prisma = new PrismaClient();
hostRepo = new HostRepository(prisma);
vi.clearAllMocks();
});
@@ -158,7 +156,7 @@ describe("Availability Utils", () => {
(prisma.host.updateMany as any).mockResolvedValue(updateResult);
const result = await updateHostsWithNewDefaultSchedule(userId, oldScheduleId, newScheduleId, prisma);
const result = await hostRepo.updateHostsSchedule(userId, oldScheduleId, newScheduleId);
expect(prisma.host.updateMany).toHaveBeenCalledWith({
where: {
@@ -180,7 +178,7 @@ describe("Availability Utils", () => {
(prisma.host.updateMany as any).mockResolvedValue(updateResult);
const result = await updateHostsWithNewDefaultSchedule(userId, oldScheduleId, newScheduleId, prisma);
const result = await hostRepo.updateHostsSchedule(userId, oldScheduleId, newScheduleId);
expect(prisma.host.updateMany).toHaveBeenCalledWith({
where: {
@@ -1,6 +1,5 @@
import type { User } from "@prisma/client";
import { HostRepository } from "@calcom/lib/server/repository/host";
import type { PrismaClient } from "@calcom/prisma";
export const getDefaultScheduleId = async (userId: number, prisma: PrismaClient) => {
@@ -54,11 +53,3 @@ export const setupDefaultSchedule = async (userId: number, scheduleId: number, p
},
});
};
export const updateHostsWithNewDefaultSchedule = async (
userId: number,
defaultScheduleId: number,
scheduleId: number
) => {
return await HostRepository.updateHostsSchedule(userId, defaultScheduleId, scheduleId);
};
@@ -440,5 +440,63 @@ const checkIfUserIsAuthorizedToConfirmBooking = async ({
if (membership) return;
}
if (bookingUserId && (await isLoggedInUserOrgAdminOfBookingUser(loggedInUserId, bookingUserId))) {
return;
}
throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not authorized to confirm this booking" });
};
async function isLoggedInUserOrgAdminOfBookingUser(loggedInUserId: number, bookingUserId: number) {
const orgIdsWhereLoggedInUserAdmin = await getOrgIdsWhereAdmin(loggedInUserId);
if (orgIdsWhereLoggedInUserAdmin.length === 0) {
return false;
}
const bookingUserOrgMembership = await prisma.membership.findFirst({
where: {
userId: bookingUserId,
teamId: {
in: orgIdsWhereLoggedInUserAdmin,
},
team: {
parentId: null,
},
},
});
if (bookingUserOrgMembership) return true;
const bookingUserOrgTeamMembership = await prisma.membership.findFirst({
where: {
userId: bookingUserId,
team: {
parentId: {
in: orgIdsWhereLoggedInUserAdmin,
},
},
},
});
return !!bookingUserOrgTeamMembership;
}
async function getOrgIdsWhereAdmin(loggedInUserId: number) {
const loggedInUserOrgMemberships = await prisma.membership.findMany({
where: {
userId: loggedInUserId,
role: {
in: [MembershipRole.OWNER, MembershipRole.ADMIN],
},
team: {
parentId: null,
},
},
select: {
teamId: true,
},
});
return loggedInUserOrgMemberships.map((m) => m.teamId);
}
@@ -80,7 +80,7 @@ export async function toggleDelegationCredentialEnabled(
if (slug === "google") {
calendarAppName = "Google Calendar";
conferencingAppName = "Google Meet";
} else if (slug === "microsoft") {
} else if (slug === "office365") {
calendarAppName = "Microsoft 365";
conferencingAppName = "Microsoft Teams";
} else {
@@ -47,7 +47,7 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
try {
await roleManager.checkPermissionToChangeRole(userId, organizationId);
await roleManager.checkPermissionToChangeRole(userId, organizationId, "org");
} catch (error) {
if (error instanceof RoleManagementError) {
throw new TRPCError({ code: "UNAUTHORIZED", message: error.message });

Some files were not shown because too many files have changed in this diff Show More