Compare commits

..
Author SHA1 Message Date
github-actions[bot] 8b3fbb672b chore: version packages (#22950)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-14 09:18:13 +00:00
Alex van AndelandHariom Balhara 2d5a86774e fix: Type error in inviteMember.handler test (#23070)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-14 01:48:09 +00:00
emrysal 0865104a77 chore: release v5.5.17
Post release / release (push) Canceled after 0s
2025-08-14 00:13:46 +00:00
1914b37ee4 feat: add PATCH endpoint for updating Google Calendar events (#22339)
* feat: add PATCH endpoint for updating Google Calendar events

- Create UpdateUnifiedCalendarEventInput DTO with optional fields for partial updates
- Add PATCH /:calendar/event/:eventUid endpoint to CalUnifiedCalendarsController
- Extend GoogleCalendarService with updateEventDetails method
- Support updating title, description, start/end times for Google Calendar events
- Follow existing patterns for validation, error handling, and response transformation
- Initially support Google Calendar only with extensibility for other providers

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: expand PATCH endpoint to support all calendar event fields

- Add comprehensive field support to UpdateUnifiedCalendarEventInput DTO
- Include locations, attendees, status, and hosts fields with proper validation
- Expand GoogleCalendarService.updateEventDetails to handle complex field transformations
- Add helper methods for response status and event status mapping
- Preserve existing transformation logic from GoogleCalendarEventOutputPipe
- Support conferenceData updates with conferenceDataVersion parameter

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: add support for simple location field in Google Calendar events

- Add handling for geographic location field in addition to conferenceData
- Filter non-video locations for the simple location string field
- Preserve existing conferenceData support for video meetings
- Verified against Google Calendar API documentation

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: extract transformation logic into GoogleCalendarEventInputPipe

- Create GoogleCalendarEventInputPipe following existing patterns
- Extract transformation logic from updateEventDetails method
- Move mapping methods to the new pipe for better organization
- Improve code reusability and maintainability

Addresses PR comment requesting transformation logic extraction.

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: remove locations field and simplify calendar event input handling

* update docs

* refactor: remove hosts field from the update endpoint

* fix: exclude organizer from attendees list in Google Calendar event transformation

* feat: implement organizer preservation logic in PATCH endpoint

- Fetch existing Google Calendar event data before transformation
- Extract organizer attendees from existing event and merge with user-provided attendees
- Prevent accidental removal of organizers when users update attendees
- Add custom interface for GoogleCalendarEventInputPipe to support optional existingEvent parameter
- Preserve organizers unless user explicitly provides hosts field in update request

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: add hosts array handling to PATCH endpoint

- Add hosts field back to UpdateUnifiedCalendarEventInput DTO
- Implement transformAttendeesWithHostsHandling method in GoogleCalendarEventInputPipe
- Convert hosts to attendees with organizer: true for Google Calendar API
- When hosts are provided, they replace existing organizers (not merge)
- Maintain backward compatibility with existing organizer preservation logic

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: implement sophisticated attendee update logic with preservation and deletion support

- Add UpdateCalendarEventAttendee class with action: 'delete' support
- Implement attendee preservation logic that starts with existing Google Calendar attendees
- Support explicit attendee deletion using action: 'delete' field
- Update existing attendees when provided without action field
- Add new attendees that don't exist in current event
- Preserve organizer status when updating existing attendees
- Fetch existing event data when attendees OR hosts are being updated
- Maintain hosts array handling that replaces existing organizers

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: add host management and attendee deletion to calendar event API

* refactor: improve readability of transformAttendeesWithHostsHandling and restrict hosts to responseStatus updates only

- Break down complex transformAttendeesWithHostsHandling method into smaller, more readable helper functions:
  - preserveExistingAttendees: handles existing attendee preservation
  - processAttendeeDeletions: handles attendee deletion logic
  - processAttendeeUpdatesAndAdditions: handles attendee updates and additions
  - replaceHostsWithUpdatedOnes: handles host replacement with restrictions
- Create UpdateCalendarEventHost class that only allows responseStatus updates
- Restrict hosts array to prevent changes to name, email, and other fields
- Preserve existing host displayName from Google Calendar when updating responseStatus

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data

- Remove email field from UpdateCalendarEventHost class
- Update replaceHostsWithUpdatedOnes to preserve all host data except responseStatus
- Apply responseStatus updates to all existing organizers from Google Calendar
- Prevent users from updating host email or name fields

Co-Authored-By: somay@cal.com <somay@cal.com>

* Revert "feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data"

This reverts commit 22d07ce0cb13e71661522b2fd8a237d8b6e0809c.

* refactor: replace optional flag with organizer property in calendar event attendees

* refactor: remove organizer field from calendar event attendee input

* feat: add comprehensive test suites for calendar transformation pipes

- Create google-calendar-event-input.pipe.spec.ts with 67 test cases
- Create get-calendar-event-details-output-pipe.spec.ts with 35 test cases
- Follow event-types transformer test patterns with separate describe blocks
- Cover all transformation functions including edge cases and null handling
- Test attendee preservation, deletion, host management, and status transformations
- Ensure comprehensive coverage for sophisticated attendee update logic

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: remove invalid optional properties from calendar pipe tests

- Remove optional: false from line 85 in 'should transform attendees without existing event' test case
- Remove optional: true from line 388 in 'should update existing attendee' test case
- Remove optional: false from line 421 in 'should add new attendee' test case
- Update expected outputs to match actual pipe implementation behavior
- All 30 tests now pass locally

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: add platform library path mappings to Jest configuration

- Resolves module resolution errors for @calcom/platform-libraries/* imports in Jest
- Ensures test suite can run without 'Cannot find module' errors
- Adds comprehensive path mappings matching TypeScript configuration

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: add @calcom/dayjs path mapping to Jest configuration

- Add missing moduleNameMapper entry for @calcom/dayjs to resolve TypeScript path mappings in Jest
- Fixes TypeError: Cannot read properties of undefined (reading 'extend') in calendar pipe tests
- Calendar transformation pipe tests now pass successfully

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: configure Jest to use dayjs mock system for consistent test environment

- Remove @calcom/dayjs path mapping from Jest config
- Add setupFilesAfterEnv to automatically import dayjs mock
- Update dayjs mock with comprehensive jest.fn() implementation
- Add @calcom/platform-libraries/repositories and @calcom/prisma/client mappings
- Resolves dayjs TypeError in slots.service.spec.ts and other test files

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: complete shared Google event data with required properties for test suites

- Add missing required properties to sharedGoogleEvent in both test files
- Fix TypeScript compilation errors in calendar transformation pipe tests
- Correct test assertion for attendees filtering logic in output pipe
- All 11 test suites now pass with 199 tests total

Co-Authored-By: somay@cal.com <somay@cal.com>

* undo extra changes

* better tests

* fix: update calendar API endpoints to use plural events in path

* update documentation

* fix: Restrict updates to only the responseStatus field for hosts

* feat: add PATCH endpoint for updating Google Calendar events

- Create UpdateUnifiedCalendarEventInput DTO with optional fields for partial updates
- Add PATCH /:calendar/event/:eventUid endpoint to CalUnifiedCalendarsController
- Extend GoogleCalendarService with updateEventDetails method
- Support updating title, description, start/end times for Google Calendar events
- Follow existing patterns for validation, error handling, and response transformation
- Initially support Google Calendar only with extensibility for other providers

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: expand PATCH endpoint to support all calendar event fields

- Add comprehensive field support to UpdateUnifiedCalendarEventInput DTO
- Include locations, attendees, status, and hosts fields with proper validation
- Expand GoogleCalendarService.updateEventDetails to handle complex field transformations
- Add helper methods for response status and event status mapping
- Preserve existing transformation logic from GoogleCalendarEventOutputPipe
- Support conferenceData updates with conferenceDataVersion parameter

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: add support for simple location field in Google Calendar events

- Add handling for geographic location field in addition to conferenceData
- Filter non-video locations for the simple location string field
- Preserve existing conferenceData support for video meetings
- Verified against Google Calendar API documentation

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: extract transformation logic into GoogleCalendarEventInputPipe

- Create GoogleCalendarEventInputPipe following existing patterns
- Extract transformation logic from updateEventDetails method
- Move mapping methods to the new pipe for better organization
- Improve code reusability and maintainability

Addresses PR comment requesting transformation logic extraction.

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: remove locations field and simplify calendar event input handling

* update docs

* refactor: remove hosts field from the update endpoint

* fix: exclude organizer from attendees list in Google Calendar event transformation

* feat: implement organizer preservation logic in PATCH endpoint

- Fetch existing Google Calendar event data before transformation
- Extract organizer attendees from existing event and merge with user-provided attendees
- Prevent accidental removal of organizers when users update attendees
- Add custom interface for GoogleCalendarEventInputPipe to support optional existingEvent parameter
- Preserve organizers unless user explicitly provides hosts field in update request

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: add hosts array handling to PATCH endpoint

- Add hosts field back to UpdateUnifiedCalendarEventInput DTO
- Implement transformAttendeesWithHostsHandling method in GoogleCalendarEventInputPipe
- Convert hosts to attendees with organizer: true for Google Calendar API
- When hosts are provided, they replace existing organizers (not merge)
- Maintain backward compatibility with existing organizer preservation logic

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: implement sophisticated attendee update logic with preservation and deletion support

- Add UpdateCalendarEventAttendee class with action: 'delete' support
- Implement attendee preservation logic that starts with existing Google Calendar attendees
- Support explicit attendee deletion using action: 'delete' field
- Update existing attendees when provided without action field
- Add new attendees that don't exist in current event
- Preserve organizer status when updating existing attendees
- Fetch existing event data when attendees OR hosts are being updated
- Maintain hosts array handling that replaces existing organizers

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: add host management and attendee deletion to calendar event API

* refactor: improve readability of transformAttendeesWithHostsHandling and restrict hosts to responseStatus updates only

- Break down complex transformAttendeesWithHostsHandling method into smaller, more readable helper functions:
  - preserveExistingAttendees: handles existing attendee preservation
  - processAttendeeDeletions: handles attendee deletion logic
  - processAttendeeUpdatesAndAdditions: handles attendee updates and additions
  - replaceHostsWithUpdatedOnes: handles host replacement with restrictions
- Create UpdateCalendarEventHost class that only allows responseStatus updates
- Restrict hosts array to prevent changes to name, email, and other fields
- Preserve existing host displayName from Google Calendar when updating responseStatus

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data

- Remove email field from UpdateCalendarEventHost class
- Update replaceHostsWithUpdatedOnes to preserve all host data except responseStatus
- Apply responseStatus updates to all existing organizers from Google Calendar
- Prevent users from updating host email or name fields

Co-Authored-By: somay@cal.com <somay@cal.com>

* Revert "feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data"

This reverts commit 22d07ce0cb13e71661522b2fd8a237d8b6e0809c.

* feat: add comprehensive test suites for calendar transformation pipes

- Create google-calendar-event-input.pipe.spec.ts with 67 test cases
- Create get-calendar-event-details-output-pipe.spec.ts with 35 test cases
- Follow event-types transformer test patterns with separate describe blocks
- Cover all transformation functions including edge cases and null handling
- Test attendee preservation, deletion, host management, and status transformations
- Ensure comprehensive coverage for sophisticated attendee update logic

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: replace optional flag with organizer property in calendar event attendees

* refactor: remove organizer field from calendar event attendee input

* fix: remove invalid optional properties from calendar pipe tests

- Remove optional: false from line 85 in 'should transform attendees without existing event' test case
- Remove optional: true from line 388 in 'should update existing attendee' test case
- Remove optional: false from line 421 in 'should add new attendee' test case
- Update expected outputs to match actual pipe implementation behavior
- All 30 tests now pass locally

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: complete shared Google event data with required properties for test suites

- Add missing required properties to sharedGoogleEvent in both test files
- Fix TypeScript compilation errors in calendar transformation pipe tests
- Correct test assertion for attendees filtering logic in output pipe
- All 11 test suites now pass with 199 tests total

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: resolve TypeScript compilation errors in calendar transformation pipe tests

- Add comprehensive Jest mocks for @calcom/prisma/client, @calcom/dayjs, CalendarManager, and delegationCredential
- Fix RedisService AbortSignal.timeout() usage
- Update UserAvailabilityService import path to use @calcom/lib/getUserAvailability
- Configure Jest moduleNameMapper to handle ES module imports and package.json mocking
- All 11 test suites now pass with 199 tests total

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: use proper CacheService dependency injection in AvailableSlotsService

- Replace direct CacheService instantiation with dependency injection
- Update CacheService import path to use correct calendar-cache module
- Fix repository and service dependencies for proper type safety
- Resolves TypeScript compilation errors in slots service tests

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: remove duplicate test file causing TypeScript compilation error

Co-Authored-By: somay@cal.com <somay@cal.com>

* undo extra changes

* undo extra changes

* fix: remove the action: "delete" key from attendees

* fix: simplify tests

* update docs

* undo: remove hosts

* Update pre-commit

* feat: add alternate route for calendar event retrieval and simplify test fixtures

* remove userId -- unused

* undo

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: somay@cal.com <somay@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-13 17:24:19 +01:00
Lauris Skraucis 58ef0abb99 Revert "fix: managed orgs get bookings (#23063)" (#23066)
This reverts commit 13ba680a73.
2025-08-13 15:54:49 +00:00
Lauris Skraucis 13ba680a73 fix: managed orgs get bookings (#23063)
* fix: bookings handler scope to provided orgId

* fix: private link input body

* tests

* chore: bump platform libs

* fix: cleanup test org

* refactor get handler

* refactor test
2025-08-13 14:58:10 +00:00
Benny JooandAlex van Andel f40c6da93b fix: use server fetched user for all sub pages in /getting-started (#23064)
* use server fetched user for all sub pages

* Wrap setAnyInstalledVideoApps call in useEffect

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-13 14:35:30 +00:00
Somay Chauhan fc2e81e89a feat: add toggle for no availability dialog (#23052)
* feat: add toggle for no availability dialog

* Create great-hands-lick.md

* fix: correct spelling from showNoAvailabilityDailog to showNoAvailabilityDialog

* remove comment and redundant
2025-08-13 13:21:56 +00:00
Somay Chauhan 293dd1a140 Fix: lock timezone on booking page breaks atoms (#23060)
* fix: eventTypeSettings atom crashed on enabling `Lock timezone on booking page`

* add changelog
2025-08-13 13:03:35 +00:00
Hariom Balhara b7fda31678 fix: Issues observed when inviting members to organization either through teams migration during onboarding or explicit user invite (#22901)
* fix: make team migrations sequential to avoid race conditions

Instead of processing team migrations in parallel using Promise.all,
process them sequentially to avoid race conditions when creating
profiles and memberships for team members.

This simplifies the implementation and avoids the need for complex
retry logic or database-level concurrency controls.

* Promise.all accepts an array of promises and not an array with array in it as the first item. This fixes the issue where these promises were unhandled

* Fix the issue where an existing pending membership with team stays pending even though theorganization membership gets created as accepted
2025-08-13 10:48:17 +00:00
Benny Joo 94a04f0dc2 remove network level caching for i18n service (#23058) 2025-08-13 09:39:00 +00:00
RomitandDevanshu Sharma 2338f85dcf fix: fixed team event form ui (#22005)
* fixed team event form ui

* edge cases for some mobiles

* revert with removed padding

* chore

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-13 05:51:22 +00:00
KartikandKartik Saini fbaccc8e5f fix: prevent dropdown arrow from centering when Select height increases (#22877)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-13 04:38:48 +00:00
Alex van Andel 750db38d38 fix: Crash of onboarding connected-video page (#23047) 2025-08-13 02:10:22 +00:00
51 changed files with 2841 additions and 3755 deletions
-9
View File
@@ -1,9 +0,0 @@
---
"@calcom/atoms": minor
---
Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
feat: style calendar settings and availability overrides
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
booker atom: allow toggling org and team info when booking round robin
View File
@@ -67,10 +67,9 @@ export class EventTypesPrivateLinksController {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Get all private links for an event type" })
async getPrivateLinks(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser("id") userId: number
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<GetPrivateLinksOutput> {
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId, userId);
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
return {
status: SUCCESS_STATUS,
@@ -1,15 +1,15 @@
import { Module } from "@nestjs/common";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
import { PrivateLinksRepository } from "./private-links.repository";
import { PrivateLinksInputService } from "./services/private-links-input.service";
import { PrivateLinksOutputService } from "./services/private-links-output.service";
import { PrivateLinksService } from "./services/private-links.service";
import { PrivateLinksRepository } from "./private-links.repository";
@Module({
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
@@ -23,5 +23,3 @@ import { PrivateLinksRepository } from "./private-links.repository";
],
})
export class EventTypesPrivateLinksModule {}
@@ -25,7 +25,10 @@ export class PrivateLinksRepository {
});
}
async create(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
async create(
eventTypeId: number,
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
) {
return this.dbWrite.prisma.hashedLink.create({
data: {
eventTypeId,
@@ -36,7 +39,10 @@ export class PrivateLinksRepository {
});
}
async update(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
async update(
eventTypeId: number,
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
) {
return this.dbWrite.prisma.hashedLink.updateMany({
where: { eventTypeId, link: link.link },
data: {
@@ -50,5 +56,3 @@ export class PrivateLinksRepository {
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
}
}
@@ -1,6 +1,12 @@
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import {
PrivateLinkOutput,
TimeBasedPrivateLinkOutput,
UsageBasedPrivateLinkOutput,
} from "@calcom/platform-types";
export type PrivateLinkData = {
id: string;
eventTypeId: number;
@@ -10,7 +16,6 @@ export type PrivateLinkData = {
maxUsageCount?: number | null;
usageCount?: number;
};
import { PrivateLinkOutput, TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksOutputService {
@@ -39,5 +44,3 @@ export class PrivateLinksOutputService {
return data.map((item) => this.transformToOutput(item));
}
}
@@ -1,14 +1,13 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
import { PrivateLinksInputService } from "@/ee/event-types-private-links/services/private-links-input.service";
import {
PrivateLinksOutputService,
type PrivateLinkData,
} from "@/ee/event-types-private-links/services/private-links-output.service";
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksService {
@@ -48,7 +47,7 @@ export class PrivateLinksService {
}
}
async getPrivateLinks(eventTypeId: number, userId: number): Promise<PrivateLinkOutput[]> {
async getPrivateLinks(eventTypeId: number): Promise<PrivateLinkOutput[]> {
try {
const links = await this.repo.listByEventTypeId(eventTypeId);
const mapped: PrivateLinkData[] = links.map((l) => ({
@@ -124,5 +123,3 @@ export class PrivateLinksService {
}
}
}
@@ -6,7 +6,6 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -42,7 +41,6 @@ import {
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -1,6 +1,7 @@
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { CalendarsModule } from "@/ee/calendars/calendars.module";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { GcalModule } from "@/ee/gcal/gcal.module";
@@ -14,7 +15,6 @@ import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.m
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -2,10 +2,21 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
import { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input";
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
import {
Controller,
Get,
Param,
UseGuards,
HttpCode,
HttpStatus,
BadRequestException,
Patch,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -30,6 +41,7 @@ export class CalUnifiedCalendarsController {
type: String,
})
@Get("/:calendar/event/:eventUid")
@Get("/:calendar/events/:eventUid")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -54,4 +66,42 @@ export class CalUnifiedCalendarsController {
data: transformedEvent,
};
}
@ApiParam({
name: "calendar",
enum: [GOOGLE_CALENDAR],
type: String,
})
@ApiParam({
name: "eventUid",
description:
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
type: String,
})
@Patch("/:calendar/events/:eventUid")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Update meeting details in calendar",
description: "Updates event information in the specified calendar provider",
})
async updateCalendarEvent(
@Param("calendar") calendar: string,
@Param("eventUid") eventUid: string,
@Body() updateData: UpdateUnifiedCalendarEventInput
): Promise<GetUnifiedCalendarEventOutput> {
if (calendar !== GOOGLE_CALENDAR) {
throw new BadRequestException("Event updates are currently only available for Google Calendar");
}
const updatedEvent = await this.googleCalendarService.updateEventDetails(eventUid, updateData);
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(updatedEvent);
return {
status: SUCCESS_STATUS,
data: transformedEvent,
};
}
}
@@ -0,0 +1,134 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsISO8601, IsOptional, IsString, ValidateNested, IsEnum, IsArray } from "class-validator";
import { CalendarEventStatus, CalendarEventResponseStatus } from "../outputs/get-unified-calendar-event.output";
export class UpdateCalendarEventAttendee {
@IsString()
@ApiPropertyOptional({
type: String,
description: "Email address of the attendee",
})
email!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "Display name of the attendee",
})
name?: string;
@IsEnum(CalendarEventResponseStatus)
@IsOptional()
@ApiPropertyOptional({
enum: CalendarEventResponseStatus,
enumName: "CalendarEventResponseStatus",
nullable: true,
description: "Response status of the attendee",
})
responseStatus?: CalendarEventResponseStatus | null;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the current user",
})
self?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee's attendance is optional",
})
optional?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the host",
})
host?: boolean;
}
export class UpdateDateTimeWithZone {
@IsISO8601()
@IsOptional()
@ApiPropertyOptional({ type: "string", format: "date-time" })
time?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
timeZone?: string;
}
export class UpdateUnifiedCalendarEventInput {
@ValidateNested()
@Type(() => UpdateDateTimeWithZone)
@IsOptional()
@ApiPropertyOptional({
type: "object",
properties: {
time: { type: "string", format: "date-time" },
timeZone: { type: "string" },
},
description: "Start date and time of the calendar event with timezone information",
})
start?: UpdateDateTimeWithZone;
@ValidateNested()
@Type(() => UpdateDateTimeWithZone)
@IsOptional()
@ApiPropertyOptional({
type: "object",
properties: {
time: { type: "string", format: "date-time" },
timeZone: { type: "string" },
},
description: "End date and time of the calendar event with timezone information",
})
end?: UpdateDateTimeWithZone;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "Title of the calendar event",
})
title?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
nullable: true,
description: "Detailed description of the calendar event",
})
description?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UpdateCalendarEventAttendee)
@ApiPropertyOptional({
type: [UpdateCalendarEventAttendee],
nullable: true,
description:
"List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
})
attendees?: UpdateCalendarEventAttendee[];
@IsEnum(CalendarEventStatus)
@IsOptional()
@ApiPropertyOptional({
enum: CalendarEventStatus,
enumName: "CalendarEventStatus",
nullable: true,
description: "Status of the event (accepted, pending, declined, cancelled)",
example: CalendarEventStatus.ACCEPTED,
})
status?: CalendarEventStatus | null;
}
@@ -225,6 +225,22 @@ export class CalendarEventHost {
responseStatus!: CalendarEventResponseStatus | null;
}
export class calendarEventOwner {
@IsString()
@ApiProperty({
description: "Email address of the event host",
})
email!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Display name of the event host",
})
name?: string;
}
export class CalendarEventAttendee {
@IsString()
@ApiProperty({
@@ -264,6 +280,14 @@ export class CalendarEventAttendee {
description: "Indicates if this attendee's attendance is optional",
})
optional?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the host",
})
host?: boolean;
}
export class DateTimeWithZone {
@@ -384,6 +408,16 @@ export class UnifiedCalendarEventOutput {
})
hosts?: CalendarEventHost[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => calendarEventOwner)
@ApiPropertyOptional({
type: calendarEventOwner,
nullable: true,
description: "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
})
calendarEventOwner?: calendarEventOwner;
@IsEnum(CALENDARS)
@ApiProperty({
enum: CALENDARS,
@@ -0,0 +1,103 @@
import { GoogleCalendarEventResponse } from "../get-calendar-event-details-output-pipe";
export const createGoogleCalendarEventFixture = (
overrides: Partial<GoogleCalendarEventResponse> = {}
): GoogleCalendarEventResponse => {
const baseEvent: GoogleCalendarEventResponse = {
kind: "calendar#event",
etag: "test-etag",
id: "test-event-id",
status: "confirmed",
htmlLink: "https://calendar.google.com/event",
created: "2024-01-01T00:00:00Z",
updated: "2024-01-01T00:00:00Z",
summary: "Test Meeting",
description: "Test description",
creator: {
email: "creator@example.com",
displayName: "Creator Name",
},
organizer: {
email: "organizer@example.com",
displayName: "Organizer Name",
},
start: {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
dateTime: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
iCalUID: "test-ical-uid",
sequence: 0,
attendees: [
{
email: "attendee@example.com",
displayName: "Attendee Name",
responseStatus: "accepted",
organizer: false,
},
{
email: "organizer@example.com",
displayName: "Organizer Name",
responseStatus: "accepted",
organizer: true,
},
],
conferenceData: {
conferenceId: "abc-def-ghi",
entryPoints: [
{
entryPointType: "video",
uri: "https://meet.google.com/abc-def-ghi",
label: "meet.google.com/abc-def-ghi",
},
],
conferenceSolution: {
key: {
type: "hangoutsMeet",
},
name: "Google Meet",
iconUri:
"https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png",
},
},
hangoutLink: "https://meet.google.com/abc-def-ghi",
};
return { ...baseEvent, ...overrides };
};
export const googleEventWithConferenceData = createGoogleCalendarEventFixture({
conferenceData: {
conferenceId: "test-conference-id",
entryPoints: [
{
entryPointType: "video",
uri: "https://meet.google.com/abc-def-ghi",
label: "Google Meet",
pin: "123456",
regionCode: "US",
},
{
entryPointType: "phone",
uri: "tel:+1-555-123-4567",
label: "Phone",
pin: "789012",
},
],
},
});
export const googleEventWithLocationOnly = createGoogleCalendarEventFixture({
location: "123 Main St, City, State",
conferenceData: undefined,
hangoutLink: undefined,
});
export const googleEventWithHangoutLink = createGoogleCalendarEventFixture({
hangoutLink: "https://hangouts.google.com/call/abc123",
conferenceData: undefined,
location: undefined,
});
@@ -0,0 +1,201 @@
import {
CalendarEventStatus,
CalendarEventResponseStatus,
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
import {
createGoogleCalendarEventFixture,
googleEventWithConferenceData,
googleEventWithLocationOnly,
googleEventWithHangoutLink,
} from "./__fixtures__/google-calendar-event.fixture";
import {
GoogleCalendarEventOutputPipe,
GoogleCalendarEventResponse,
} from "./get-calendar-event-details-output-pipe";
describe("GoogleCalendarEventOutputPipe", () => {
let pipe: GoogleCalendarEventOutputPipe;
let sharedGoogleEvent: GoogleCalendarEventResponse;
beforeAll(() => {
sharedGoogleEvent = createGoogleCalendarEventFixture();
});
beforeEach(() => {
pipe = new GoogleCalendarEventOutputPipe();
});
describe("transform", () => {
it("should transform Google Calendar event to unified format", () => {
const result = pipe.transform(sharedGoogleEvent);
expect(result.id).toBe("test-event-id");
expect(result.title).toBe("Test Meeting");
expect(result.description).toBe("Test description");
expect(result.source).toBe("google");
expect(result.status).toBe(CalendarEventStatus.ACCEPTED);
expect(result.start.time).toBe("2024-01-15T10:00:00Z");
expect(result.start.timeZone).toBe("America/New_York");
expect(result.end.time).toBe("2024-01-15T11:00:00Z");
expect(result.end.timeZone).toBe("America/New_York");
expect(result.attendees).toHaveLength(2);
});
});
describe("transformDateTimeWithZone", () => {
it("should transform Google date time to unified format", () => {
const googleDateTime = {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
};
const result = pipe["transformDateTimeWithZone"](googleDateTime);
expect(result.time).toBe("2024-01-15T10:00:00Z");
expect(result.timeZone).toBe("America/New_York");
});
});
describe("transformAttendeeResponseStatus", () => {
it("should transform accepted status", () => {
const result = pipe["transformAttendeeResponseStatus"]("accepted");
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
});
it("should transform tentative status", () => {
const result = pipe["transformAttendeeResponseStatus"]("tentative");
expect(result).toBe(CalendarEventResponseStatus.PENDING);
});
it("should transform declined status", () => {
const result = pipe["transformAttendeeResponseStatus"]("declined");
expect(result).toBe(CalendarEventResponseStatus.DECLINED);
});
it("should transform needsaction status", () => {
const result = pipe["transformAttendeeResponseStatus"]("needsaction");
expect(result).toBe(CalendarEventResponseStatus.NEEDS_ACTION);
});
it("should handle case insensitive status", () => {
const result = pipe["transformAttendeeResponseStatus"]("ACCEPTED");
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
});
it("should handle unknown status", () => {
const result = pipe["transformAttendeeResponseStatus"]("unknown");
expect(result).toBeNull();
});
it("should handle null status", () => {
const result = pipe["transformAttendeeResponseStatus"](undefined);
expect(result).toBeNull();
});
});
describe("transformEventStatus", () => {
it("should transform confirmed status", () => {
const result = pipe["transformEventStatus"]("confirmed");
expect(result).toBe(CalendarEventStatus.ACCEPTED);
});
it("should transform tentative status", () => {
const result = pipe["transformEventStatus"]("tentative");
expect(result).toBe(CalendarEventStatus.PENDING);
});
it("should transform cancelled status", () => {
const result = pipe["transformEventStatus"]("cancelled");
expect(result).toBe(CalendarEventStatus.CANCELLED);
});
it("should handle case insensitive status", () => {
const result = pipe["transformEventStatus"]("CONFIRMED");
expect(result).toBe(CalendarEventStatus.ACCEPTED);
});
it("should handle unknown status", () => {
const result = pipe["transformEventStatus"]("unknown");
expect(result).toBeNull();
});
it("should handle null status", () => {
const result = pipe["transformEventStatus"](undefined);
expect(result).toBeNull();
});
});
describe("transformOrganizer", () => {
it("should return calendarEventOwner organizer exist", () => {
const eventWithoutOrganizerAttendees = createGoogleCalendarEventFixture({
organizer: { email: "organizer@example.com" },
attendees: [
{
email: "attendee@example.com",
displayName: "Regular Attendee",
responseStatus: "accepted",
organizer: false,
},
],
});
const result = pipe.transform(eventWithoutOrganizerAttendees);
expect(result?.calendarEventOwner?.email).toBe("organizer@example.com");
});
});
describe("transformLocations", () => {
it("should transform conference data entry points", () => {
const result = pipe["transformLocations"](googleEventWithConferenceData);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: "video",
url: "https://meet.google.com/abc-def-ghi",
label: "Google Meet",
pin: "123456",
regionCode: "US",
});
expect(result[1]).toEqual({
type: "phone",
url: "tel:+1-555-123-4567",
label: "Phone",
pin: "789012",
regionCode: undefined,
});
});
it("should fallback to location field", () => {
const result = pipe["transformLocations"](googleEventWithLocationOnly);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "video",
url: "123 Main St, City, State",
});
});
it("should fallback to hangout link", () => {
const result = pipe["transformLocations"](googleEventWithHangoutLink);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "video",
url: "https://hangouts.google.com/call/abc123",
});
});
it("should return empty array when no location data", () => {
const eventWithoutLocation = createGoogleCalendarEventFixture({
location: undefined,
conferenceData: undefined,
hangoutLink: undefined,
});
const result = pipe["transformLocations"](eventWithoutLocation);
expect(result).toEqual([]);
});
});
});
@@ -3,7 +3,7 @@ import {
CalendarEventStatus,
DateTimeWithZone,
UnifiedCalendarEventOutput,
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
import { PipeTransform, Injectable } from "@nestjs/common";
export interface GoogleCalendarEventResponse {
@@ -94,14 +94,12 @@ export class GoogleCalendarEventOutputPipe
calendarEvent.locations = this.transformLocations(googleEvent);
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
calendarEvent.attendees = googleEvent.attendees
.filter((attendee) => !attendee.organizer)
.map((attendee) => {
calendarEvent.attendees = googleEvent.attendees.map((attendee) => {
return {
email: attendee.email,
name: attendee.displayName,
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
organizer: attendee.organizer,
host: attendee.organizer,
self: attendee.self,
optional: attendee.optional,
};
@@ -110,7 +108,8 @@ export class GoogleCalendarEventOutputPipe
calendarEvent.status = this.transformEventStatus(googleEvent.status);
calendarEvent.hosts = this.transformHosts(googleEvent);
calendarEvent.calendarEventOwner = googleEvent.organizer
calendarEvent.hosts = this.transformHosts(googleEvent)
calendarEvent.source = "google";
@@ -196,6 +195,8 @@ export class GoogleCalendarEventOutputPipe
regionCode: entryPoint.regionCode,
};
});
} else if (googleEvent.location) {
return [{ type: "video", url: googleEvent.location }];
} else if (googleEvent.hangoutLink) {
return [{ type: "video", url: googleEvent.hangoutLink }];
}
@@ -0,0 +1,121 @@
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
import { Injectable } from "@nestjs/common";
import {
UpdateUnifiedCalendarEventInput,
UpdateDateTimeWithZone,
} from "../inputs/update-unified-calendar-event.input";
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
// Common interfaces for Google Calendar types
interface GoogleCalendarDateTime {
dateTime: string;
timeZone: string;
}
interface GoogleCalendarAttendee {
email: string;
displayName?: string;
responseStatus: string;
organizer?: boolean;
}
interface GoogleCalendarEventInputTransform {
transform(
updateData: UpdateUnifiedCalendarEventInput,
fetchedEvent?: GoogleCalendarEventResponse | null
): any;
}
@Injectable()
export class GoogleCalendarEventInputPipe implements GoogleCalendarEventInputTransform {
transform(
updateData: UpdateUnifiedCalendarEventInput,
): any {
const updatePayload: any = {};
if (updateData.title !== undefined) {
updatePayload.summary = updateData.title;
}
if (updateData.description !== undefined) {
updatePayload.description = updateData.description;
}
if (updateData.start) {
updatePayload.start = this.transformDateTimeWithZone(updateData.start);
}
if (updateData.end) {
updatePayload.end = this.transformDateTimeWithZone(updateData.end);
}
if (updateData.attendees !== undefined) {
updatePayload.attendees = this.transformAttendees(
updateData.attendees,
);
}
if (updateData.status !== undefined) {
updatePayload.status = this.transformEventStatus(updateData.status);
}
return updatePayload;
}
private transformDateTimeWithZone(dateTime: UpdateDateTimeWithZone): GoogleCalendarDateTime {
return {
dateTime: dateTime.time || "",
timeZone: dateTime.timeZone || "",
};
}
private transformAttendees(
inputAttendees: NonNullable<UpdateUnifiedCalendarEventInput["attendees"]>,
): GoogleCalendarAttendee[] {
return inputAttendees.map((attendee) => {
return {
email: attendee.email,
displayName: attendee.name,
responseStatus: this.transformResponseStatus(attendee.responseStatus),
organizer: attendee.host,
self: attendee.self,
optional: attendee.optional,
};
});
}
private transformResponseStatus(responseStatus?: CalendarEventResponseStatus | null): string {
if (!responseStatus) return "needsAction";
switch (responseStatus) {
case CalendarEventResponseStatus.ACCEPTED:
return "accepted";
case CalendarEventResponseStatus.PENDING:
return "tentative";
case CalendarEventResponseStatus.DECLINED:
return "declined";
case CalendarEventResponseStatus.NEEDS_ACTION:
return "needsAction";
default:
return "needsAction";
}
}
private transformEventStatus(status?: CalendarEventStatus | null): string {
if (!status) return "confirmed";
switch (status) {
case CalendarEventStatus.ACCEPTED:
return "confirmed";
case CalendarEventStatus.PENDING:
return "tentative";
case CalendarEventStatus.CANCELLED:
return "cancelled";
case CalendarEventStatus.DECLINED:
return "cancelled";
default:
return "confirmed";
}
}
}
@@ -0,0 +1,222 @@
import {
UpdateUnifiedCalendarEventInput,
UpdateDateTimeWithZone,
} from "../inputs/update-unified-calendar-event.input";
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
import { GoogleCalendarEventResponse } from "./get-calendar-event-details-output-pipe";
import { GoogleCalendarEventInputPipe } from "./google-calendar-event-input-pipe";
describe("GoogleCalendarEventInputPipe", () => {
let pipe: GoogleCalendarEventInputPipe;
let sharedGoogleEvent: GoogleCalendarEventResponse;
beforeAll(() => {
sharedGoogleEvent = {
kind: "calendar#event",
etag: "test-etag",
id: "test-event-id",
status: "confirmed",
htmlLink: "https://calendar.google.com/event",
created: "2024-01-01T00:00:00Z",
updated: "2024-01-01T00:00:00Z",
summary: "Test Meeting",
description: "Test description",
start: {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
dateTime: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
iCalUID: "test-ical-uid",
sequence: 0,
attendees: [
{
email: "attendee1@example.com",
displayName: "Attendee One",
responseStatus: "accepted",
organizer: false,
},
{
email: "attendee2@example.com",
displayName: "Attendee Two",
responseStatus: "needsAction",
organizer: false,
},
{
email: "organizer@example.com",
displayName: "Organizer",
responseStatus: "accepted",
organizer: true,
},
],
creator: {
email: "organizer@example.com",
displayName: "Organizer",
},
organizer: {
email: "organizer@example.com",
displayName: "Organizer",
},
};
});
beforeEach(() => {
pipe = new GoogleCalendarEventInputPipe();
});
describe("transform", () => {
it("should transform basic event fields", () => {
const input: UpdateUnifiedCalendarEventInput = {
title: "Updated Meeting",
description: "Updated description",
start: {
time: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
time: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
status: CalendarEventStatus.ACCEPTED,
};
const expectedOutput = {
summary: "Updated Meeting",
description: "Updated description",
start: {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
dateTime: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
status: "confirmed",
};
const result = pipe.transform(input);
expect(result).toEqual(expectedOutput);
});
it("should handle partial updates", () => {
const input: UpdateUnifiedCalendarEventInput = {
title: "Only Title Update",
};
const expectedOutput = {
summary: "Only Title Update",
};
const result = pipe.transform(input);
expect(result).toEqual(expectedOutput);
});
it("should handle null description", () => {
const input: UpdateUnifiedCalendarEventInput = {
description: null,
};
const expectedOutput = {
description: null,
};
const result = pipe.transform(input);
expect(result).toEqual(expectedOutput);
});
});
describe("transformDateTimeWithZone", () => {
it("should transform date time with timezone", () => {
const input: UpdateDateTimeWithZone = {
time: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
};
const expectedOutput = {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
};
const result = pipe["transformDateTimeWithZone"](input);
expect(result).toEqual(expectedOutput);
});
it("should handle empty time and timezone", () => {
const input: UpdateDateTimeWithZone = {};
const expectedOutput = {
dateTime: "",
timeZone: "",
};
const result = pipe["transformDateTimeWithZone"](input);
expect(result).toEqual(expectedOutput);
});
});
describe("transformResponseStatus", () => {
it("should transform ACCEPTED status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.ACCEPTED);
expect(result).toBe("accepted");
});
it("should transform PENDING status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.PENDING);
expect(result).toBe("tentative");
});
it("should transform DECLINED status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.DECLINED);
expect(result).toBe("declined");
});
it("should transform NEEDS_ACTION status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.NEEDS_ACTION);
expect(result).toBe("needsAction");
});
it("should handle null status", () => {
const result = pipe["transformResponseStatus"](null);
expect(result).toBe("needsAction");
});
it("should handle undefined status", () => {
const result = pipe["transformResponseStatus"](undefined);
expect(result).toBe("needsAction");
});
});
describe("transformEventStatus", () => {
it("should transform ACCEPTED status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.ACCEPTED);
expect(result).toBe("confirmed");
});
it("should transform PENDING status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.PENDING);
expect(result).toBe("tentative");
});
it("should transform CANCELLED status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.CANCELLED);
expect(result).toBe("cancelled");
});
it("should transform DECLINED status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.DECLINED);
expect(result).toBe("cancelled");
});
it("should handle null status", () => {
const result = pipe["transformEventStatus"](null);
expect(result).toBe("confirmed");
});
it("should handle undefined status", () => {
const result = pipe["transformEventStatus"](undefined);
expect(result).toBe("confirmed");
});
});
});
@@ -10,7 +10,8 @@ import { JWT } from "googleapis-common";
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
import { UnifiedCalendarEventOutput } from "../outputs/get-unified-calendar-event";
import { UpdateUnifiedCalendarEventInput } from "../inputs/update-unified-calendar-event.input";
import { GoogleCalendarEventInputPipe } from "../pipes/google-calendar-event-input-pipe";
@Injectable()
export class GoogleCalendarService {
@@ -53,6 +54,44 @@ export class GoogleCalendarService {
}
}
async updateEventDetails(
eventUid: string,
updateData: UpdateUnifiedCalendarEventInput
): Promise<GoogleCalendarEventResponse> {
const bookingReference =
await this.bookingReferencesRepository.getBookingReferencesIncludeSensitiveCredentials(eventUid);
if (!bookingReference) {
throw new NotFoundException("Booking reference not found");
}
const ownerUserEmail = bookingReference?.booking?.user?.email;
const calendar = await this.getAuthorizedCalendarInstance(
ownerUserEmail,
bookingReference.credential?.key,
bookingReference.delegationCredential
);
const updatePayload = new GoogleCalendarEventInputPipe().transform(updateData);
try {
const event = await calendar.events.patch({
calendarId: bookingReference?.externalCalendarId ?? "primary",
eventId: bookingReference?.uid,
requestBody: updatePayload,
});
if (!event.data) {
throw new NotFoundException("Failed to update meeting");
}
return event.data as GoogleCalendarEventResponse;
} catch (error) {
throw new NotFoundException("Failed to update meeting details");
}
}
/**
* Gets an authorized Google Calendar instance
* Tries delegation credentials first, falls back to direct OAuth
@@ -40,5 +40,3 @@ export class EventTypeOwnershipGuard implements CanActivate {
return true;
}
}
+180 -1
View File
@@ -8563,6 +8563,69 @@
]
}
},
"/v2/calendars/{calendar}/events/{eventUid}": {
"patch": {
"operationId": "CalUnifiedCalendarsController_updateCalendarEvent",
"summary": "Update meeting details in calendar",
"description": "Updates event information in the specified calendar provider",
"parameters": [
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"enum": [
"google"
],
"type": "string"
}
},
{
"name": "eventUid",
"required": true,
"in": "path",
"description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUnifiedCalendarEventInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetUnifiedCalendarEventOutput"
}
}
}
}
},
"tags": [
"Cal Unified Calendars"
]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -28782,7 +28845,7 @@
},
"CalendarEventResponseStatus": {
"type": "string",
"description": "Host's response to the invitation",
"description": "Response status of the attendee",
"enum": [
"accepted",
"pending",
@@ -28815,6 +28878,11 @@
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee's attendance is optional"
},
"host": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee is the host"
}
},
"required": [
@@ -28853,6 +28921,23 @@
"email"
]
},
"calendarEventOwner": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Email address of the event host"
},
"name": {
"type": "string",
"nullable": true,
"description": "Display name of the event host"
}
},
"required": [
"email"
]
},
"CalendarSource": {
"type": "string",
"description": "Calendar integration source (e.g., Google Calendar, Office 365, Apple Calendar). Currently only Google Calendar is supported.",
@@ -28949,6 +29034,15 @@
"$ref": "#/components/schemas/CalendarEventHost"
}
},
"calendarEventOwner": {
"nullable": true,
"description": "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
"allOf": [
{
"$ref": "#/components/schemas/calendarEventOwner"
}
]
},
"source": {
"example": "google",
"$ref": "#/components/schemas/CalendarSource"
@@ -28982,6 +29076,91 @@
"data"
]
},
"UpdateCalendarEventAttendee": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Email address of the attendee"
},
"name": {
"type": "string",
"description": "Display name of the attendee"
},
"responseStatus": {
"nullable": true,
"$ref": "#/components/schemas/CalendarEventResponseStatus"
},
"self": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee is the current user"
},
"optional": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee's attendance is optional"
},
"host": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee is the host"
}
}
},
"UpdateUnifiedCalendarEventInput": {
"type": "object",
"properties": {
"start": {
"type": "object",
"properties": {
"time": {
"type": "string",
"format": "date-time"
},
"timeZone": {
"type": "string"
}
},
"description": "Start date and time of the calendar event with timezone information"
},
"end": {
"type": "object",
"properties": {
"time": {
"type": "string",
"format": "date-time"
},
"timeZone": {
"type": "string"
}
},
"description": "End date and time of the calendar event with timezone information"
},
"title": {
"type": "string",
"description": "Title of the calendar event"
},
"description": {
"type": "string",
"nullable": true,
"description": "Detailed description of the calendar event"
},
"attendees": {
"nullable": true,
"description": "List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
"type": "array",
"items": {
"$ref": "#/components/schemas/UpdateCalendarEventAttendee"
}
},
"status": {
"nullable": true,
"example": "accepted",
"$ref": "#/components/schemas/CalendarEventStatus"
}
}
},
"RequestEmailVerificationInput": {
"type": "object",
"properties": {
@@ -1,7 +1,11 @@
import { type TFunction } from "i18next";
import { useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { List } from "@calcom/ui/components/list";
@@ -12,10 +16,18 @@ import { StepConnectionLoader } from "../components/StepConnectionLoader";
interface ConnectedAppStepProps {
nextStep: () => void;
isPageLoading: boolean;
user: RouterOutputs["viewer"]["me"]["get"];
}
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { nextStep, isPageLoading } = props;
const ConnectedVideoStepInner = ({
t,
setAnyInstalledVideoApps,
user,
}: {
t: TFunction;
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
user: RouterOutputs["viewer"]["me"]["get"];
}) => {
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
variant: "conferencing",
onlyInstalled: false,
@@ -30,47 +42,59 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
sortByMostPopular: true,
sortByInstalledFirst: true,
});
const { data } = useMeQuery();
const { t } = useLocale();
const metadata = userMetadata.parse(data?.metadata);
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
(item) => item.userCredentialIds.length > 0
);
useEffect(() => {
setAnyInstalledVideoApps(Boolean(hasAnyInstalledVideoApps));
}, [hasAnyInstalledVideoApps, setAnyInstalledVideoApps]);
if (isPending) {
return <StepConnectionLoader />;
}
const result = userMetadata.safeParse(user.metadata);
if (!result.success) {
return <StepConnectionLoader />;
}
const { data: metadata } = result;
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
return (
<>
{!isPending && (
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
return (
<li key={item.name}>
{item.name && item.logo && (
<AppConnectionItem
type={item.type}
title={item.name}
isDefault={item.slug === defaultConferencingApp}
description={item.description}
dependencyData={item.dependencyData}
logo={item.logo}
slug={item.slug}
installed={item.userCredentialIds.length > 0}
defaultInstall={
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
}
/>
)}
</li>
);
})}
</List>
)}
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
return (
<li key={item.name}>
{item.name && item.logo && (
<AppConnectionItem
type={item.type}
title={item.name}
isDefault={item.slug === defaultConferencingApp}
description={item.description}
dependencyData={item.dependencyData}
logo={item.logo}
slug={item.slug}
installed={item.userCredentialIds.length > 0}
defaultInstall={!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"}
/>
)}
</li>
);
})}
</List>
);
};
{isPending && <StepConnectionLoader />}
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { nextStep, isPageLoading, user } = props;
const { t } = useLocale();
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
return (
<>
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} t={t} user={user} />
<Button
EndIcon="arrow-right"
data-testid="save-video-button"
@@ -10,6 +10,7 @@ import { md } from "@calcom/lib/markdownIt";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { UserAvatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Editor } from "@calcom/ui/components/editor";
@@ -21,8 +22,11 @@ type FormData = {
bio: string;
};
const UserProfile = () => {
const [user] = trpc.viewer.me.get.useSuspenseQuery();
interface UserProfileProps {
user: RouterOutputs["viewer"]["me"]["get"];
}
const UserProfile = ({ user }: UserProfileProps) => {
const { t } = useLocale();
const avatarRef = useRef<HTMLInputElement>(null);
const { setValue, handleSubmit, getValues } = useForm<FormData>({
@@ -13,6 +13,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Input } from "@calcom/ui/components/form";
@@ -21,11 +22,11 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
interface IUserSettingsProps {
nextStep: () => void;
hideUsername?: boolean;
user: RouterOutputs["viewer"]["me"]["get"];
}
const UserSettings = (props: IUserSettingsProps) => {
const { nextStep } = props;
const [user] = trpc.viewer.me.get.useSuspenseQuery();
const { nextStep, user } = props;
const { t } = useLocale();
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
const telemetry = useTelemetry();
@@ -162,20 +162,20 @@ const OnboardingPage = (props: PageProps) => {
<StepCard>
<Suspense fallback={<Icon name="loader" />}>
{currentStep === "user-settings" && (
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} />
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} user={user} />
)}
{currentStep === "connected-calendar" && (
<ConnectedCalendars nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
)}
{currentStep === "connected-video" && (
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} user={user} />
)}
{currentStep === "setup-availability" && (
<SetupAvailability nextStep={goToNextStep} defaultScheduleId={user.defaultScheduleId} />
)}
{currentStep === "user-profile" && <UserProfile />}
{currentStep === "user-profile" && <UserProfile user={user} />}
</Suspense>
</StepCard>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.16",
"version": "5.5.17",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
File diff suppressed because it is too large Load Diff
+1
View File
@@ -127,6 +127,7 @@ Below is a list of props that can be passed to the booker atom.
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
## Styling
@@ -81,6 +81,7 @@ const BookerComponent = ({
timeZones,
eventMetaChildren,
roundRobinHideOrgAndTeam,
showNoAvailabilityDialog,
}: BookerProps & WrappedBookerProps) => {
const searchParams = useCompatSearchParams();
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
@@ -417,6 +418,7 @@ const BookerComponent = ({
slots={schedule?.data?.slots}
isLoading={schedule.isPending}
scrollToTimeSlots={scrollToTimeSlots}
showNoAvailabilityDialog={showNoAvailabilityDialog}
/>
</div>
)}
@@ -445,6 +447,7 @@ const BookerComponent = ({
slots={schedule?.data?.slots}
isLoading={schedule.isPending}
scrollToTimeSlots={scrollToTimeSlots}
showNoAvailabilityDialog={showNoAvailabilityDialog}
/>
</BookerSection>
@@ -57,6 +57,7 @@ export const DatePicker = ({
isLoading,
classNames,
scrollToTimeSlots,
showNoAvailabilityDialog,
}: {
event: {
data?: {
@@ -72,6 +73,7 @@ export const DatePicker = ({
isLoading?: boolean;
classNames?: DatePickerClassNames;
scrollToTimeSlots?: () => void;
showNoAvailabilityDialog?: boolean;
}) => {
const { i18n } = useLocale();
const [month, selectedDate, layout] = useBookerStore(
@@ -148,6 +150,7 @@ export const DatePicker = ({
scrollToTimeSlots={scrollToTimeSlots}
periodData={periodData}
isCompact={isCompact}
showNoAvailabilityDialog={showNoAvailabilityDialog}
/>
);
};
@@ -98,6 +98,7 @@ export interface BookerProps {
hashedLink?: string | null;
isInstantMeeting?: boolean;
teamMemberEmail?: string | null;
showNoAvailabilityDialog?: boolean;
crmOwnerRecordType?: string | null;
crmAppSlug?: string | null;
crmRecordId?: string | null;
@@ -5,9 +5,9 @@ import { SystemField } from "@calcom/features/bookings/lib/SystemField";
import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber";
import { getBookingWithResponses } from "@calcom/lib/getBooking";
import { HttpError } from "@calcom/lib/http-error";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { HttpError } from "@calcom/lib/http-error";
export const getCalEventResponses = ({
bookingFields,
+17 -8
View File
@@ -59,6 +59,8 @@ export type DatePickerProps = {
periodData?: PeriodData;
// Whether this is a compact sidebar view or main monthly view
isCompact?: boolean;
// Whether to show the no availability dialog
showNoAvailabilityDialog?: boolean;
};
const Day = ({
@@ -164,6 +166,7 @@ const Days = ({
isBookingInPast,
periodData,
isCompact,
showNoAvailabilityDialog = true,
...props
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
@@ -345,14 +348,18 @@ const Days = ({
)}
</div>
))}
{!props.isLoading && !isBookingInPast && includedDates && includedDates?.length === 0 && (
<NoAvailabilityDialog
month={month}
nextMonthButton={nextMonthButton}
browsingDate={browsingDate}
periodData={periodData}
/>
)}
{!props.isLoading &&
!isBookingInPast &&
includedDates &&
includedDates?.length === 0 &&
showNoAvailabilityDialog && (
<NoAvailabilityDialog
month={month}
nextMonthButton={nextMonthButton}
browsingDate={browsingDate}
periodData={periodData}
/>
)}
</>
);
};
@@ -374,6 +381,7 @@ const DatePicker = ({
periodType: "UNLIMITED",
},
isCompact,
showNoAvailabilityDialog,
...passThroughProps
}: DatePickerProps &
Partial<React.ComponentProps<typeof Days>> & {
@@ -484,6 +492,7 @@ const DatePicker = ({
isBookingInPast={isBookingInPast}
periodData={periodData}
isCompact={isCompact}
showNoAvailabilityDialog={showNoAvailabilityDialog}
/>
</div>
</div>
@@ -43,7 +43,7 @@ export const TeamEventTypeForm = ({
return (
<Form form={form} handleSubmit={handleSubmit}>
<div className="mt-3 space-y-6 pb-11">
<div className="mt-1 space-y-6">
<TextField
type="hidden"
labelProps={{ style: { display: "none" } }}
@@ -1,4 +1,4 @@
import { useState, Suspense } from "react";
import { useState, Suspense, useMemo } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
@@ -10,9 +10,10 @@ import {
SelectedCalendarSettingsScope,
SelectedCalendarsSettingsWebWrapperSkeleton,
} from "@calcom/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
import { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone";
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
import { TimezoneSelect as WebTimezoneSelect } from "@calcom/features/components/timezone-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import {
allowDisablingAttendeeConfirmationEmails,
@@ -565,6 +566,10 @@ export const EventAdvancedTab = ({
userEmail = removePlatformClientIdFromEmail(userEmail, platformContext.clientId);
}
const TimezoneSelect = useMemo(() => {
return isPlatform ? PlatformTimzoneSelect : WebTimezoneSelect;
}, [isPlatform]);
return (
<div className="flex flex-col space-y-4">
<calendarComponents.CalendarSettings
+3 -1
View File
@@ -327,7 +327,9 @@ export default class EventManager {
}
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const isMSTeamsWithOutlookCalendar = evt.location === MSTeamsLocationType && mainHostDestinationCalendar?.integration === "office365_calendar";
const isMSTeamsWithOutlookCalendar =
evt.location === MSTeamsLocationType &&
mainHostDestinationCalendar?.integration === "office365_calendar";
const results: Array<EventResult<Exclude<Event, AdditionalInformation>>> = [];
+1 -1
View File
@@ -33,7 +33,7 @@ export async function loadTranslations(_locale: string, _ns: string) {
const response = await fetchWithTimeout(
url,
{
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
cache: "no-store",
},
process.env.NODE_ENV === "development" ? 30000 : 3000
);
+18
View File
@@ -1,5 +1,23 @@
## 1.1.2
## 1.3.0
### Minor Changes
- [#23060](https://github.com/calcom/cal.com/pull/23060) [`293dd1a`](https://github.com/calcom/cal.com/commit/293dd1a1402fbc0748dffd5619a1b1aacaea9d60) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - fix: EventTypeSettings Atom crashes on enabling `Lock timezone on booking` in advanced tab
- [#22911](https://github.com/calcom/cal.com/pull/22911) [`d3fbc73`](https://github.com/calcom/cal.com/commit/d3fbc73a3962a9bcca6a95870b146a97f4c7d996) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
- [#23052](https://github.com/calcom/cal.com/pull/23052) [`fc2e81e`](https://github.com/calcom/cal.com/commit/fc2e81e89a6e320c7346dad3259dd51ed700fb30) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - Added showNoAvailabilityDialog prop to the Booker atom – a Boolean that controls whether the 'no availability' dialog is displayed
- [#22976](https://github.com/calcom/cal.com/pull/22976) [`abe92cd`](https://github.com/calcom/cal.com/commit/abe92cd693f33d20113f5357a960baec042e5256) Thanks [@supalarry](https://github.com/supalarry)! - feat: style calendar settings and availability overrides
- [#22956](https://github.com/calcom/cal.com/pull/22956) [`d18e233`](https://github.com/calcom/cal.com/commit/d18e23378b699e734e9dbbdf93225b02a9059ff4) Thanks [@supalarry](https://github.com/supalarry)! - booker atom: allow toggling org and team info when booking round robin
## 1.2.0
### Minor Changes
@@ -58,6 +58,7 @@ export const BookerPlatformWrapper = (
handleSlotReservation,
onTimeslotsLoaded,
startTime: customStartTime,
showNoAvailabilityDialog,
} = props;
const layout = BookerLayouts[view];
@@ -465,6 +466,7 @@ export const BookerPlatformWrapper = (
customClassNames={props.customClassNames}
eventSlug={props.eventSlug}
username={username}
showNoAvailabilityDialog={showNoAvailabilityDialog}
entity={
event?.data?.entity ?? {
considerUnpublished: false,
@@ -150,7 +150,7 @@ const EventType = forwardRef<
const description = message ? t(message) : t(err.message);
toast({ description });
onError?.(currentValues, err);
const errorObj = new Error(description);
callbacksRef.current?.onError?.(errorObj);
},
@@ -175,24 +175,27 @@ const EventType = forwardRef<
const callbacksRef = useRef<{ onSuccess?: () => void; onError?: (error: Error) => void }>({});
const handleFormSubmit = useCallback((customCallbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) => {
if (customCallbacks) {
callbacksRef.current = customCallbacks;
}
const handleFormSubmit = useCallback(
(customCallbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) => {
if (customCallbacks) {
callbacksRef.current = customCallbacks;
}
if (saveButtonRef.current) {
saveButtonRef.current.click();
} else {
form.handleSubmit((data) => {
try {
handleSubmit(data);
customCallbacks?.onSuccess?.();
} catch (error) {
customCallbacks?.onError?.(error as Error);
}
})();
}
}, [handleSubmit, form]);
if (saveButtonRef.current) {
saveButtonRef.current.click();
} else {
form.handleSubmit((data) => {
try {
handleSubmit(data);
customCallbacks?.onSuccess?.();
} catch (error) {
customCallbacks?.onError?.(error as Error);
}
})();
}
},
[handleSubmit, form]
);
const validateForm = useCallback(async () => {
const isValid = await form.trigger();
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.",
"version": "1.2.0",
"version": "1.3.0",
"scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
@@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
const DEFAULT_EXPECT_TIMEOUT = process.env.CI ? 30000 : 120000;
const DEFAULT_TEST_TIMEOUT = process.env.CI ? 60000 : 240000;
@@ -12,35 +12,35 @@ export default defineConfig({
timeout: DEFAULT_TEST_TIMEOUT,
fullyParallel: true,
reporter: [
['list'],
['html', { outputFolder: './test-results/reports/playwright-html-report', open: 'never' }],
["list"],
["html", { outputFolder: "./test-results/reports/playwright-html-report", open: "never" }],
],
outputDir: './test-results/results',
outputDir: "./test-results/results",
use: {
baseURL: 'http://localhost:4322',
locale: 'en-US',
trace: 'retain-on-failure',
baseURL: "http://localhost:4322",
locale: "en-US",
trace: "retain-on-failure",
headless,
},
projects: [
{
name: '@calcom/base',
testDir: './tests',
name: "@calcom/base",
testDir: "./tests",
testMatch: /.*\.e2e\.tsx?/,
expect: {
timeout: DEFAULT_EXPECT_TIMEOUT,
},
use: {
...devices['Desktop Chrome'],
locale: 'en-US',
...devices["Desktop Chrome"],
locale: "en-US",
},
},
],
webServer: {
command: process.env.CI
command: process.env.CI
? `yarn workspace @calcom/atoms dev-on && yarn workspace @calcom/atoms build && rm -f prisma/dev.db && yarn prisma db push && NEXT_PUBLIC_IS_E2E=1 NODE_ENV=test NEXT_PUBLIC_X_CAL_ID="${process.env.ATOMS_E2E_OAUTH_CLIENT_ID}" X_CAL_SECRET_KEY="${process.env.ATOMS_E2E_OAUTH_CLIENT_SECRET}" NEXT_PUBLIC_CALCOM_API_URL="${process.env.ATOMS_E2E_API_URL}" VITE_BOOKER_EMBED_OAUTH_CLIENT_ID="${process.env.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED}" VITE_BOOKER_EMBED_API_URL="${process.env.ATOMS_E2E_API_URL}" ORGANIZATION_ID=${process.env.ATOMS_E2E_ORG_ID} yarn dev:e2e`
: `rm -f prisma/dev.db && yarn prisma db push && yarn dev:e2e`,
url: 'http://localhost:4322',
url: "http://localhost:4322",
timeout: 600_000,
reuseExistingServer: !process.env.CI,
},
@@ -1,19 +1,19 @@
import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";
test('create event type using CreateEventTypeAtom', async ({ page }) => {
await page.goto('/');
test("create event type using CreateEventTypeAtom", async ({ page }) => {
await page.goto("/");
await page.goto('/event-types');
await page.goto("/event-types");
await expect(page).toHaveURL('/event-types');
await expect(page.locator('body')).toBeVisible();
await expect(page).toHaveURL("/event-types");
await page.fill('[data-testid="event-type-quick-chat"]', 'e2e event');
await expect(page.locator("body")).toBeVisible();
await page.fill('textarea[placeholder="A quick video meeting."]', 'This is an e2e test event description');
await page.fill('[data-testid="event-type-quick-chat"]', "e2e event");
await page.waitForSelector('button[type="submit"]:has-text("Continue")', { state: 'visible' });
await page.fill('textarea[placeholder="A quick video meeting."]', "This is an e2e test event description");
await page.waitForSelector('button[type="submit"]:has-text("Continue")', { state: "visible" });
await page.click('button[type="submit"]:has-text("Continue")');
await expect(page.locator('h1:has-text("E2e Event")')).toBeVisible();
@@ -128,5 +128,3 @@ export class DeletePrivateLinkOutput {
message: string;
};
}
@@ -95,21 +95,18 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) =>
teamNames.map((item) => slugify(item)).includes(slug)
);
await Promise.all(
moveTeams
.filter((team) => team.shouldMove)
.map(async ({ id: teamId, newSlug }) => {
await moveTeam({
teamId,
newSlug,
org: {
...organization,
ownerId: organizationOwner.id,
},
creationSource,
});
})
);
// Process team migrations sequentially to avoid race conditions - Moving a team invites members to the organization again and there are known unique constraints failure in membership and profile creation if done in parallel and a user happens to be part of more than one team
for (const team of moveTeams.filter((team) => team.shouldMove)) {
await moveTeam({
teamId: team.id,
newSlug: team.newSlug,
org: {
...organization,
ownerId: organizationOwner.id,
},
creationSource,
});
}
if (duplicatedSlugs.length === teamNames.length) {
return { duplicatedSlugs };
@@ -0,0 +1,438 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { prisma } from "@calcom/prisma";
import type { Team, User, Membership, Profile } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import inviteMemberHandler, { inviteMembersWithNoInviterPermissionCheck } from "./inviteMember.handler";
// Helper functions for database verification
async function verifyProfileExists(userId: number, organizationId: number): Promise<Profile | null> {
return await prisma.profile.findUnique({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
});
}
async function verifyMembershipExists(userId: number, teamId: number): Promise<Membership | null> {
return await prisma.membership.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
}
async function verifyUserOrganizationConsistency(userId: number): Promise<{
profileCount: number;
acceptedMembershipCount: number;
pendingMembershipCount: number;
}> {
const profiles = await prisma.profile.count({ where: { userId } });
const acceptedMemberships = await prisma.membership.count({
where: { userId, accepted: true },
});
const pendingMemberships = await prisma.membership.count({
where: { userId, accepted: false },
});
return {
profileCount: profiles,
acceptedMembershipCount: acceptedMemberships,
pendingMembershipCount: pendingMemberships,
};
}
// Test data creation helpers with unique identifiers
function generateUniqueId() {
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
}
async function createTestUser(data: { email: string; username: string; name?: string }): Promise<User> {
const uniqueId = generateUniqueId();
const uniqueEmail = data.email.includes("@")
? data.email.replace("@", `-${uniqueId}@`)
: `${data.email}-${uniqueId}@example.com`;
const uniqueUsername = `${data.username}-${uniqueId}`;
// Pre-emptive cleanup to avoid conflicts
try {
await prisma.user.deleteMany({
where: { OR: [{ email: uniqueEmail }, { username: uniqueUsername }] },
});
} catch {}
return await prisma.user.create({
data: {
email: uniqueEmail,
username: uniqueUsername,
name: data.name || uniqueUsername,
},
});
}
async function createTestTeam(data: {
name: string;
slug: string;
isOrganization?: boolean;
parentId?: number;
metadata?: any;
organizationSettings?: {
orgAutoAcceptEmail: string;
isOrganizationVerified?: boolean;
};
}): Promise<Team> {
const uniqueId = generateUniqueId();
const uniqueSlug = `${data.slug}-${uniqueId}`;
// Pre-emptive cleanup to avoid conflicts
try {
await prisma.team.deleteMany({
where: { slug: uniqueSlug },
});
} catch {
// Ignore cleanup errors
}
const team = await prisma.team.create({
data: {
name: `${data.name} ${uniqueId}`,
slug: uniqueSlug,
isOrganization: data.isOrganization || false,
parentId: data.parentId,
metadata: data.metadata,
},
});
// If this is an organization and organizationSettings are provided, create them
if (data.isOrganization && data.organizationSettings) {
await prisma.organizationSettings.create({
data: {
organizationId: team.id,
orgAutoAcceptEmail: data.organizationSettings.orgAutoAcceptEmail,
isOrganizationVerified: data.organizationSettings.isOrganizationVerified,
},
});
}
return team;
}
// Mock translations fetch
vi.mock("node-fetch", () => ({
default: vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
),
}));
// Mock email sending
vi.mock("@calcom/emails", () => ({
sendTeamInviteEmail: vi.fn(() => Promise.resolve()),
sendOrganizationAutoJoinEmail: vi.fn(() => Promise.resolve()),
}));
// Mock for getTranslation
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn(() => Promise.resolve((key: string) => key)),
}));
describe("inviteMember.handler Integration Tests", () => {
// Track created test data for cleanup
let testUsers: User[] = [];
let testTeams: Team[] = [];
beforeEach(async () => {
// Reset tracking arrays
testUsers = [];
testTeams = [];
});
afterEach(async () => {
// Clean up test data in reverse dependency order
const userIds = testUsers.map((u) => u.id);
const teamIds = testTeams.map((t) => t.id);
if (userIds.length > 0 || teamIds.length > 0) {
await prisma.$transaction([
// Clean verification tokens for test users
prisma.verificationToken.deleteMany({
where: { identifier: { in: testUsers.map((u) => u.email) } },
}),
// Clean memberships
prisma.membership.deleteMany({
where: {
OR: [{ userId: { in: userIds } }, { teamId: { in: teamIds } }],
},
}),
// Clean profiles
prisma.profile.deleteMany({
where: {
OR: [{ userId: { in: userIds } }, { organizationId: { in: teamIds } }],
},
}),
// Clean teams
prisma.team.deleteMany({
where: { id: { in: teamIds } },
}),
// Clean users
prisma.user.deleteMany({
where: { id: { in: userIds } },
}),
]);
}
});
// Helper to track created entities
function trackUser(user: User): User {
testUsers.push(user);
return user;
}
function trackTeam(team: Team): Team {
testTeams.push(team);
return team;
}
// Helper to create a proper user context for inviteMemberHandler
function createUserContext(user: User, organizationId: number | null = null): NonNullable<TrpcSessionUser> {
const baseUser = {
id: user.id,
email: user.email,
username: user.username || "",
name: user.name,
emailVerified: user.emailVerified,
avatarUrl: user.avatarUrl,
avatar: `https://example.com/avatar.png`,
locale: user.locale || "en",
timeZone: user.timeZone || "UTC",
role: user.role,
allowDynamicBooking: user.allowDynamicBooking || true,
completedOnboarding: user.completedOnboarding,
twoFactorEnabled: user.twoFactorEnabled,
identityProvider: user.identityProvider,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
theme: user.theme,
createdDate: user.createdDate,
disableImpersonation: user.disableImpersonation,
movedToProfileId: user.movedToProfileId,
organizationId,
organization: organizationId
? {
id: organizationId,
isOrgAdmin: true,
metadata: {},
requestedSlug: null,
}
: {
id: null,
isOrgAdmin: false,
metadata: {},
requestedSlug: null,
},
defaultBookerLayouts: null,
profile: organizationId
? {
id: user.id,
upId: `${user.username}-${organizationId}`,
username: user.username || "",
userId: user.id,
organizationId,
organization: {
id: organizationId,
name: "Test Organization",
slug: "test-org",
isOrganization: true,
metadata: {},
},
}
: {
id: user.id,
upId: `${user.username}-default`,
username: user.username || "",
userId: user.id,
organizationId: null,
organization: null,
},
};
return baseUser as unknown as NonNullable<TrpcSessionUser>;
}
describe("Organization Direct Invite Flow", () => {
it("should not auto-accept user's membership that was unaccepted when migrating to org with non-matching autoAcceptEmailDomain", async () => {
const organization = trackTeam(
await createTestTeam({
name: "Test Organization",
slug: "test-org",
isOrganization: true,
metadata: {},
organizationSettings: {
orgAutoAcceptEmail: "company.com",
isOrganizationVerified: true,
},
})
);
const team = trackTeam(
await createTestTeam({
name: "Test Team",
slug: "test-team",
isOrganization: false,
parentId: organization.id,
})
);
const inviterUser = trackUser(
await createTestUser({
email: "inviter@company.com",
username: "inviter",
})
);
await prisma.membership.create({
data: {
userId: inviterUser.id,
teamId: organization.id,
role: MembershipRole.OWNER,
accepted: true,
},
});
// Act: Invite user with non-matching domain
const nonMatchingUser = trackUser(
await createTestUser({
email: "external@other.com",
username: "external",
})
);
await prisma.membership.create({
data: {
userId: nonMatchingUser.id,
teamId: team.id,
role: MembershipRole.MEMBER,
accepted: false,
},
});
await inviteMemberHandler({
ctx: {
user: createUserContext(inviterUser, organization.id),
session: {} as any,
} as any,
input: {
teamId: organization.id,
usernameOrEmail: nonMatchingUser.email,
role: MembershipRole.MEMBER,
language: "en",
creationSource: "WEBAPP" as const,
},
});
// Assert: No profile should be created
const profile = await verifyProfileExists(nonMatchingUser.id, organization.id);
expect(profile).toBeFalsy();
// Membership should not be auto-accepted
const membership = await verifyMembershipExists(nonMatchingUser.id, organization.id);
expect(membership).toBeTruthy();
expect(membership?.accepted).toBe(false);
// Verify that the user is not a member of the organization
const nonMatchingUserMembershipWithTeam = await verifyMembershipExists(nonMatchingUser.id, team.id);
expect(nonMatchingUserMembershipWithTeam).toBeTruthy();
expect(nonMatchingUserMembershipWithTeam?.accepted).toBe(false);
});
it("should auto-accept user's membership that was unaccepted when migrating to org with matching autoAcceptEmailDomain", async () => {
// Setup: User with unverified email who has an unaccepted team membership
const unverifiedUserWithUnacceptedMembership = trackUser(
await createTestUser({
email: "john.doe@acme.com",
username: "john.doe",
})
);
// Add unverified email status - will need to update in DB
await prisma.user.update({
where: { id: unverifiedUserWithUnacceptedMembership.id },
data: { emailVerified: null },
});
const organization = trackTeam(
await createTestTeam({
name: "Acme",
slug: "acme",
isOrganization: true,
metadata: {},
organizationSettings: {
orgAutoAcceptEmail: "acme.com", // Matches user's email domain
isOrganizationVerified: true,
},
})
);
// Create a regular team first where user has unaccepted membership
const regularTeam = trackTeam(
await createTestTeam({
name: "Regular Team",
slug: "regular-team",
isOrganization: false,
parentId: organization.id,
})
);
// Add unaccepted team membership
await prisma.membership.create({
data: {
teamId: regularTeam.id,
userId: unverifiedUserWithUnacceptedMembership.id,
accepted: false, // KEY: Unaccepted membership
role: MembershipRole.MEMBER,
},
});
// Act: Simulate migration by inviting the user to the organization
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: organization.id,
language: "en",
creationSource: "WEBAPP" as const,
orgSlug: organization.slug,
invitations: [
{
usernameOrEmail: unverifiedUserWithUnacceptedMembership.email,
role: MembershipRole.MEMBER,
},
],
});
const orgMembership = await verifyMembershipExists(
unverifiedUserWithUnacceptedMembership.id,
organization.id
);
const profile = await verifyProfileExists(unverifiedUserWithUnacceptedMembership.id, organization.id);
expect(profile?.userId).toBe(unverifiedUserWithUnacceptedMembership.id);
expect(profile?.organizationId).toBe(organization.id);
// Verify original team membership is accepted now
const originalMembership = await verifyMembershipExists(
unverifiedUserWithUnacceptedMembership.id,
regularTeam.id
);
expect(originalMembership?.accepted).toBe(true);
});
});
});
@@ -1,3 +1,8 @@
/**
* This file is deprecated in favour of inviteMember.handler.integration-test.ts
*
* It mocks a lot of things that are untested and integration tests make more sense for this handler
*/
import { scenarios as checkRateLimitAndThrowErrorScenarios } from "../../../../../../../tests/libs/__mocks__/checkRateLimitAndThrowError";
import { mock as getTranslationMock } from "../../../../../../../tests/libs/__mocks__/getTranslation";
import {
@@ -409,7 +414,7 @@ describe("inviteMemberHandler", () => {
// Call inviteMembersWithNoInviterPermissionCheck directly with isDirectUserAction=false
const { inviteMembersWithNoInviterPermissionCheck } = await import("./inviteMember.handler");
const result = await inviteMembersWithNoInviterPermissionCheck({
inviterName: loggedInUser.name,
teamId: team.id,
@@ -487,7 +492,7 @@ describe("inviteMemberHandler", () => {
// Call inviteMembersWithNoInviterPermissionCheck directly with isDirectUserAction=false
const { inviteMembersWithNoInviterPermissionCheck } = await import("./inviteMember.handler");
const result = await inviteMembersWithNoInviterPermissionCheck({
inviterName: "Test Inviter",
teamId: team.id,
@@ -517,6 +522,7 @@ describe("inviteMemberHandler", () => {
);
});
});
it("When rate limit exceeded, it should throw error", async () => {
const userToBeInvited = buildExistingUser({
id: 1,
@@ -39,6 +39,9 @@ const mockedReturnSuccessCheckPerms = {
role: MembershipRole.ADMIN,
userId: 1,
teamId: 1,
createdAt: null,
updatedAt: null,
customRoleId: null,
team: {
id: 1,
name: "Team A",
@@ -95,6 +98,11 @@ const mockedRegularTeam: TeamWithParent = {
smsLockState: "LOCKED",
createdByOAuthClientId: null,
smsLockReviewedByAdmin: false,
hideTeamProfileLink: false,
rrResetInterval: null,
rrTimestampBasis: "CREATED_AT",
bookingLimits: null,
includeManagedEventsInLimits: false,
};
const mockedSubTeam = {
@@ -263,9 +271,13 @@ describe("Invite Member Utils", () => {
adminGetsNoSlotsNotification: false,
isAdminReviewed: false,
isAdminAPIEnabled: false,
allowSEOIndexing: false,
orgProfileRedirectsToVerifiedDomain: false,
disablePhoneOnlySMSNotifications: false,
},
slug: "abc",
parent: null,
isOrganization: true,
};
const result = getOrgState(true, { ...mockedRegularTeam, ...team });
expect(result).toEqual({
@@ -293,6 +305,9 @@ describe("Invite Member Utils", () => {
adminGetsNoSlotsNotification: false,
isAdminReviewed: false,
isAdminAPIEnabled: false,
allowSEOIndexing: false,
orgProfileRedirectsToVerifiedDomain: false,
disablePhoneOnlySMSNotifications: false,
},
},
};
@@ -410,6 +425,7 @@ describe("Invite Member Utils", () => {
...mockedRegularTeam,
parentId: null,
id: inviteeOrgId,
isOrganization: true,
};
expect(canBeInvited(inviteeWithOrg, organization)).toBe(INVITE_STATUS.USER_ALREADY_INVITED_OR_MEMBER);
});
@@ -445,12 +461,33 @@ describe("Invite Member Utils", () => {
const organization = {
...mockedRegularTeam,
id: organizationIdBeingInvitedTo,
isOrganization: true,
};
expect(canBeInvited(inviteeWithOrg, organization)).toBe(
INVITE_STATUS.USER_MEMBER_OF_OTHER_ORGANIZATION
);
});
it("should return CAN_BE_INVITED if the user being invited has a profile with the organization already", () => {
const organizationId = 3;
const inviteeWithOrg: UserWithMembership = {
...invitee,
profiles: [
getSampleProfile({
organizationId: organizationId,
}),
],
teams: [],
};
const organization = {
...mockedRegularTeam,
id: organizationId,
isOrganization: true,
};
expect(canBeInvited(inviteeWithOrg, organization)).toBe(INVITE_STATUS.CAN_BE_INVITED);
});
it("should return USER_MEMBER_OF_OTHER_ORGANIZATION if the invitee is being invited to a sub-team in an organization but he belongs to another organization", () => {
const inviteeOrganizationId = 2;
const subTeamOrganizationId = 3;
@@ -160,10 +160,27 @@ export function canBeInvited(invitee: UserWithMembership, team: TeamWithParent)
return INVITE_STATUS.USER_PENDING_MEMBER_OF_THE_ORG;
}
const hasDifferentOrganizationProfile = invitee.profiles.some((profile) => {
const isRegularTeam = !team.isOrganization && !team.parentId;
if (isRegularTeam) {
// ⚠️ Inviting to a regular team but the user has a profile with some organization
return true;
}
const isOrganization = team.isOrganization && !team.parentId;
if (isOrganization) {
// ⚠️ User has profile with different organization than the organization being invited to
return profile.organizationId !== team.id;
}
// ⚠️ User having profile with an organization is invited to join a sub-team that is not part of the organization
return profile.organizationId != team.parentId;
});
if (
!ENABLE_PROFILE_SWITCHER &&
// Member of an organization is invited to join a team that is not a subteam of the organization
invitee.profiles.find((profile) => profile.organizationId != team.parentId)
// User having profile with an organization is invited to join a sub-team that is not part of the organization
hasDifferentOrganizationProfile
) {
return INVITE_STATUS.USER_MEMBER_OF_OTHER_ORGANIZATION;
}
@@ -828,7 +845,7 @@ export async function handleExistingUsersInvites({
if (parentOrganization) {
const parsedOrg = getParsedTeam(parentOrganization);
// Create profiles if needed
await Promise.all([
await Promise.all(
autoJoinUsers
.concat(regularUsers)
.filter((u) => u.needToCreateProfile)
@@ -841,8 +858,8 @@ export async function handleExistingUsersInvites({
},
organizationId: parsedOrg.id,
})
),
]);
)
);
}
} else {
const organization = team;
@@ -877,6 +894,22 @@ export async function handleExistingUsersInvites({
role: user.newRole,
},
});
// If auto-accepting into org, also accept any pending sub-team memberships
if (shouldAutoAccept) {
await prisma.membership.updateMany({
where: {
userId: user.id,
accepted: false,
team: {
parentId: organization.id,
},
},
data: {
accepted: true,
},
});
}
return {
...user,
profile,
@@ -106,7 +106,7 @@ export const Select = <
),
indicatorsContainer: (state) =>
cx(
"flex items-center justify-center mt-0.5",
"flex !items-start justify-center mt-0.5 h-full",
state.selectProps.menuIsOpen
? hasMultiLastIcons
? "[&>*:last-child]:rotate-180 [&>*:last-child]:transition-transform [&>*:last-child]:w-4 [&>*:last-child]:h-4"