Compare commits

...
Author SHA1 Message Date
emrysal 2a0991c259 chore: release v5.6.1
Post release / release (push) Canceled after 0s
2025-08-18 23:55:48 +00:00
Eunjae LeeandDevin AI 2e454c9723 refactor: rename insights routing service files (#22917)
- Rename insightsRoutingDI.ts -> InsightsRoutingDIService.ts
- Rename insightsRoutingBase.ts -> InsightsRoutingBaseService.ts
- Update all import statements to use new file names
- No functional changes, only file renames and import updates

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

* update

* Update role-management.factory.ts

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

* Fix tsconfig.json paths path

* chore: fix root path on tsconfig.paths

* chore: add baseUrl to tsconfig.json

* chore: tsconfig.json issue

* try: CI without tsconfig paths

* chore: add only local paths on tsconfig.json

* chore: Fix docs

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

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

Fixes issue where canceled payments showed success page on rebooking

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

* test: add unit test for payment retry scenario

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

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

* update

* fix: restore calendar event deletion for round robin reassignment

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

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

* Update roundRobinReassignment.test.ts

* Update EventManager.ts

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

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

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

---------

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

* fixes

* revert api-v2 specific changes

* Add more tests

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

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

* refactor: Rename memberId to userId in TeamService methods

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

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

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

* package.json version

* fix unit teswtes

* No specs file

* fix unit tests

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

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

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

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

* Making tests clearer and easier to undestand

* chore: bump platform libs

* chore: bump platform libs

---------

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

* Create `getCalcomUrl` function

* Use `getCalcomURl` in generate metadata

* Use `getCalcomURl` in `buildCanonical`

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

* followup test
2025-08-17 20:26:14 +01:00
Anik Dhabal Babu 469a942b5a fix: multiple booking duration isn't working (#23140) 2025-08-17 17:08:27 +00:00
keithwillcode 0afe6cf5d1 chore: release v5.6.0
Post release / release (push) Canceled after 0s
2025-08-15 15:32:31 +00:00
github-actions[bot] 75a161fe35 chore: version packages (#23120)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-15 13:13:03 +00:00
Morgan eb1ed107ba chore: bump atoms changeset silentlyHandleCalendarFailures (#23119)
* chore: bump atoms changeset silentlyHandleCalendarFailures

* update dock booker

* fixup! update dock booker
2025-08-15 12:40:10 +00:00
13e2e340ac refactor: convert handleNotificationWhenNoSlots to service class with DI (#23055)
* refactor: convert handleNotificationWhenNoSlots to service class with DI

- Convert standalone handleNotificationWhenNoSlots function to NoSlotsNotificationService class
- Add INoSlotsNotificationService interface following existing patterns
- Create NoSlotsNotification DI module and add to container
- Add NO_SLOTS_NOTIFICATION_SERVICE tokens to DI_TOKENS
- Inject service into AvailableSlotsService dependencies
- Update AvailableSlotsService to use injected service instead of direct function call
- Update all test cases to use service class pattern
- Follows existing DI patterns used by other services like BusyTimesService and CheckBookingLimitsService

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

* refactor: move Prisma calls to repositories and create dedicated DI container

- Add findOrganizationSettingsBySlug and findTeamSlugById methods to TeamRepository
- Add findTeamAdminsByTeamId method to MembershipRepository
- Create MembershipRepository DI module and tokens
- Update NoSlotsNotificationService to inject TeamRepository, MembershipRepository, and Redis client
- Create dedicated DI container for NoSlotsNotificationService
- Update AvailableSlots DI container to include MembershipRepository
- Replace direct Prisma and Redis calls with repository methods and injected client
- Update tests to use DI container instead of direct service instantiation
- Fix Redis interface import path

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

* chore: DI noSlotsNotification service

* chore: bump library

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-08-15 08:30:04 -03:00
0af62da16b feat: add dry run events for SDK (dryRunBookingSuccessfulV2 and dryRunRescheduleBookingSuccessfulV2) (#23072)
* feat: add dry run events for SDK (dryRunBookingSuccessfulV2 and dryRunRescheduleBookingSuccessfulV2)

- Add new event types dryRunBookingSuccessfulV2 and dryRunRescheduleBookingSuccessfulV2 to EventDataMap
- Create payload functions that exclude uid field for dry run events
- Fire dry run events when isDryRun is true in both regular and recurring booking flows
- Add unit tests to verify dry run event payloads are correct and exclude uid field
- Ensures dry run mode no longer skips event firing but uses appropriate dry run variants

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

* refactor: make dry run events more DRY by reusing types and payload logic

- Create BaseBookingEventPayload type to eliminate duplication between regular and dry run events
- Use intersection types to compose final event types in sdk-action-manager.ts
- Create getBaseBookingEventPayload function for common payload generation logic
- Export dry run payload functions and import them in tests instead of redefining
- Fix missing videoCallUrl field in rescheduleBookingSuccessfulV2 type

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

* Remove test file

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-15 05:21:08 +00:00
emrysal 9b323cbcc8 chore: release v5.5.18
Post release / release (push) Canceled after 0s
2025-08-15 01:13:15 +00:00
18a0e2d75c fix: event not removed from calendar when reassigned (#23099)
* fix: event not removed from calendar when reassign

* fix test

* tweak

* add test

* test: add comprehensive test for calendar event deletion during round robin reassignment

- Remove existing tests and replace with focused test for organizer change scenario
- Verify deleteEventsAndMeetings is called when organizer changes
- Mock reschedule method to simulate real behavior while tracking deletion calls
- Test verifies original host's calendar events are properly deleted
- Ensures booking reassignment and email notifications work correctly

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

* update

* revert roundRobinReassignment.test.ts

* revert

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-14 20:42:36 +00:00
Benny Joo 87c89a9968 perf: check feature flag in layout level for /settings/organizations/new routes (#23057)
* remove getServerSideProps

* add layout

* update pages
2025-08-14 13:12:21 -07:00
e237749abd fix: gracefully handle calendar failures when bypassBusyCalendarTimes is enabled (#23061)
* fix: gracefully handle calendar failures when bypassBusyCalendarTimes is enabled

- When _bypassCalendarBusyTimes parameter is true and third-party calendar fetching fails
- System now falls back to internal bookings only instead of throwing error
- Maintains existing error behavior when bypass is disabled

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

* feat: add _silentCalendarFailures parameter to handle calendar failures gracefully

- Add silentlyHandleCalendarFailures parameter to getBusyTimes service
- Add _silentCalendarFailures parameter to getUserAvailability schema
- Add _silentCalendarFailures parameter to tRPC slots endpoint
- Add _silentCalendarFailures parameter to API v2 slots/available endpoint
- Parameter attempts calendar calls first, falls back silently on failure
- Different from bypassCalendarBusyTimes which skips calendar calls entirely

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

* refactor: remove bypassBusyCalendarTimes logic from error handling

- Keep only silentlyHandleCalendarFailures logic in getBusyTimes error handling
- Remove redundant bypassBusyCalendarTimes condition from calendar failure handling
- Maintain existing bypassBusyCalendarTimes logic that skips calendar calls entirely

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

* feat: add silentlyHandleCalendarFailures prop to Booker atom

- Pass silentlyHandleCalendarFailures prop from BookerPlatformWrapper to useAvailableSlots
- Forward prop as _silentCalendarFailures parameter to API v2 slots/available endpoint
- Complete the parameter flow from Booker atom to underlying service layer
- Enables silent handling of calendar provider failures in Booker component

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

* chore: fix type and bump library

* chore: bump library

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-08-14 18:41:53 +03:00
Alex van AndelandDevin AI 994a36d039 fix: add authentication guard to /apps/installed routes (#23092)
* fix: add authentication guard to /apps/installed routes

- Add session validation before making tRPC calls in server component
- Redirect unauthenticated users to /auth/login instead of throwing 500 errors
- Follows existing authentication patterns used throughout the codebase
- Fixes 500 error when accessing /apps/installed/calendar and other categories

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Missing return; was that so hard, DevinAI?

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-14 14:50:56 +00:00
Benny Joo c989817373 fix: more precise error code for event type duplication handler (#23059)
* fix: unique constraint violation

* refactor

* add test

* fix space

* clean up
2025-08-14 11:35:12 -03:00
97 changed files with 2612 additions and 1361 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+1 -1
View File
@@ -199,7 +199,7 @@ There's some settings that you'll need to setup.
Under Vercel > Your API Project > Settings
In General > Build & Development Settings
BUILD COMMAND: `yarn turbo run build --scope=@calcom/api --include-dependencies --no-deps`
BUILD COMMAND: `yarn turbo run build --filter=@calcom/api --no-deps`
OUTPUT DIRECTORY: `apps/api/.next`
In Git > Ignored Build Step
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.306",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.314",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -1,6 +1,7 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
@@ -12,6 +13,7 @@ import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -31,6 +33,7 @@ import { Module } from "@nestjs/common";
PrismaTeamRepository,
RedisService,
PrismaFeaturesRepository,
PrismaMembershipRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
@@ -38,6 +41,7 @@ import { Module } from "@nestjs/common";
BusyTimesService,
FilterHostsService,
QualifiedHostsService,
NoSlotsNotificationService,
],
exports: [AvailableSlotsService],
})
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaMembershipRepository as PrismaMembershipRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaMembershipRepository extends PrismaMembershipRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -10,6 +10,7 @@ import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
@@ -35,7 +36,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
checkBookingLimitsService: CheckBookingLimitsService,
cacheService: CacheService,
userAvailabilityService: UserAvailabilityService,
busyTimesService: BusyTimesService
busyTimesService: BusyTimesService,
noSlotsNotificationService: NoSlotsNotificationService
) {
super({
oooRepo: oooRepoDependency,
@@ -53,6 +55,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
busyTimesService,
qualifiedHostsService,
featuresRepo: featuresRepository,
noSlotsNotificationService,
});
}
}
@@ -0,0 +1,21 @@
import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { NoSlotsNotificationService as BaseNoSlotsNotificationService } from "@calcom/platform-libraries/slots";
@Injectable()
export class NoSlotsNotificationService extends BaseNoSlotsNotificationService {
constructor(
teamRepository: PrismaTeamRepository,
membershipRepository: PrismaMembershipRepository,
redisService: RedisService
) {
super({
teamRepo: teamRepository,
membershipRepo: membershipRepository,
redisClient: redisService,
});
}
}
@@ -2,6 +2,8 @@ import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inpu
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
@@ -62,10 +64,24 @@ export class OrganizationsMembershipService {
}
async deleteOrgMembership(organizationId: number, membershipId: number) {
const membership = await this.organizationsMembershipRepository.deleteOrgMembership(
// Get the membership first to get the userId
const membership = await this.organizationsMembershipRepository.findOrgMembership(
organizationId,
membershipId
);
if (!membership) {
throw new NotFoundException(
`Membership with id ${membershipId} within organization id ${organizationId} not found`
);
}
await TeamService.removeMembers({
teamIds: [organizationId],
userIds: [membership.userId],
isOrg: true,
});
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
@@ -20,7 +20,6 @@ import {
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
@@ -59,7 +58,6 @@ export class OrganizationsTeamsMembershipsController {
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private teamsEventTypesService: TeamsEventTypesService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@@ -127,8 +125,6 @@ export class OrganizationsTeamsMembershipsController {
membershipId
);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
@@ -3,6 +3,8 @@ import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/member
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsMembershipsService {
constructor(
@@ -58,11 +60,21 @@ export class OrganizationsTeamsMembershipsService {
}
async deleteOrgTeamMembership(organizationId: number, teamId: number, membershipId: number) {
const teamMembership = await this.organizationsTeamsMembershipsRepository.deleteOrgTeamMembershipById(
// First get the membership to get the userId
const teamMembership = await this.organizationsTeamsMembershipsRepository.findOrgTeamMembership(
organizationId,
teamId,
membershipId
);
if (!teamMembership) {
throw new NotFoundException(
`Membership with id ${membershipId} not found in team ${teamId} of organization ${organizationId}`
);
}
await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false });
return teamMembership;
}
}
@@ -12,7 +12,12 @@ import {
EMAIL,
WorkflowEmailAttendeeStepDto,
} from "@/modules/workflows/inputs/workflow-step.input";
import { BEFORE_EVENT, DAY } from "@/modules/workflows/inputs/workflow-trigger.input";
import {
BEFORE_EVENT,
DAY,
OnAfterEventTriggerDto,
OnBeforeEventTriggerDto,
} from "@/modules/workflows/inputs/workflow-trigger.input";
// Adjust path if needed
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
import { INestApplication } from "@nestjs/common";
@@ -255,6 +260,10 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
"+37255555555"
);
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
const trigger = sampleCreateWorkflowDto.trigger as OnBeforeEventTriggerDto;
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
createdWorkflowId = responseBody.data.id;
createdWorkflow = responseBody.data;
});
@@ -330,6 +339,13 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
expect(step1).toBeDefined();
const partialUpdateDto: Partial<CreateWorkflowDto> = {
name: updatedName,
trigger: {
type: "afterEvent",
offset: {
unit: "minute",
value: 10,
},
},
steps: step1 ? [{ ...step1, sender: "updatedSender" } as WorkflowEmailAttendeeStepDto] : [],
};
expect(createdWorkflowId).toBeDefined();
@@ -348,6 +364,10 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
step1 && expect(responseBody.data.steps[0].id).toEqual(step1.id);
expect(responseBody.data.steps[0].sender).toEqual("updatedSender");
expect(responseBody.data.steps[1]?.id).toBeUndefined();
const trigger = partialUpdateDto.trigger as OnAfterEventTriggerDto;
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
});
});
@@ -156,17 +156,4 @@ export class TeamsEventTypesService {
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.teamsEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.teamsEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
}
}
@@ -3,7 +3,6 @@ import { API_KEY_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
@@ -45,10 +44,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
export class TeamsMembershipsController {
private logger = new Logger("TeamsMembershipsController");
constructor(
private teamsMembershipsService: TeamsMembershipsService,
private teamsEventTypesService: TeamsEventTypesService
) {}
constructor(private teamsMembershipsService: TeamsMembershipsService) {}
@Roles("TEAM_ADMIN")
@Post("/")
@@ -127,7 +123,6 @@ export class TeamsMembershipsController {
membershipId,
body
);
if (!currentMembership.accepted && updatedMembership.accepted) {
try {
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
@@ -151,8 +146,6 @@ export class TeamsMembershipsController {
): Promise<DeleteTeamMembershipOutput> {
const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId);
await this.teamsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
@@ -3,6 +3,8 @@ import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/up
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
@Injectable()
export class TeamsMembershipsService {
constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {}
@@ -41,10 +43,15 @@ export class TeamsMembershipsService {
}
async deleteTeamMembership(teamId: number, membershipId: number) {
const teamMembership = await this.teamsMembershipsRepository.deleteTeamMembershipById(
teamId,
membershipId
);
// First get the membership to get the userId
const teamMembership = await this.teamsMembershipsRepository.findTeamMembership(teamId, membershipId);
if (!teamMembership) {
throw new NotFoundException(`Membership with id ${membershipId} not found in team ${teamId}`);
}
await TeamService.removeMembers({ teamIds: [teamId], userIds: [teamMembership.userId], isOrg: false });
return teamMembership;
}
}
@@ -1,7 +1,7 @@
import { ApiProperty } from "@nestjs/swagger";
import { TimeUnit, WorkflowTriggerEvents } from "@prisma/client";
import { Type } from "class-transformer";
import { IsNumber, ValidateNested } from "class-validator";
import { IsIn, IsNumber, IsString, ValidateNested } from "class-validator";
export const BEFORE_EVENT = "beforeEvent";
export const EVENT_CANCELLED = "eventCancelled";
@@ -68,6 +68,8 @@ export class WorkflowTriggerOffsetDto {
value!: number;
@ApiProperty({ description: "Unit for the offset time", example: HOUR })
@IsString()
@IsIn(TIME_UNITS)
unit!: TimeUnitType;
}
@@ -75,6 +77,8 @@ export class BaseWorkflowTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([WORKFLOW_TRIGGER_TYPES])
type!: WorkflowTriggerType;
}
@@ -82,6 +86,8 @@ export class OnCreationTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([NEW_EVENT])
type: typeof NEW_EVENT = NEW_EVENT;
}
@@ -89,12 +95,16 @@ export class OnRescheduleTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([RESCHEDULE_EVENT])
type: typeof RESCHEDULE_EVENT = RESCHEDULE_EVENT;
}
export class OnCancelTriggerDto {
@ApiProperty({
description: "Trigger type for the workflow",
})
@IsString()
@IsIn([EVENT_CANCELLED])
type: typeof EVENT_CANCELLED = EVENT_CANCELLED;
}
@@ -113,6 +123,8 @@ export class OnBeforeEventTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: BEFORE_EVENT,
})
@IsString()
@IsIn([BEFORE_EVENT])
type: typeof BEFORE_EVENT = BEFORE_EVENT;
}
@@ -121,6 +133,8 @@ export class OnAfterEventTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_EVENT,
})
@IsString()
@IsIn([AFTER_EVENT])
type: typeof AFTER_EVENT = AFTER_EVENT;
}
@@ -129,6 +143,8 @@ export class OnAfterCalVideoGuestsNoShowTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
})
@IsString()
@IsIn([AFTER_GUESTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_GUESTS_CAL_VIDEO_NO_SHOW = AFTER_GUESTS_CAL_VIDEO_NO_SHOW;
}
@@ -137,5 +153,7 @@ export class OnAfterCalVideoHostsNoShowTriggerDto extends TriggerOffsetDTO {
description: "Trigger type for the workflow",
example: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
})
@IsString()
@IsIn([AFTER_HOSTS_CAL_VIDEO_NO_SHOW])
type: typeof AFTER_HOSTS_CAL_VIDEO_NO_SHOW = AFTER_HOSTS_CAL_VIDEO_NO_SHOW;
}
@@ -59,4 +59,8 @@ export class EventTypesRepositoryFixture {
async delete(eventTypeId: EventType["id"]) {
return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } });
}
async findById(eventTypeId: EventType["id"]) {
return this.prismaReadClient.eventType.findUnique({ where: { id: eventTypeId } });
}
}
@@ -35,4 +35,8 @@ export class MembershipRepositoryFixture {
await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } });
return membership;
}
async findById(membershipId: Membership["id"]) {
return this.prismaReadClient.membership.findUnique({ where: { id: membershipId } });
}
}
@@ -1,13 +1,17 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppCategories } from "@calcom/prisma/enums";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { calendarsRouter } from "@calcom/trpc/server/routers/viewer/calendars/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import InstalledApps from "~/apps/installed/[category]/installed-category-view";
const querySchema = z.object({
@@ -31,16 +35,23 @@ const InstalledAppsWrapper = async ({ params }: PageProps) => {
redirect("/apps/installed/calendar");
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const [calendarsCaller, appsCaller] = await Promise.all([
createRouterCaller(calendarsRouter),
createRouterCaller(appsRouter),
]);
const connectedCalendars = await calendarsCaller.connectedCalendars();
const installedCalendars = await appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
});
const [connectedCalendars, installedCalendars] = await Promise.all([
calendarsCaller.connectedCalendars(),
appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
}),
]);
return (
<InstalledApps
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/about-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/about"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/add-teams-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/add-teams"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,11 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies } from "next/headers";
import { headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboarding-handover";
@@ -18,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/handover"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,21 +1,15 @@
import type { GetServerSidePropsResult } from "next";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
export default async function Layout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository(prisma);
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizations) {
return {
notFound: true,
};
return notFound();
}
return {
props: {
isOrg: true,
},
};
};
return children;
}
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboard-members-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/onboard-members"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,13 +1,7 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/create-new-view";
export const generateMetadata = async () =>
@@ -19,14 +13,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new"
);
type Props = {
isOrg: boolean;
};
const getData = withAppDirSsr<Props>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LicenseRequired>
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/payment-status-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/status"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
+2 -1
View File
@@ -5,6 +5,7 @@ import { getLocale } from "@calcom/features/auth/lib/getLocale";
import type { AppImageProps, MeetingImageProps } from "@calcom/lib/OgImages";
import { constructAppImage, constructGenericImage, constructMeetingImage } from "@calcom/lib/OgImages";
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { getTranslation } from "@calcom/lib/server/i18n";
import { truncateOnWord } from "@calcom/lib/text";
@@ -32,7 +33,7 @@ const _generateMetadataWithoutImage = async (
const description = getDescription(t);
const titleSuffix = `| ${APP_NAME}`;
const displayedTitle = title.includes(titleSuffix) || hideBranding ? title : `${title} ${titleSuffix}`;
const metadataBase = new URL(IS_CALCOM ? "https://cal.com" : WEBAPP_URL);
const metadataBase = new URL(IS_CALCOM ? getCalcomUrl() : WEBAPP_URL);
return {
title: title.length === 0 ? APP_NAME : displayedTitle,
+2 -1
View File
@@ -9,6 +9,7 @@ import Script from "next/script";
import "@calcom/embed-core/src/embed-iframe";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
import { buildCanonical } from "@calcom/lib/next-seo.config";
import { IconSprites } from "@calcom/ui/components/icon";
@@ -64,7 +65,7 @@ function PageWrapper(props: AppProps) {
// Set canonical to https://cal.com or self-hosted URL
canonical={
IS_CALCOM
? buildCanonical({ path, origin: "https://cal.com" }) // cal.com & .dev
? buildCanonical({ path, origin: getCalcomUrl() }) // cal.com & .dev
: buildCanonical({ path, origin: WEBAPP_URL }) // self-hosted
}
{...seoConfig.defaultNextSeo}
@@ -1,34 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {
error: null,
migrated: null,
userId: session.user.id,
username: session.user.username,
},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -1,29 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import { UserPermissionRole } from "@calcom/prisma/enums";
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {},
};
}
@@ -156,7 +156,7 @@ export function AvailabilityList({ availabilities }: AvailabilityListProps) {
))}
</ul>
</div>
<div className="text-default mb-16 mt-4 hidden text-center text-sm md:block">
<div className="text-default mb-16 mt-4 block text-center text-sm">
{t("temporarily_out_of_office")}{" "}
<Link href="settings/my-account/out-of-office" className="underline">
{t("add_a_redirect")}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.17",
"version": "5.6.1",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -6,6 +6,7 @@ import { test } from "./lib/fixtures";
import {
bookTimeSlot,
confirmReschedule,
confirmBooking,
doOnOrgDomain,
selectFirstAvailableTimeSlotNextMonth,
selectSecondAvailableTimeSlotNextMonth,
@@ -98,6 +99,58 @@ test("dynamic booking info prefilled by query params", async ({ page, users }) =
activeState = await listItemLocator.getAttribute("data-active");
expect(activeState).toEqual("false");
});
test("multiple duration selection updates event length correctly", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await test.step("update event title to include duration placeholder", async () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
await page.click(`text=Multiple duration`);
await page.waitForSelector('[data-testid="event-title"]');
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
"aria-current",
"page"
);
await page.getByTestId("vertical-tab-event_advanced_tab_title").click();
await page.fill('[name="eventName"]', "{Event duration} event btwn {Organiser} {Scheduler}");
await page.locator('[data-testid="update-eventtype"]').click();
await page.waitForResponse("/api/trpc/eventTypes/update?batch=1");
});
await page.goto(`/${user.username}/multiple-duration`);
await page.locator('[data-testid="multiple-choice-30mins"]').waitFor({ state: "visible" });
await test.step("verify default 30min duration is selected", async () => {
const duration30 = page.getByTestId("multiple-choice-30mins");
const activeState = await duration30.getAttribute("data-active");
expect(activeState).toEqual("true");
});
await test.step("book with 90min duration and verify title", async () => {
await page.getByTestId("multiple-choice-90mins").click();
const duration90 = page.getByTestId("multiple-choice-90mins");
const activeState = await duration90.getAttribute("data-active");
expect(activeState).toEqual("true");
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "Test User");
await page.fill('[name="email"]', "test@example.com");
await confirmBooking(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
const bookingTitle = page.locator('[data-testid="booking-title"]');
await expect(bookingTitle).toBeVisible();
const titleText = await bookingTitle.textContent();
expect(titleText).toContain("90");
});
});
// eslint-disable-next-line playwright/no-skipped-test
test.skip("it contains the right event details", async ({ page }) => {
const response = await page.goto(`http://acme.cal.local:3000/owner1+member1`);
+10 -242
View File
@@ -4264,9 +4264,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
},
"get": {
"operationId": "OrganizationsEventTypesPrivateLinksController_getPrivateLinks",
@@ -4328,9 +4326,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links/{linkId}": {
@@ -4402,9 +4398,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
},
"delete": {
"operationId": "OrganizationsEventTypesPrivateLinksController_deletePrivateLink",
@@ -4474,9 +4468,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/memberships": {
@@ -22121,13 +22113,7 @@
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "expiresAt"]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
@@ -22164,14 +22150,7 @@
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "maxUsageCount", "usageCount"]
},
"CreatePrivateLinkOutput": {
"type": "object",
@@ -22193,10 +22172,7 @@
]
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"GetPrivateLinksOutput": {
"type": "object",
@@ -22221,10 +22197,7 @@
}
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"UpdatePrivateLinkOutput": {
"type": "object",
@@ -22246,10 +22219,7 @@
]
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"DeletePrivateLinkOutput": {
"type": "object",
@@ -22274,10 +22244,7 @@
}
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"StripConnectOutputDto": {
"type": "object",
@@ -26267,205 +26234,6 @@
},
"required": ["status", "data"]
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "expiresAt"]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "maxUsageCount", "usageCount"]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": ["status", "data"]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": ["status", "data"]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": ["status", "data"]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": ["status", "data"]
},
"UserWebhookOutputDto": {
"type": "object",
"properties": {
+1
View File
@@ -128,6 +128,7 @@ Below is a list of props that can be passed to the booker atom.
| 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. |
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
## Styling
+1 -1
View File
@@ -76,7 +76,7 @@ Sometimes, yarn install might fail during deployment on Vercel, in which case, y
<Step title="Override the build command">
Override to:
```
cd ../.. && yarn build --include-dependencies --no-deps
cd ../.. && yarn build --no-deps
```
</Step>
<Step title="Hit Deploy">
+2 -2
View File
@@ -33,8 +33,8 @@ RUN set -eux; \
apt-get clean && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives && \
yarn config set httpTimeout 1200000 && \
npx turbo prune --scope=@calcom/web --docker && \
npx turbo prune --scope=@calcom/api --docker && \
npx turbo prune --filter=@calcom/web --docker && \
npx turbo prune --filter=@calcom/api --docker && \
yarn install && \
yarn turbo run build --filter=@calcom/api
+13 -13
View File
@@ -28,23 +28,23 @@
"edit-app-template": "yarn app-store edit-template",
"delete-app-template": "yarn app-store delete-template",
"build": "turbo run build --filter=@calcom/web...",
"build:ai": "turbo run build --scope=\"@calcom/ai\"",
"build:ai": "turbo run build --filter=\"@calcom/ai\"",
"clean": "find . -name node_modules -o -name .next -o -name .turbo -o -name dist -type d -prune | xargs rm -rf",
"db-deploy": "turbo run db-deploy",
"db-seed": "turbo run db-seed",
"db-studio": "yarn prisma studio",
"deploy": "turbo run deploy",
"dev:all": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\" --scope=\"@calcom/console\"",
"dev:ai": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/ai\"",
"dev:api": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\"",
"dev:api:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/console\"",
"dev:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/console\"",
"dev:swagger": "turbo run dev --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/swagger\"",
"dev:website": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\"",
"dev": "turbo run dev --scope=\"@calcom/web\"",
"build-storybook": "turbo run build --scope=\"@calcom/storybook\"",
"dev:all": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/website\" --filter=\"@calcom/console\"",
"dev:ai": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/ai\"",
"dev:api": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\"",
"dev:api:console": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/console\"",
"dev:console": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/console\"",
"dev:swagger": "turbo run dev --filter=\"@calcom/api-proxy\" --filter=\"@calcom/api\" --filter=\"@calcom/swagger\"",
"dev:website": "turbo run dev --filter=\"@calcom/web\" --filter=\"@calcom/website\"",
"dev": "turbo run dev --filter=\"@calcom/web\"",
"build-storybook": "turbo run build --filter=\"@calcom/storybook\"",
"dx": "turbo run dx",
"i-dev": "infisical run -- turbo run dev --scope=\"@calcom/web\"",
"i-dev": "infisical run -- turbo run dev --filter=\"@calcom/web\"",
"i-dx": "infisical run -- turbo run dx",
"i-gen-web-example-env": "infisical secrets generate-example-env --tags=web > .env.example",
"i-gen-app-store-example-env": "infisical secrets generate-example-env --tags=appstore > .env.appStore.example",
@@ -61,7 +61,7 @@
"pre-commit": "lint-staged",
"predev": "echo 'Checking env files'",
"prisma": "yarn workspace @calcom/prisma prisma",
"start": "turbo run start --scope=\"@calcom/web\"",
"start": "turbo run start --filter=\"@calcom/web\"",
"tdd": "vitest watch",
"e2e": "NEXT_PUBLIC_IS_E2E=1 yarn playwright test --project=@calcom/web",
"e2e:app-store": "QUICK=true yarn playwright test --project=@calcom/app-store",
@@ -124,7 +124,7 @@
"date-fns-tz": "^3.2.0",
"eslint": "^8.34.0",
"p-limit": "^6.2.0",
"turbo": "^1.10.1"
"turbo": "^2.5.5"
},
"resolutions": {
"types-ramda": "0.29.4",
@@ -9,6 +9,21 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) {
window.dispatchEvent(event);
}
type BaseBookingEventPayload = {
title: string | undefined;
startTime: string | undefined;
endTime: string | undefined;
eventTypeId: number | null | undefined;
status: string | undefined;
paymentRequired: boolean;
isRecurring: boolean;
/**
* This is only used for recurring bookings
*/
allBookings?: { startTime: string; endTime: string }[];
videoCallUrl?: string;
};
export type EventDataMap = {
eventTypeSelected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -24,20 +39,8 @@ export type EventDataMap = {
linkReady: Record<string, never>;
__connectInitiated: Record<string, never>;
__connectCompleted: Record<string, never>;
bookingSuccessfulV2: {
bookingSuccessfulV2: BaseBookingEventPayload & {
uid: string | undefined;
title: string | undefined;
startTime: string | undefined;
endTime: string | undefined;
eventTypeId: number | null | undefined;
status: string | undefined;
paymentRequired: boolean;
isRecurring: boolean;
/**
* This is only used for recurring bookings
*/
allBookings?: { startTime: string; endTime: string }[];
videoCallUrl?: string;
};
/**
@@ -56,20 +59,11 @@ export type EventDataMap = {
};
confirmed: boolean;
};
rescheduleBookingSuccessfulV2: {
rescheduleBookingSuccessfulV2: BaseBookingEventPayload & {
uid: string | undefined;
title: string | undefined;
startTime: string | undefined;
endTime: string | undefined;
eventTypeId: number | null | undefined;
status: string | undefined;
paymentRequired: boolean;
isRecurring: boolean;
/**
* This is only used for recurring bookings
*/
allBookings?: { startTime: string; endTime: string }[];
};
dryRunBookingSuccessfulV2: BaseBookingEventPayload;
dryRunRescheduleBookingSuccessfulV2: BaseBookingEventPayload;
/**
* @deprecated Use `rescheduleBookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well.
*/
@@ -55,7 +55,7 @@ export const EventMeta = ({
timeZones,
children,
selectedTimeslot,
roundRobinHideOrgAndTeam
roundRobinHideOrgAndTeam,
}: {
event?: Pick<
BookerEvent,
@@ -57,6 +57,28 @@ export interface IUseBookings {
isBookingDryRun?: boolean;
}
const getBaseBookingEventPayload = (booking: {
title?: string;
startTime: string;
endTime: string;
eventTypeId?: number | null;
status?: BookingStatus;
paymentRequired: boolean;
isRecurring: boolean;
videoCallUrl?: string;
}) => {
return {
title: booking.title,
startTime: booking.startTime,
endTime: booking.endTime,
eventTypeId: booking.eventTypeId,
status: booking.status,
paymentRequired: booking.paymentRequired,
isRecurring: booking.isRecurring,
videoCallUrl: booking.videoCallUrl,
};
};
const getBookingSuccessfulEventPayload = (booking: {
title?: string;
startTime: string;
@@ -70,18 +92,15 @@ const getBookingSuccessfulEventPayload = (booking: {
}) => {
return {
uid: booking.uid,
title: booking.title,
startTime: booking.startTime,
endTime: booking.endTime,
eventTypeId: booking.eventTypeId,
status: booking.status,
paymentRequired: booking.paymentRequired,
isRecurring: booking.isRecurring,
videoCallUrl: booking.videoCallUrl,
...getBaseBookingEventPayload(booking),
};
};
const getRescheduleBookingSuccessfulEventPayload = getBookingSuccessfulEventPayload;
export const getDryRunBookingSuccessfulEventPayload = getBaseBookingEventPayload;
export const getDryRunRescheduleBookingSuccessfulEventPayload = getDryRunBookingSuccessfulEventPayload;
export interface IUseBookingLoadingStates {
creatingBooking: boolean;
creatingRecurringBooking: boolean;
@@ -196,6 +215,30 @@ export const useBookings = ({
mutationFn: createBooking,
onSuccess: (booking) => {
if (booking.isDryRun) {
const validDuration = event.data?.isDynamic
? duration || event.data?.length
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
? duration
: event.data?.length;
if (isRescheduling) {
sdkActionManager?.fire(
"dryRunRescheduleBookingSuccessfulV2",
getDryRunRescheduleBookingSuccessfulEventPayload({
...booking,
isRecurring: false,
})
);
} else {
sdkActionManager?.fire(
"dryRunBookingSuccessfulV2",
getDryRunBookingSuccessfulEventPayload({
...booking,
isRecurring: false,
})
);
}
router.push("/booking/dry-run-successful");
return;
}
@@ -343,6 +386,30 @@ export const useBookings = ({
const booking = bookings[0] || {};
if (booking.isDryRun) {
if (isRescheduling) {
sdkActionManager?.fire("dryRunRescheduleBookingSuccessfulV2", {
...getDryRunRescheduleBookingSuccessfulEventPayload({
...booking,
isRecurring: true,
}),
allBookings: bookings.map((booking) => ({
startTime: booking.startTime,
endTime: booking.endTime,
})),
});
} else {
sdkActionManager?.fire("dryRunBookingSuccessfulV2", {
...getDryRunBookingSuccessfulEventPayload({
...booking,
isRecurring: true,
}),
allBookings: bookings.map((booking) => ({
startTime: booking.startTime,
endTime: booking.endTime,
})),
});
}
router.push("/booking/dry-run-successful");
return;
}
@@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useShouldShowArrows } from "@calcom/features/apps/components/AllApps";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
@@ -45,7 +45,7 @@ export const EventDuration = ({
const { t } = useLocale();
const itemRefs = useRef<(HTMLLIElement | null)[]>([]);
const isPlatform = useIsPlatform();
const [selectedDuration, setSelectedDuration, state] = useBookerStore((state) => [
const [selectedDuration, setSelectedDuration, state] = useBookerStoreContext((state) => [
state.selectedDuration,
state.setSelectedDuration,
state.state,
@@ -550,6 +550,9 @@ async function handler(
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
const bookingRepo = new BookingRepository(prisma);
const requiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
const existingBooking = await bookingRepo.getValidBookingFromEventTypeForAttendee({
eventTypeId,
bookerEmail,
@@ -559,13 +562,20 @@ async function handler(
});
if (existingBooking) {
const hasPayments = existingBooking.payment.length > 0;
const isPaidBooking = existingBooking.paid || !hasPayments;
const shouldShowPaymentForm = requiresPayment && !isPaidBooking;
const firstPayment = shouldShowPaymentForm ? existingBooking.payment[0] : undefined;
const bookingResponse = {
...existingBooking,
user: {
...existingBooking.user,
email: null,
},
paymentRequired: false,
paymentRequired: shouldShowPaymentForm,
seatReferenceUid: "",
};
@@ -574,8 +584,8 @@ async function handler(
luckyUsers: bookingResponse.userId ? [bookingResponse.userId] : [],
isDryRun,
...(isDryRun ? { troubleshooterData } : {}),
paymentUid: undefined,
paymentId: undefined,
paymentUid: firstPayment?.uid,
paymentId: firstPayment?.id,
};
}
}
@@ -1430,6 +1440,8 @@ async function handler(
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
originalRescheduledBooking.userId !== evt.organizer.id;
const skipDeleteEventsAndMeetings = changedOrganizer;
const isBookingRequestedReschedule =
!!originalRescheduledBooking &&
!!originalRescheduledBooking.rescheduled &&
@@ -1702,7 +1714,8 @@ async function handler(
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
);
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
@@ -7,6 +7,8 @@
*
* They don't intend to test what the apps logic should do, but rather test if the apps are called with the correct data. For testing that, once should write tests within each app.
*/
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
import {
createBookingScenario,
getDate,
@@ -3277,6 +3279,122 @@ describe("handleNewBooking", () => {
},
timeout
);
test(
`Payment retry scenario - should return existing payment UID and prevent duplicate bookings when retrying canceled payment`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential(), getStripeAppCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const scenarioData = getScenarioData({
eventTypes: [
{
id: 1,
title: "Paid Event",
description: "It's a test Paid Event",
slotInterval: 30,
requiresConfirmation: false,
metadata: {
apps: {
stripe: {
price: 100,
enabled: true,
currency: "usd",
},
},
},
length: 30,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["stripe-payment"]],
});
await createBookingScenario(scenarioData);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const { paymentUid } = mockPaymentApp({
metadataLookupKey: "stripe",
appStoreLookupKey: "stripepayment",
});
mockCalendarToHaveNoBusySlots("googlecalendar");
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const firstBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expect(firstBooking).toEqual(
expect.objectContaining({
paymentUid: expect.any(String),
paymentRequired: true,
uid: expect.any(String),
})
);
const firstBookingInDb = await prismaMock.booking.findUnique({
where: { uid: firstBooking.uid },
include: { payment: true },
});
expect(firstBookingInDb).toBeTruthy();
expect(firstBookingInDb?.payment).toHaveLength(1);
expect(firstBookingInDb?.paid).toBe(false);
const secondBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expect(secondBooking.uid).toBe(firstBooking.uid);
expect(secondBooking.paymentUid).toBe(firstBooking.paymentUid);
expect(secondBooking.paymentRequired).toBe(true);
const bookingsInDb = await prismaMock.booking.findMany({
where: {
eventTypeId: 1,
attendees: {
some: {
email: booker.email,
},
},
},
include: { payment: true },
});
expect(bookingsInDb).toHaveLength(1);
expect(bookingsInDb[0].payment).toHaveLength(1);
expect(bookingsInDb[0].uid).toBe(firstBooking.uid);
},
timeout
);
});
});
@@ -1,11 +1,7 @@
import removeMember from "@calcom/features/ee/teams/lib/removeMember";
import { TeamService } from "@calcom/lib/server/service/teamService";
const removeUserFromOrg = async ({ userId, orgId }: { userId: number; orgId: number }) => {
return removeMember({
memberId: userId,
teamId: orgId,
isOrg: true,
});
return TeamService.removeMembers({ teamIds: [orgId], userIds: [userId], isOrg: true });
};
export default removeUserFromOrg;
@@ -0,0 +1,145 @@
/**
* Tests for calendar event deletion during round robin organizer change operations.
* Covers scenarios where events need to be deleted from original hosts and
* credential handling during organizer changes.
*/
import {
getDate,
createBookingScenario,
getScenarioData,
getMockBookingAttendee,
TestData,
getOrganizer,
getGoogleCalendarCredential,
mockCalendarToHaveNoBusySlots,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import {
expectBookingToBeInDatabase,
expectSuccessfulRoundRobinReschedulingEmails,
} from "@calcom/web/test/utils/bookingScenario/expects";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { SchedulingType, BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
describe("roundRobinReassignment test", () => {
setupAndTeardown();
test("should delete calendar events from original host when round robin reassignment changes organizer", async ({
emails,
}) => {
const roundRobinReassignment = (await import("./roundRobinReassignment")).default;
const originalHost = getOrganizer({
name: "Original Host",
email: "originalhost@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const newHost = getOrganizer({
name: "New Host",
email: "newhost@example.com",
id: 102,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
const bookingToReassignUid = "booking-to-reassign-with-deletion";
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: [{ id: 101 }, { id: 102 }],
hosts: [
{ userId: 101, isFixed: false },
{ userId: 102, isFixed: false },
],
},
],
bookings: [
{
id: 123,
eventTypeId: 1,
userId: originalHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:45:00.000Z`,
attendees: [
getMockBookingAttendee({
id: 2,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
],
references: [
{
id: 1,
type: appStoreMetadata.googlecalendar.type,
uid: "ORIGINAL_EVENT_ID",
meetingId: "ORIGINAL_EVENT_ID",
meetingPassword: null,
meetingUrl: null,
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
credentialId: 1,
deleted: null,
},
],
},
],
organizer: originalHost,
usersApartFromOrganizer: [newHost],
apps: [TestData.apps["google-calendar"]],
})
);
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: { uid: "NEW_EVENT_ID" },
update: { uid: "UPDATED_EVENT_ID" },
});
await roundRobinReassignment({
bookingId: 123,
});
// Verify that calendar deletion occurred (may be called multiple times due to duplicate references)
expect(calendarMock.deleteEventCalls.length).toBeGreaterThanOrEqual(1);
const deleteCall = calendarMock.deleteEventCalls[0];
expect(deleteCall.args.uid).toBe("ORIGINAL_EVENT_ID");
expect(deleteCall.args.externalCalendarId).toBe("MOCK_EXTERNAL_CALENDAR_ID");
expect(deleteCall.args.event.organizer.email).toBe(newHost.email); // Current implementation uses new host credentials
expect(deleteCall.args.event.uid).toBe(bookingToReassignUid);
// Verify that creation occurred with new host credentials
expect(calendarMock.createEventCalls.length).toBe(1);
const createCall = calendarMock.createEventCalls[0];
expect(createCall.args.calEvent.organizer.email).toBe(newHost.email);
// Verify the booking was reassigned to the new host
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
userId: newHost.id,
});
expectSuccessfulRoundRobinReschedulingEmails({
prevOrganizer: originalHost,
newOrganizer: newHost,
emails,
});
});
});
@@ -1,141 +0,0 @@
import logger from "@calcom/lib/logger";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import { deleteWorkfowRemindersOfRemovedMember } from "./deleteWorkflowRemindersOfRemovedMember";
const log = logger.getSubLogger({ prefix: ["removeMember"] });
const removeMember = async ({
memberId,
teamId,
isOrg,
}: {
memberId: number;
teamId: number;
isOrg: boolean;
}) => {
const [membership] = await prisma.$transaction([
prisma.membership.delete({
where: {
userId_teamId: { userId: memberId, teamId: teamId },
},
include: {
user: true,
team: true,
},
}),
// remove user as host from team events associated with this membership
prisma.host.deleteMany({
where: {
userId: memberId,
eventType: {
teamId: teamId,
},
},
}),
]);
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
organizationSettings: true,
id: true,
metadata: true,
activeOrgWorkflows: true,
parentId: true,
},
});
const foundUser = await prisma.user.findUnique({
where: { id: memberId },
select: {
id: true,
movedToProfileId: true,
email: true,
username: true,
completedOnboarding: true,
teams: {
select: {
team: {
select: {
id: true,
parentId: true,
},
},
},
},
},
});
if (!team || !foundUser) throw new TRPCError({ code: "NOT_FOUND" });
if (isOrg) {
log.debug("Removing a member from the organization");
// Deleting membership from all child teams
// Delete all sub-team memberships where this team is the organization
await prisma.membership.deleteMany({
where: {
team: {
parentId: teamId,
},
userId: membership.userId,
},
});
const userToDeleteMembershipOf = foundUser;
const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({
userId: userToDeleteMembershipOf.id,
organizationId: team.id,
});
if (
userToDeleteMembershipOf.username &&
userToDeleteMembershipOf.movedToProfileId === profileToDelete?.id
) {
log.debug("Cleaning up tempOrgRedirect for user", userToDeleteMembershipOf.username);
await prisma.tempOrgRedirect.deleteMany({
where: {
from: userToDeleteMembershipOf.username,
},
});
}
await prisma.$transaction([
prisma.user.update({
where: { id: membership.userId },
data: { organizationId: null },
}),
ProfileRepository.delete({
userId: membership.userId,
organizationId: team.id,
}),
prisma.host.deleteMany({
where: {
userId: memberId,
eventType: {
team: {
parentId: teamId,
},
},
},
}),
]);
}
// Deleted managed event types from this team from this member
await prisma.eventType.deleteMany({
where: { parent: { teamId: teamId }, userId: membership.userId },
});
await deleteWorkfowRemindersOfRemovedMember(team, memberId, isOrg);
return { membership };
};
export default removeMember;
@@ -110,13 +110,15 @@ describe("RoleManagementFactory", () => {
it("should allow role change when user has permission", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).resolves.not.toThrow();
await expect(
manager.checkPermissionToChangeRole(userId, organizationId, "org")
).resolves.not.toThrow();
});
it("should throw UNAUTHORIZED when user lacks permission", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(
await expect(manager.checkPermissionToChangeRole(userId, organizationId, "org")).rejects.toThrow(
new RoleManagementError(
"You do not have permission to change roles",
RoleManagementErrorCode.UNAUTHORIZED
@@ -193,13 +195,15 @@ describe("RoleManagementFactory", () => {
customRoleId: null,
});
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).resolves.not.toThrow();
await expect(
manager.checkPermissionToChangeRole(userId, organizationId, "org")
).resolves.not.toThrow();
});
it("should throw UNAUTHORIZED when user is not owner", async () => {
vi.mocked(isOrganisationAdmin).mockResolvedValue(false);
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(
await expect(manager.checkPermissionToChangeRole(userId, organizationId, "org")).rejects.toThrow(
new RoleManagementError(
"Only owners or admin can update roles",
RoleManagementErrorCode.UNAUTHORIZED
@@ -1,5 +1,6 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -10,7 +11,7 @@ import { RoleService } from "./role.service";
interface IRoleManager {
isPBACEnabled: boolean;
checkPermissionToChangeRole(userId: number, organizationId: number): Promise<void>;
checkPermissionToChangeRole(userId: number, targetId: number, scope: "org" | "team"): Promise<void>;
assignRole(
userId: number,
organizationId: number,
@@ -29,11 +30,11 @@ class PBACRoleManager implements IRoleManager {
private readonly permissionCheckService: PermissionCheckService
) {}
async checkPermissionToChangeRole(userId: number, organizationId: number): Promise<void> {
async checkPermissionToChangeRole(userId: number, targetId: number, scope: "org" | "team"): Promise<void> {
const hasPermission = await this.permissionCheckService.checkPermission({
userId,
teamId: organizationId,
permission: "organization.changeMemberRole",
teamId: targetId,
permission: scope === "team" ? "team.changeMemberRole" : "organization.changeMemberRole",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
@@ -95,9 +96,11 @@ class PBACRoleManager implements IRoleManager {
class LegacyRoleManager implements IRoleManager {
public isPBACEnabled = false;
async checkPermissionToChangeRole(userId: number, organizationId: number): Promise<void> {
const membership = await isOrganisationAdmin(userId, organizationId);
async checkPermissionToChangeRole(userId: number, targetId: number, scope: "org" | "team"): Promise<void> {
const membership =
scope === "team"
? !!(await isTeamAdmin(userId, targetId))
: !!(await isOrganisationAdmin(userId, targetId));
// Only OWNER/ADMIN can update role
if (!membership) {
throw new RoleManagementError(
+11 -2
View File
@@ -570,7 +570,8 @@ export default class EventManager {
newBookingId?: number,
changedOrganizer?: boolean,
previousHostDestinationCalendar?: DestinationCalendar[] | null,
isBookingRequestedReschedule?: boolean
isBookingRequestedReschedule?: boolean,
skipDeleteEventsAndMeetings?: boolean
): Promise<CreateUpdateResult> {
const originalEvt = processLocation(event);
const evt = cloneDeep(originalEvt);
@@ -637,7 +638,7 @@ export default class EventManager {
const shouldUpdateBookingReferences =
!!changedOrganizer || isLocationChanged || !!isBookingRequestedReschedule || isDailyVideoRoomExpired;
if (evt.requiresConfirmation && !changedOrganizer) {
if (evt.requiresConfirmation && !skipDeleteEventsAndMeetings) {
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking");
// As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking.
await this.deleteEventsAndMeetings({
@@ -646,6 +647,14 @@ export default class EventManager {
});
} else {
if (changedOrganizer) {
if (!skipDeleteEventsAndMeetings) {
log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
await this.deleteEventsAndMeetings({
event: { ...event, destinationCalendar: previousHostDestinationCalendar },
bookingReferences: booking.references,
});
}
log.debug("RescheduleOrganizerChanged: Creating Event and Meeting for for new booking");
const createdEvent = await this.create(originalEvt);
results.push(...createdEvent.results);
+2 -1
View File
@@ -45,7 +45,8 @@ export const IS_CALCOM =
(new URL(WEBAPP_URL).hostname.endsWith("cal.com") ||
new URL(WEBAPP_URL).hostname.endsWith("cal.dev") ||
new URL(WEBAPP_URL).hostname.endsWith("cal.qa") ||
new URL(WEBAPP_URL).hostname.endsWith("cal-staging.com"));
new URL(WEBAPP_URL).hostname.endsWith("cal-staging.com") ||
new URL(WEBAPP_URL).hostname.endsWith("cal.eu"));
export const CONSOLE_URL =
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") ||
@@ -14,6 +14,8 @@ import { eventTypeRepositoryModule } from "../modules/EventType";
import { featuresRepositoryModule } from "../modules/Features";
import { filterHostsModule } from "../modules/FilterHosts";
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
import { membershipRepositoryModule } from "../modules/Membership";
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
import { oooRepositoryModule } from "../modules/Ooo";
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
import { routingFormResponseRepositoryModule } from "../modules/RoutingFormResponse";
@@ -29,6 +31,7 @@ container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
container.load(DI_TOKENS.SCHEDULE_REPOSITORY_MODULE, scheduleRepositoryModule);
container.load(DI_TOKENS.SELECTED_SLOT_REPOSITORY_MODULE, selectedSlotsRepositoryModule);
container.load(DI_TOKENS.TEAM_REPOSITORY_MODULE, teamRepositoryModule);
container.load(DI_TOKENS.MEMBERSHIP_REPOSITORY_MODULE, membershipRepositoryModule);
container.load(DI_TOKENS.USER_REPOSITORY_MODULE, userRepositoryModule);
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule);
@@ -42,6 +45,7 @@ container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
container.load(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE_MODULE, noSlotsNotificationModule);
export function getAvailableSlotsService() {
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
@@ -5,8 +5,8 @@ import type {
InsightsRoutingServicePublicOptions,
InsightsRoutingServiceFilterOptions,
InsightsRoutingBaseService,
} from "@calcom/lib/server/service/insightsRoutingBase";
import type { InsightsRoutingService } from "@calcom/lib/server/service/insightsRoutingDI";
} from "@calcom/lib/server/service/InsightsRoutingBaseService";
import type { InsightsRoutingService } from "@calcom/lib/server/service/InsightsRoutingDIService";
import { prismaModule } from "@calcom/prisma/prisma.module";
import { insightsRoutingModule } from "../modules/InsightsRouting";
@@ -0,0 +1,21 @@
import { createContainer } from "@evyweb/ioctopus";
import { redisModule } from "@calcom/features/redis/di/redisModule";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
import { membershipRepositoryModule } from "../modules/Membership";
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
import { teamRepositoryModule } from "../modules/Team";
const container = createContainer();
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.TEAM_REPOSITORY_MODULE, teamRepositoryModule);
container.load(DI_TOKENS.MEMBERSHIP_REPOSITORY_MODULE, membershipRepositoryModule);
container.load(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE_MODULE, noSlotsNotificationModule);
export function getNoSlotsNotificationService() {
return container.get<NoSlotsNotificationService>(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE);
}
@@ -22,4 +22,5 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
busyTimesService: DI_TOKENS.BUSY_TIMES_SERVICE,
featuresRepo: DI_TOKENS.FEATURES_REPOSITORY,
qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE,
noSlotsNotificationService: DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE,
} satisfies Record<keyof IAvailableSlotsService, symbol>);
+2 -2
View File
@@ -1,7 +1,7 @@
import { createModule } from "@evyweb/ioctopus";
import type { IInsightsRoutingService } from "@calcom/lib/server/service/insightsRoutingDI";
import { InsightsRoutingService } from "@calcom/lib/server/service/insightsRoutingDI";
import type { IInsightsRoutingService } from "@calcom/lib/server/service/InsightsRoutingDIService";
import { InsightsRoutingService } from "@calcom/lib/server/service/InsightsRoutingDIService";
import { DI_TOKENS } from "../tokens";
+9
View File
@@ -0,0 +1,9 @@
import { createModule } from "@evyweb/ioctopus";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
export const membershipRepositoryModule = createModule();
membershipRepositoryModule
.bind(DI_TOKENS.MEMBERSHIP_REPOSITORY)
.toClass(MembershipRepository, [DI_TOKENS.PRISMA_CLIENT]);
@@ -0,0 +1,13 @@
import { createModule } from "@evyweb/ioctopus";
import type { INoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
import { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
import { DI_TOKENS } from "../tokens";
export const noSlotsNotificationModule = createModule();
noSlotsNotificationModule.bind(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE).toClass(NoSlotsNotificationService, {
teamRepo: DI_TOKENS.TEAM_REPOSITORY,
membershipRepo: DI_TOKENS.MEMBERSHIP_REPOSITORY,
redisClient: DI_TOKENS.REDIS_CLIENT,
} satisfies Record<keyof INoSlotsNotificationService, symbol>);
+4
View File
@@ -41,4 +41,8 @@ export const DI_TOKENS = {
QUALIFIED_HOSTS_SERVICE_MODULE: Symbol("QualifiedHostsServiceModule"),
FILTER_HOSTS_SERVICE: Symbol("FilterHostsService"),
FILTER_HOSTS_SERVICE_MODULE: Symbol("FilterHostsServiceModule"),
NO_SLOTS_NOTIFICATION_SERVICE: Symbol("NoSlotsNotificationService"),
NO_SLOTS_NOTIFICATION_SERVICE_MODULE: Symbol("NoSlotsNotificationServiceModule"),
MEMBERSHIP_REPOSITORY: Symbol("MembershipRepository"),
MEMBERSHIP_REPOSITORY_MODULE: Symbol("MembershipRepositoryModule"),
};
+64 -54
View File
@@ -53,6 +53,7 @@ export class BusyTimesService {
})[]
| null;
bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean;
}) {
const {
@@ -70,6 +71,7 @@ export class BusyTimesService {
rescheduleUid,
duration,
bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache,
} = params;
@@ -198,63 +200,71 @@ export class BusyTimesService {
);
if (!calendarBusyTimesQuery.success) {
throw new Error(
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map(
(calendar) => calendar.id
)}`
if (silentlyHandleCalendarFailures) {
logger.warn(
`Calendar busy times fetch failed but handling silently due to silentlyHandleCalendarFailures flag for user ${username}`,
{
selectedCalendarIds: selectedCalendars.map((calendar) => calendar.id),
}
);
} else {
throw new Error(
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map(
(calendar) => calendar.id
)}`
);
}
} else {
const calendarBusyTimes = calendarBusyTimesQuery.data;
const endConnectedCalendarsGet = performance.now();
logger.debug(
`Connected Calendars get took ${
endConnectedCalendarsGet - startConnectedCalendarsGet
} ms for user ${username}`,
JSON.stringify({
eventTypeId,
startTimeDate,
endTimeDate,
calendarBusyTimes,
})
);
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
const [start, end] = key.split("<>");
return {
start: dayjs(start),
end: dayjs(end),
};
});
if (rescheduleUid) {
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
if (originalRescheduleBooking) {
openSeatsDateRanges.push({
start: dayjs(originalRescheduleBooking.startTime),
end: dayjs(originalRescheduleBooking.endTime),
});
}
}
const result = subtract(
calendarBusyTimes.map((value) => ({
...value,
end: dayjs(value.end),
start: dayjs(value.start),
})),
openSeatsDateRanges
);
busyTimes.push(
...result.map((busyTime) => ({
...busyTime,
start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(),
end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(),
}))
);
}
const calendarBusyTimes = calendarBusyTimesQuery.data;
const endConnectedCalendarsGet = performance.now();
logger.debug(
`Connected Calendars get took ${
endConnectedCalendarsGet - startConnectedCalendarsGet
} ms for user ${username}`,
JSON.stringify({
eventTypeId,
startTimeDate,
endTimeDate,
calendarBusyTimes,
})
);
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
const [start, end] = key.split("<>");
return {
start: dayjs(start),
end: dayjs(end),
};
});
if (rescheduleUid) {
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
// calendar busy time from original rescheduled booking should not be blocked
if (originalRescheduleBooking) {
openSeatsDateRanges.push({
start: dayjs(originalRescheduleBooking.startTime),
end: dayjs(originalRescheduleBooking.endTime),
});
}
}
const result = subtract(
calendarBusyTimes.map((value) => ({
...value,
end: dayjs(value.end),
start: dayjs(value.start),
})),
openSeatsDateRanges
);
busyTimes.push(
...result.map((busyTime) => ({
...busyTime,
start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(),
end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(),
}))
);
/*
// TODO: Disabled until we can filter Zoom events by date. Also this is adding too much latency.
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
+8
View File
@@ -0,0 +1,8 @@
import { WEBAPP_URL, IS_CALCOM } from "./constants";
export const getCalcomUrl = () => {
if (IS_CALCOM) {
return new URL(WEBAPP_URL).hostname.endsWith("cal.eu") ? "https://cal.eu" : "https://cal.com";
}
return WEBAPP_URL;
};
+4
View File
@@ -54,6 +54,7 @@ const availabilitySchema = z
withSource: z.boolean().optional(),
returnDateOverrides: z.boolean(),
bypassBusyCalendarTimes: z.boolean().optional(),
silentlyHandleCalendarFailures: z.boolean().optional(),
shouldServeCache: z.boolean().optional(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
@@ -110,6 +111,7 @@ type GetUserAvailabilityQuery = {
duration?: number;
returnDateOverrides: boolean;
bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean;
};
@@ -295,6 +297,7 @@ export class UserAvailabilityService {
duration,
returnDateOverrides,
bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache,
} = availabilitySchema.parse(query);
@@ -457,6 +460,7 @@ export class UserAvailabilityService {
duration,
currentBookings: initialData?.currentBookings,
bypassBusyCalendarTimes,
silentlyHandleCalendarFailures,
shouldServeCache,
});
} catch (error) {
@@ -828,6 +828,7 @@ export class BookingRepository {
attendees: true,
references: true,
user: true,
payment: true,
},
});
}
@@ -460,4 +460,28 @@ export class MembershipRepository {
select: select ?? { userId: true },
})) as unknown as Promise<MembershipDTOFromSelect<TSelect>[]>;
}
async findTeamAdminsByTeamId({ teamId }: { teamId: number }) {
return await this.prismaClient.membership.findMany({
where: {
team: {
id: teamId,
parentId: {
not: null,
},
},
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
user: {
select: {
email: true,
locale: true,
},
},
},
});
}
}
+27
View File
@@ -350,4 +350,31 @@ export class TeamRepository {
return team?.parent;
}
async findOrganizationSettingsBySlug({ slug }: { slug: string }) {
return await this.prismaClient.team.findFirst({
where: {
slug,
isOrganization: true,
},
select: {
organizationSettings: {
select: {
adminGetsNoSlotsNotification: true,
},
},
},
});
}
async findTeamSlugById({ id }: { id: number }) {
return await this.prismaClient.team.findUnique({
where: {
id,
},
select: {
slug: true,
},
});
}
}
@@ -4,7 +4,7 @@ import {
InsightsRoutingBaseService,
type InsightsRoutingServicePublicOptions,
type InsightsRoutingServiceFilterOptions,
} from "./insightsRoutingBase";
} from "./InsightsRoutingBaseService";
export interface IInsightsRoutingService {
prisma: typeof readonlyPrisma;
@@ -7,7 +7,7 @@ import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsRoutingBaseService as InsightsRoutingService } from "../../service/insightsRoutingBase";
import { InsightsRoutingBaseService as InsightsRoutingService } from "../../service/InsightsRoutingBaseService";
// SQL condition constants for testing
const NOTHING_CONDITION = Prisma.sql`1=0`;
File diff suppressed because it is too large Load Diff
+263 -6
View File
@@ -1,16 +1,52 @@
import { Prisma } from "@prisma/client";
import { TeamBilling } from "@calcom/features/ee/billing/teams";
import removeMember from "@calcom/features/ee/teams/lib/removeMember";
import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember";
import { deleteDomain } from "@calcom/lib/domainManager/organization";
import logger from "@calcom/lib/logger";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { WorkflowService } from "@calcom/lib/server/service/workflows";
import prisma from "@calcom/prisma";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
const log = logger.getSubLogger({ prefix: ["TeamService"] });
type MembershipWithRelations = Pick<
Membership,
"id" | "userId" | "teamId" | "role" | "accepted" | "disableImpersonation"
>;
type TeamWithSettings = {
id: number;
isOrganization: boolean | null;
organizationSettings: unknown;
metadata: unknown;
activeOrgWorkflows: unknown;
parentId: number | null;
};
type UserWithTeams = {
id: number;
movedToProfileId: number | null;
email: string;
username: string | null;
completedOnboarding: boolean;
teams: {
team: {
id: number;
parentId: number | null;
};
}[];
};
export type RemoveMemberResult = {
membership: MembershipWithRelations;
};
export class TeamService {
/**
* Deletes a team and all its associated data in a safe, transactional order.
@@ -45,15 +81,23 @@ export class TeamService {
return deletedTeam;
}
static async removeMembers(teamIds: number[], memberIds: number[], isOrg = false) {
const deleteMembershipPromises = [];
static async removeMembers({
teamIds,
userIds,
isOrg = false,
}: {
teamIds: number[];
userIds: number[];
isOrg?: boolean;
}) {
const deleteMembershipPromises: Promise<RemoveMemberResult>[] = [];
for (const memberId of memberIds) {
for (const userId of userIds) {
for (const teamId of teamIds) {
deleteMembershipPromises.push(
removeMember({
TeamService.removeMember({
teamId,
memberId,
userId,
isOrg,
})
);
@@ -121,4 +165,217 @@ export class TeamService {
const teamBilling = await TeamBilling.findAndInit(teamId);
return teamBilling.publish();
}
private static async removeMember({
userId,
teamId,
isOrg,
}: {
userId: number;
teamId: number;
isOrg: boolean;
}) {
const membership = await TeamService.fetchMembershipOrThrow(userId, teamId);
const team = await TeamService.fetchTeamOrThrow(teamId);
const user = await TeamService.fetchUserOrThrow(userId);
if (isOrg) {
log.debug("Removing a member from the organization");
await TeamService.removeFromOrganization(membership, team, user);
} else {
log.debug("Removing a member from a team");
await TeamService.removeFromTeam(membership, teamId);
}
await deleteWorkfowRemindersOfRemovedMember(team, userId, isOrg);
return { membership };
}
// TODO: Needs to be moved to repository
private static async fetchMembershipOrThrow(
userId: number,
teamId: number
): Promise<MembershipWithRelations> {
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: { userId: userId, teamId: teamId },
},
select: {
id: true,
userId: true,
teamId: true,
role: true,
accepted: true,
disableImpersonation: true,
},
});
if (!membership) {
throw new TRPCError({ code: "NOT_FOUND", message: "Membership not found" });
}
return membership;
}
// TODO: Needs to be moved to repository
private static async fetchTeamOrThrow(teamId: number): Promise<TeamWithSettings> {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
organizationSettings: true,
id: true,
metadata: true,
activeOrgWorkflows: true,
parentId: true,
},
});
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
}
return team;
}
// TODO: Needs to be moved to repository
private static async fetchUserOrThrow(userId: number): Promise<UserWithTeams> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
movedToProfileId: true,
email: true,
username: true,
completedOnboarding: true,
teams: {
select: {
team: {
select: {
id: true,
parentId: true,
},
},
},
},
},
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
return user;
}
// TODO: Needs to be moved to repository
private static async cleanupTempOrgRedirect(user: UserWithTeams, team: TeamWithSettings) {
const profileToDelete = await ProfileRepository.findByUserIdAndOrgId({
userId: user.id,
organizationId: team.id,
});
if (user.username && user.movedToProfileId === profileToDelete?.id) {
log.debug("Cleaning up tempOrgRedirect for user", user.username);
await prisma.tempOrgRedirect.deleteMany({
where: {
from: user.username,
},
});
}
}
private static async removeFromOrganization(
membership: MembershipWithRelations,
team: TeamWithSettings,
user: UserWithTeams
) {
await TeamService.cleanupTempOrgRedirect(user, team);
const newUsername = generateNewUsername(user);
await prisma.$transaction([
// Remove user from all sub-teams event type hosts
prisma.host.deleteMany({
where: {
userId: membership.userId,
eventType: {
team: {
parentId: team.id,
},
},
},
}),
// Delete managed child events in sub-teams
prisma.eventType.deleteMany({
where: {
userId: membership.userId,
parent: {
team: {
parentId: team.id,
},
},
},
}),
// Remove organizationId from the user
prisma.user.update({
where: { id: membership.userId },
data: {
organizationId: null,
username: newUsername,
},
}),
// Delete the profile of the user from the organization
ProfileRepository.delete({
userId: membership.userId,
organizationId: team.id,
}),
// Delete all sub-team memberships where this team is the organization
prisma.membership.deleteMany({
where: {
team: {
parentId: team.id,
},
userId: membership.userId,
},
}),
// Delete the membership of the user from the organization
prisma.membership.delete({
where: {
userId_teamId: { userId: membership.userId, teamId: team.id },
},
}),
]);
// Generate new username for user leaving organization
function generateNewUsername(user: UserWithTeams): string | null {
// We ensure that new username would be unique across all users in the global namespace outside any organization
return user.username != null ? `${user.username}-${user.id}` : null;
}
}
// Remove member from regular team
private static async removeFromTeam(membership: MembershipWithRelations, teamId: number) {
await prisma.$transaction([
// Remove user from all team event types' hosts
prisma.host.deleteMany({
where: {
userId: membership.userId,
eventType: {
teamId: teamId,
},
},
}),
// Deleted managed event types from this team for this member
prisma.eventType.deleteMany({
where: { parent: { teamId: teamId }, userId: membership.userId },
}),
// Delete the membership of the user from the team
prisma.membership.delete({
where: {
userId_teamId: { userId: membership.userId, teamId: teamId },
},
}),
]);
}
}
+6
View File
@@ -1,5 +1,11 @@
## 1.1.2
## 1.4.0
### Minor Changes
- [#23119](https://github.com/calcom/cal.com/pull/23119) [`eb1ed10`](https://github.com/calcom/cal.com/commit/eb1ed107ba54a4ae99d0b3aa741ae3076d0ec629) Thanks [@ThyMinimalDev](https://github.com/ThyMinimalDev)! - Added Booker atom prop silentlyHandleCalendarFailures, which ensure the booker still displays slots when the third party calendars credentials are invalid or expired
## 1.3.1
### Patch Changes
@@ -64,6 +64,7 @@ const BookerPlatformWrapperComponent = (
onTimeslotsLoaded,
startTime: customStartTime,
showNoAvailabilityDialog,
silentlyHandleCalendarFailures = false,
} = props;
const layout = BookerLayouts[view];
@@ -310,6 +311,7 @@ const BookerPlatformWrapperComponent = (
Boolean(event?.data?.id),
orgSlug: props.entity?.orgSlug ?? undefined,
eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "",
_silentCalendarFailures: silentlyHandleCalendarFailures,
...routingParams,
});
+1
View File
@@ -85,6 +85,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
onTimeslotsLoaded?: (slots: Record<string, Slot[]>) => void;
startTime?: string | Date;
roundRobinHideOrgAndTeam?: boolean;
silentlyHandleCalendarFailures?: boolean;
};
type VIEW_TYPE = keyof typeof BookerLayouts;
+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.3.1",
"version": "1.4.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",
@@ -136,6 +136,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
: { username: props.calUsername })}
hostsLimit={3}
allowUpdatingUrlParams={true}
silentlyHandleCalendarFailures={false}
/>
</>
)}
+1
View File
@@ -120,4 +120,5 @@ export { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
export { verifyPhoneNumber, sendVerificationCode };
export { TeamService } from "@calcom/lib/server/service/teamService";
export { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
@@ -7,3 +7,4 @@ export { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/Pris
export { TeamRepository as PrismaTeamRepository } from "@calcom/lib/server/repository/team";
export { UserRepository as PrismaUserRepository } from "@calcom/lib/server/repository/user";
export { FeaturesRepository as PrismaFeaturesRepository } from "@calcom/features/flags/features.repository";
export { MembershipRepository as PrismaMembershipRepository } from "@calcom/lib/server/repository/membership";
+2
View File
@@ -1,6 +1,7 @@
import { FilterHostsService } from "@calcom/lib/bookings/filterHostsBySameRoundRobinHost";
import { QualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { BusyTimesService } from "@calcom/lib/getBusyTimes";
import { NoSlotsNotificationService } from "@calcom/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots";
import { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
export { AvailableSlotsService };
@@ -10,3 +11,4 @@ export { BusyTimesService };
export { QualifiedHostsService };
export { FilterHostsService };
export { NoSlotsNotificationService };
@@ -194,6 +194,12 @@ export class GetAvailableSlotsInput_2024_04_15 {
@ApiHideProperty()
_bypassCalendarBusyTimes?: boolean;
@Transform(({ value }) => value && value.toLowerCase() === "true")
@IsBoolean()
@IsOptional()
@ApiHideProperty()
_silentCalendarFailures?: boolean;
@Transform(({ value }) => (value ? value.toLowerCase() === "true" : false))
@IsBoolean()
@IsOptional()
Binary file not shown.
@@ -0,0 +1,46 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
import { duplicateHandler } from "./duplicate.handler";
vi.mock("@calcom/prisma", () => ({
default: prismaMock,
}));
vi.mock("@calcom/lib/server/repository/eventTypeRepository");
describe("duplicateHandler", () => {
const ctx = { user: { id: 1, profile: { id: 1 } } } as any;
const input = { id: 123, slug: "test-event", title: "Test", description: "Test", length: 30, teamId: null };
const eventType = { id: 123, userId: 1, teamId: null, users: [{ id: 1 }] };
beforeEach(() => {
vi.resetAllMocks();
prismaMock.eventType.findUnique.mockResolvedValue(eventType);
});
it("should throw BAD_REQUEST in case of unique constraint violation", async () => {
const { EventTypeRepository } = await import("@calcom/lib/server/repository/eventTypeRepository");
vi.mocked(EventTypeRepository).mockImplementation(
() =>
({
create: vi.fn().mockRejectedValue(
new PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "mockedVersion",
})
),
} as any)
);
await expect(duplicateHandler({ ctx, input })).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: "Unique constraint violation while creating a duplicate event.",
})
);
});
});
@@ -226,6 +226,13 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
eventType: newEventType,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
// unique constraint violation
throw new TRPCError({
code: "BAD_REQUEST",
message: "Unique constraint violation while creating a duplicate event.",
});
}
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Error duplicating event type ${error}` });
}
};
@@ -47,7 +47,7 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(organizationId);
try {
await roleManager.checkPermissionToChangeRole(userId, organizationId);
await roleManager.checkPermissionToChangeRole(userId, organizationId, "org");
} catch (error) {
if (error instanceof RoleManagementError) {
throw new TRPCError({ code: "UNAUTHORIZED", message: error.message });
@@ -6,8 +6,7 @@ import { vi, describe, it, beforeAll, afterAll, expect, beforeEach, afterEach }
import dayjs from "@calcom/dayjs";
import * as CalcomEmails from "@calcom/emails";
import { RedisService } from "@calcom/features/redis/RedisService";
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
import { getNoSlotsNotificationService } from "@calcom/lib/di/containers/NoSlotsNotification";
vi.mock("@calcom/features/redis/RedisService", () => {
const mockedRedis = vi.fn();
@@ -84,14 +83,16 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
};
// Call the function with teamId
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled();
// Mock length to be one then recall to trigger email
mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
const service2 = getNoSlotsNotificationService();
await service2.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).toHaveBeenCalled();
});
it("Should not send a notification if the org has them disabled", async () => {
@@ -113,7 +114,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
isValidOrgDomain: true,
};
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled();
});
@@ -153,7 +155,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]); // This will trigger email sending
// Call with specific teamId
await handleNotificationWhenNoSlots({
const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails,
orgDetails,
teamId: 123, // specific teamId
@@ -205,7 +208,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({
const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails,
orgDetails,
// teamId intentionally omitted
@@ -233,7 +237,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({
const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails,
orgDetails,
teamId: 123,
@@ -263,7 +268,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
isValidOrgDomain: true,
};
await handleNotificationWhenNoSlots({
const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails,
orgDetails,
teamId: 123,
@@ -318,7 +324,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({
const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails,
orgDetails,
teamId: 123,
@@ -375,7 +382,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
// First notification cycle - simulate having one previous occurrence
mocked.lrange.mockResolvedValueOnce([""]); // One previous occurrence
await handleNotificationWhenNoSlots({
const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails,
orgDetails,
teamId: 123,
@@ -391,7 +399,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
// This better simulates the real Redis state after the first notification
mocked.lrange.mockResolvedValueOnce(["", ""]); // Two occurrences now
await handleNotificationWhenNoSlots({
const service2 = getNoSlotsNotificationService();
await service2.handleNotificationWhenNoSlots({
eventDetails,
orgDetails,
teamId: 123,
@@ -436,7 +445,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
// First event type notification
mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for first event
await handleNotificationWhenNoSlots({
const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails: { ...baseEventDetails, eventSlug: "event1" },
orgDetails,
teamId: 123,
@@ -451,7 +461,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
// For the second event type, also simulate one previous occurrence
// This needs to be a separate mock since it's a different key in Redis
mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for second event
await handleNotificationWhenNoSlots({
const service2 = getNoSlotsNotificationService();
await service2.handleNotificationWhenNoSlots({
eventDetails: { ...baseEventDetails, eventSlug: "event2" },
orgDetails,
teamId: 123,
@@ -1,9 +1,10 @@
import type { Dayjs } from "@calcom/dayjs";
import { sendOrganizationAdminNoSlotsNotification } from "@calcom/emails";
import { RedisService } from "@calcom/features/redis/RedisService";
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import type { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { TeamRepository } from "@calcom/lib/server/repository/team";
type EventDetails = {
username: string;
@@ -38,107 +39,83 @@ const constructDataHash = (eventDetails: EventDetails) => {
return JSON.stringify(obj);
};
export const handleNotificationWhenNoSlots = async ({
eventDetails,
orgDetails,
teamId,
}: {
eventDetails: EventDetails;
orgDetails: { currentOrgDomain: string | null };
teamId?: number;
}) => {
// Check for org
if (!orgDetails.currentOrgDomain || !teamId) return;
const UPSTASH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_TOKEN && process.env.UPSTASH_REDIS_REST_URL;
if (!UPSTASH_ENV_FOUND) return;
export interface INoSlotsNotificationService {
teamRepo: TeamRepository;
membershipRepo: MembershipRepository;
redisClient: IRedisService;
}
// Check org has this setting enabled
const orgSettings = await prisma.team.findFirst({
where: {
export class NoSlotsNotificationService {
constructor(public readonly dependencies: INoSlotsNotificationService) {}
async handleNotificationWhenNoSlots({
eventDetails,
orgDetails,
teamId,
}: {
eventDetails: EventDetails;
orgDetails: { currentOrgDomain: string | null };
teamId?: number;
}) {
// Check for org
if (!orgDetails.currentOrgDomain || !teamId) return;
const UPSTASH_ENV_FOUND = process.env.UPSTASH_REDIS_REST_TOKEN && process.env.UPSTASH_REDIS_REST_URL;
if (!UPSTASH_ENV_FOUND) return;
// Check org has this setting enabled
const orgSettings = await this.dependencies.teamRepo.findOrganizationSettingsBySlug({
slug: orgDetails.currentOrgDomain,
isOrganization: true,
},
select: {
organizationSettings: {
select: {
adminGetsNoSlotsNotification: true,
},
},
},
});
if (!orgSettings?.organizationSettings?.adminGetsNoSlotsNotification) return;
const redis = new RedisService();
const usersUniqueKey = constructRedisKey(eventDetails, orgDetails.currentOrgDomain, teamId);
// Get only the required amount of data so the request is as small as possible
// We may need to get more data and check the startDate occurrence of this
// Not trigger email if the start months are the same
const usersExistingNoSlots =
(await redis.lrange(usersUniqueKey, 0, NO_SLOTS_COUNT_FOR_NOTIFICATION - 1)) ?? [];
await redis.lpush(usersUniqueKey, constructDataHash(eventDetails));
if (!usersExistingNoSlots.length) {
await redis.expire(usersUniqueKey, NO_SLOTS_NOTIFICATION_FREQUENCY);
}
// We add one as we know we just added one to the list - saves us re-fetching the data
if (usersExistingNoSlots.length + 1 === NO_SLOTS_COUNT_FOR_NOTIFICATION) {
// Get all team admins to send the email too
const foundAdmins = await prisma.membership.findMany({
where: {
team: {
id: teamId,
parentId: {
not: null,
},
},
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
user: {
select: {
email: true,
locale: true,
},
},
},
});
const teamSlug = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
slug: true,
},
});
// TODO: use new tasker as we dont want this blocking loading slots (Just out of scope for this PR)
// Tasker isn't 100% working with emails - will refactor after i have made changes to Tasker in another PR.
const emailsToSend: Array<Promise<void>> = [];
// const tasker = getTasker();
for (const admin of foundAdmins) {
const translation = await getTranslation(admin.user.locale ?? "en", "common");
if (!orgSettings?.organizationSettings?.adminGetsNoSlotsNotification) return;
const payload = {
language: translation,
to: {
email: admin.user.email,
},
user: eventDetails.username,
slug: eventDetails.eventSlug,
startTime: eventDetails.startTime.format("YYYY-MM-DD"),
endTime: eventDetails.endTime.format("YYYY-MM-DD"),
// For now navigate here - when impersonation via parameter has been pushed we will impersonate and then navigate to availability
editLink: `${WEBAPP_URL}/availability?type=team`,
teamSlug: teamSlug?.slug ?? "",
};
const usersUniqueKey = constructRedisKey(eventDetails, orgDetails.currentOrgDomain, teamId);
// Get only the required amount of data so the request is as small as possible
// We may need to get more data and check the startDate occurrence of this
// Not trigger email if the start months are the same
const usersExistingNoSlots =
(await this.dependencies.redisClient.lrange(usersUniqueKey, 0, NO_SLOTS_COUNT_FOR_NOTIFICATION - 1)) ??
[];
await this.dependencies.redisClient.lpush(usersUniqueKey, constructDataHash(eventDetails));
emailsToSend.push(sendOrganizationAdminNoSlotsNotification(payload));
if (!usersExistingNoSlots.length) {
await this.dependencies.redisClient.expire(usersUniqueKey, NO_SLOTS_NOTIFICATION_FREQUENCY);
}
// We add one as we know we just added one to the list - saves us re-fetching the data
if (usersExistingNoSlots.length + 1 === NO_SLOTS_COUNT_FOR_NOTIFICATION) {
// Get all team admins to send the email too
const foundAdmins = await this.dependencies.membershipRepo.findTeamAdminsByTeamId({
teamId,
});
const teamSlug = await this.dependencies.teamRepo.findTeamSlugById({
id: teamId,
});
// TODO: use new tasker as we dont want this blocking loading slots (Just out of scope for this PR)
// Tasker isn't 100% working with emails - will refactor after i have made changes to Tasker in another PR.
const emailsToSend: Array<Promise<void>> = [];
// const tasker = getTasker();
for (const admin of foundAdmins) {
const translation = await getTranslation(admin.user.locale ?? "en", "common");
const payload = {
language: translation,
to: {
email: admin.user.email,
},
user: eventDetails.username,
slug: eventDetails.eventSlug,
startTime: eventDetails.startTime.format("YYYY-MM-DD"),
endTime: eventDetails.endTime.format("YYYY-MM-DD"),
// For now navigate here - when impersonation via parameter has been pushed we will impersonate and then navigate to availability
editLink: `${WEBAPP_URL}/availability?type=team`,
teamSlug: teamSlug?.slug ?? "",
};
emailsToSend.push(sendOrganizationAdminNoSlotsNotification(payload));
}
await Promise.all(emailsToSend);
}
await Promise.all(emailsToSend);
}
};
}
@@ -35,6 +35,7 @@ export const getScheduleSchemaObject = z.object({
skipContactOwner: z.boolean().nullish(),
_enableTroubleshooter: z.boolean().optional(),
_bypassCalendarBusyTimes: z.boolean().optional(),
_silentCalendarFailures: z.boolean().optional(),
_shouldServeCache: z.boolean().optional(),
routingFormResponseId: z.number().optional(),
queuedFormResponseId: z.string().nullish(),
@@ -61,7 +61,7 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { TRPCError } from "@trpc/server";
import type { TGetScheduleInputSchema } from "./getSchedule.schema";
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
import type { NoSlotsNotificationService } from "./handleNotificationWhenNoSlots";
import type { GetScheduleOptions } from "./types";
const log = logger.getSubLogger({ prefix: ["[slots/util]"] });
@@ -108,6 +108,7 @@ export interface IAvailableSlotsService {
redisClient: IRedisService;
featuresRepo: FeaturesRepository;
qualifiedHostsService: QualifiedHostsService;
noSlotsNotificationService: NoSlotsNotificationService;
}
function withSlotsCache(
@@ -737,6 +738,7 @@ export class AvailableSlotsService {
startTime,
endTime,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
}: {
input: TGetScheduleInputSchema;
@@ -753,6 +755,7 @@ export class AvailableSlotsService {
startTime: ReturnType<(typeof AvailableSlotsService)["prototype"]["getStartTime"]>;
endTime: Dayjs;
bypassBusyCalendarTimes: boolean;
silentCalendarFailures: boolean;
shouldServeCache?: boolean;
}) {
const usersWithCredentials = this.getUsersWithCredentials({
@@ -887,6 +890,7 @@ export class AvailableSlotsService {
duration: input.duration || 0,
returnDateOverrides: false,
bypassBusyCalendarTimes,
silentlyHandleCalendarFailures: silentCalendarFailures,
shouldServeCache,
},
initialData: {
@@ -958,6 +962,7 @@ export class AvailableSlotsService {
const {
_enableTroubleshooter: enableTroubleshooter = false,
_bypassCalendarBusyTimes: bypassBusyCalendarTimes = false,
_silentCalendarFailures: silentCalendarFailures = false,
_shouldServeCache,
routingFormResponseId,
queuedFormResponseId,
@@ -1069,6 +1074,7 @@ export class AvailableSlotsService {
? this.getStartTime(twoWeeksFromNow.format(), input.timeZone, eventType.minimumBookingNotice)
: endTime,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
});
@@ -1095,6 +1101,7 @@ export class AvailableSlotsService {
startTime: dayjs(),
endTime: twoWeeksFromNow,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
});
if (
@@ -1129,6 +1136,7 @@ export class AvailableSlotsService {
startTime,
endTime,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
}));
aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
@@ -1431,8 +1439,7 @@ export class AvailableSlotsService {
// We only want to run this on single targeted events and not dynamic
if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) {
try {
// TODO: DI handleNotificationWhenNoSlots
await handleNotificationWhenNoSlots({
await this.dependencies.noSlotsNotificationService.handleNotificationWhenNoSlots({
eventDetails: {
username: input.usernameList?.[0],
startTime: startTime,
@@ -1,5 +1,5 @@
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -32,7 +32,7 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
// Check permission to change roles
try {
await roleManager.checkPermissionToChangeRole(ctx.user.id, organizationId);
await roleManager.checkPermissionToChangeRole(ctx.user.id, input.teamId, "team");
} catch (error) {
throw new TRPCError({
code: "UNAUTHORIZED",
@@ -45,8 +45,6 @@ export const changeMemberRoleHandler = async ({ ctx, input }: ChangeMemberRoleOp
typeof input.role === "string" &&
Object.values(MembershipRole).includes(input.role as MembershipRole)
) {
// Traditional role assignment logic
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
// Only owners can award owner role.
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -59,7 +59,7 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
message: "You can not remove yourself from a team you own.",
});
await TeamService.removeMembers(teamIds, memberIds, isOrg);
await TeamService.removeMembers({ teamIds, userIds: memberIds, isOrg });
};
export default removeMemberHandler;
@@ -1,167 +0,0 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
// TODO: Bring this test back with the correct setup (no illegal imports)
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, test, expect } from "vitest";
import { SchedulingType, MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "../../../types";
import removeMember from "./removeMember.handler";
describe.skip("removeMember", () => {
describe("should remove a member from a team", () => {
test(`1) Should remove a member from a team
2) Should remove the member from hosts
`, async () => {
const org = await createOrganization({
name: "Test Org",
slug: "testorg",
});
// Create the child team
const childTeam = {
id: 202,
name: "Team 1",
slug: "team-1",
parentId: org.id,
};
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
organizationId: org.id,
schedules: [TestData.schedules.IstWorkHours],
teams: [
{
membership: {
accepted: true,
role: MembershipRole.ADMIN,
},
team: {
id: org.id,
name: "Test Org",
slug: "testorg",
},
},
{
membership: {
accepted: true,
role: MembershipRole.ADMIN,
},
team: {
id: childTeam.id,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
],
});
const otherTeamMembers = [
{
name: "Other Team Member 1",
username: "other-team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "other-team-member-1@example.com",
id: 102,
organizationId: org.id,
schedules: [TestData.schedules.IstEveningShift],
teams: [
{
membership: {
accepted: true,
role: MembershipRole.MEMBER,
},
team: {
id: org.id,
name: "Test Org",
slug: "testorg",
},
},
{
membership: {
accepted: true,
role: MembershipRole.MEMBER,
},
team: {
id: childTeam.id,
name: "Team 1",
slug: "team-1",
parentId: org.id,
},
},
],
},
];
await createBookingScenario(
getScenarioData(
{
eventTypes: [
{
id: 1,
teamId: childTeam.id,
schedulingType: SchedulingType.ROUND_ROBIN,
length: 30,
hosts: [
{
userId: 101,
isFixed: false,
},
{
userId: 102,
isFixed: false,
},
],
},
],
organizer,
usersApartFromOrganizer: otherTeamMembers,
apps: [TestData.apps["daily-video"]],
},
org
)
);
//Logged in user is admin of the org
const ctx = {
user: {
id: organizer.id,
name: organizer.name,
} as NonNullable<TrpcSessionUser>,
};
await removeMember({
ctx,
input: {
teamIds: [org.id],
memberIds: [102],
isOrg: true,
},
});
//Check if the remaining memberships are correct
const remainingMemberships = await prismaMock.membership.count({
where: {
teamId: childTeam.id,
},
});
expect(remainingMemberships).toBe(1);
//Check if the event type has the correct hosts
const remainingHostsCount = await prismaMock.host.count({
where: {
eventTypeId: 1,
},
});
expect(remainingHostsCount).toBe(1);
});
});
});
+4 -15
View File
@@ -1,19 +1,5 @@
{
"extends": "@calcom/tsconfig/react-library.json",
"paths": {
"~/*": ["/*"],
"@calcom/app-store": ["../app-store"],
"@calcom/app-store/*": ["../app-store/*"],
"@calcom/dayjs": ["../dayjs"],
"@calcom/dayjs/*": ["../dayjs/*"],
"@calcom/features": ["../features"],
"@calcom/features/*": ["../features/*"],
"@calcom/lib": ["../lib"],
"@calcom/lib/*": ["../lib/*"],
"@calcom/prisma": ["../prisma"],
"@calcom/prisma/*": ["../prisma/*"],
"@calcom/repository/*": ["../lib/server/repository/*"]
},
"include": [
"../types/@wojtekmaj__react-daterange-picker.d.ts",
"../types/business-days-plugin.d.ts",
@@ -33,6 +19,9 @@
"declarationMap": false,
"resolveJsonModule": true,
"declarationDir": "types/server",
"moduleResolution": "node"
"moduleResolution": "node",
"paths": {
"~/*": ["./*"]
}
}
}
+254 -253
View File
@@ -1,6 +1,257 @@
{
"$schema": "https://turborepo.org/schema.json",
"pipeline": {
"globalDependencies": ["yarn.lock"],
"globalEnv": [
"ALLOWED_HOSTNAMES",
"ANALYZE",
"API_KEY_PREFIX",
"APP_USER_NAME",
"ATOMS_E2E_API_URL",
"ATOMS_E2E_OAUTH_CLIENT_ID",
"ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED",
"ATOMS_E2E_OAUTH_CLIENT_SECRET",
"ATOMS_E2E_ORG_ID",
"BASECAMP3_CLIENT_ID",
"BASECAMP3_CLIENT_SECRET",
"BASECAMP3_USER_AGENT",
"BLACKLISTED_GUEST_EMAILS",
"AUTH_BEARER_TOKEN_VERCEL",
"BUILD_ID",
"CAL_SIGNATURE_TOKEN",
"CALCOM_PRIVATE_API_ROUTE",
"CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY",
"CALCOM_CREDENTIAL_SYNC_ENDPOINT",
"CALCOM_CREDENTIAL_SYNC_SECRET",
"CALCOM_CREDENTIAL_SYNC_SECRET_HEADER_NAME",
"CALCOM_ENV",
"CALCOM_LICENSE_KEY",
"CALCOM_QA_EMAIL",
"CALCOM_QA_PASSWORD",
"CALCOM_TELEMETRY_DISABLED",
"CALCOM_CREDENTIAL_SYNC_HEADER_NAME",
"CALENDSO_ENCRYPTION_KEY",
"CALCOM_CREDENTIAL_SYNC_SECRET",
"CI",
"CLOSECOM_CLIENT_ID",
"CLOSECOM_CLIENT_SECRET",
"CRON_API_KEY",
"CRON_SECRET",
"CRON_ENABLE_APP_SYNC",
"CLOUDFLARE_TURNSTILE_SECRET",
"DAILY_API_KEY",
"DAILY_SCALE_PLAN",
"DAILY_WEBHOOK_SECRET",
"DAILY_MEETING_ENDED_WEBHOOK_SECRET",
"DAILY_VIDEO_REGION",
"DATABASE_CHUNK_SIZE",
"DATABASE_DIRECT_URL",
"DATABASE_URL",
"DEBUG",
"DUB_API_KEY",
"NEXT_PUBLIC_DUB_PROGRAM_ID",
"E2E_TEST_APPLE_CALENDAR_EMAIL",
"E2E_TEST_APPLE_CALENDAR_PASSWORD",
"E2E_TEST_CALCOM_QA_EMAIL",
"E2E_TEST_CALCOM_QA_PASSWORD",
"E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS",
"E2E_TEST_CALCOM_GCAL_KEYS",
"E2E_TEST_MAILHOG_ENABLED",
"E2E_TEST_OIDC_CLIENT_ID",
"E2E_TEST_OIDC_CLIENT_SECRET",
"E2E_TEST_OIDC_PROVIDER_DOMAIN",
"E2E_TEST_OIDC_USER_EMAIL",
"E2E_TEST_OIDC_USER_PASSWORD",
"E2E_TEST_SAML_ADMIN_EMAIL",
"E2E_TEST_SAML_ADMIN_PASSWORD",
"EMAIL_FROM",
"EMAIL_FROM_NAME",
"EMAIL_SERVER_HOST",
"EMAIL_SERVER_PASSWORD",
"EMAIL_SERVER_PORT",
"EMAIL_SERVER_USER",
"EMAIL_SERVER",
"ENABLE_EXPANDED_CACHE",
"EXCHANGE_DEFAULT_EWS_URL",
"FORMBRICKS_FEEDBACK_SURVEY_ID",
"AVATARAPI_USERNAME",
"AVATARAPI_PASSWORD",
"GIPHY_API_KEY",
"GITHUB_API_REPO_TOKEN",
"GOOGLE_API_CREDENTIALS",
"GOOGLE_LOGIN_ENABLED",
"GOOGLE_WEBHOOK_TOKEN",
"HEROKU_APP_NAME",
"HUBSPOT_CLIENT_ID",
"HUBSPOT_CLIENT_SECRET",
"IFFY_API_KEY",
"INTEGRATION_TEST_MODE",
"IS_E2E",
"INTERCOM_SECRET",
"INTERCOM_SECRET",
"INSIGHTS_DATABASE_URL",
"IP_BANLIST",
"LARK_OPEN_APP_ID",
"LARK_OPEN_APP_SECRET",
"LARK_OPEN_VERIFICATION_TOKEN",
"MOCK_PAYMENT_APP_ENABLED",
"MS_GRAPH_CLIENT_ID",
"MS_GRAPH_CLIENT_SECRET",
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_APP_NAME",
"NEXT_PUBLIC_AUTH_URL",
"NEXT_PUBLIC_CALCOM_VERSION",
"NEXT_PUBLIC_COMPANY_NAME",
"NEXT_PUBLIC_CONSOLE_URL",
"NEXT_PUBLIC_LOGGER_LEVEL",
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PUBLIC_EMBED_LIB_URL",
"NEXT_PUBLIC_FORMBRICKS_HOST_URL",
"NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID",
"NEXT_PUBLIC_HOSTED_CAL_FEATURES",
"NEXT_PUBLIC_MINUTES_TO_BOOK",
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
"NEXT_PUBLIC_SENDER_ID",
"NEXT_PUBLIC_SENDGRID_SENDER_NAME",
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID",
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_PUBLISHABLE_KEY",
"NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS",
"NEXT_PUBLIC_SWAGGER_DOCS_URL",
"NEXT_PUBLIC_TEAM_IMPERSONATION",
"NEXT_PUBLIC_VERCEL_URL",
"NEXT_PUBLIC_CLOUDFLARE_SITEKEY",
"NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER",
"NEXT_PUBLIC_VERCEL_ENV",
"NEXT_PUBLIC_VERCEL_BRANCH_URL",
"NEXT_PUBLIC_GTM_ID",
"NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS",
"NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW",
"NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL",
"NEXT_PUBLIC_WEBSITE_TERMS_URL",
"NEXT_RUNTIME",
"NEXTAUTH_COOKIE_DOMAIN",
"NEXTAUTH_SECRET",
"NEXTAUTH_URL",
"NODE_ENV",
"ORGANIZATIONS_ENABLED",
"ORGANIZATIONS_AUTOLINK",
"PAYMENT_FEE_FIXED",
"PAYMENT_FEE_PERCENTAGE",
"PLAYWRIGHT_HEADLESS",
"PLAYWRIGHT_TEST_BASE_URL",
"PRISMA_FIELD_ENCRYPTION_KEY",
"PRISMA_GENERATE_DATAPROXY",
"PROJECT_ID_VERCEL",
"QUICK",
"RAILWAY_STATIC_URL",
"RENDER_EXTERNAL_URL",
"RESERVED_SUBDOMAINS",
"RETELL_AI_KEY",
"SALESFORCE_CONSUMER_KEY",
"SALESFORCE_CONSUMER_SECRET",
"SAML_ADMINS",
"SAML_CLIENT_SECRET_VERIFIER",
"SAML_DATABASE_URL",
"SEND_FEEDBACK_EMAIL",
"SENDGRID_API_KEY",
"SENDGRID_EMAIL",
"SENDGRID_SYNC_API_KEY",
"SKIP_DB_MIGRATIONS",
"SLACK_CLIENT_ID",
"SLACK_CLIENT_SECRET",
"SLACK_SIGNING_SECRET",
"STRIPE_CLIENT_ID",
"STRIPE_PRIVATE_KEY",
"STRIPE_PRODUCT_ID_ESSENTIALS",
"STRIPE_PRODUCT_ID_SCALE",
"STRIPE_PRODUCT_ID_STARTER",
"STRIPE_WEBHOOK_SECRET",
"STRIPE_WEBHOOK_SECRET_APPS",
"STRIPE_WEBHOOK_SECRET_BILLING",
"STRIPE_TEAM_MONTHLY_PRICE_ID",
"TANDEM_BASE_URL",
"TANDEM_CLIENT_ID",
"TANDEM_CLIENT_SECRET",
"TASKER_ENABLE_WEBHOOKS",
"TEAM_ID_VERCEL",
"TELEMETRY_DEBUG",
"TWILIO_MESSAGING_SID",
"TWILIO_OPT_OUT_ENABLED",
"TWILIO_PHONE_NUMBER",
"TWILIO_WHATSAPP_PHONE_NUMBER",
"TWILIO_SID",
"TWILIO_TOKEN",
"TWILIO_VERIFY_SID",
"TWILIO_WHATSAPP_REMINDER_CONTENT_SID",
"TWILIO_WHATSAPP_CANCELLED_CONTENT_SID",
"TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID",
"TWILIO_WHATSAPP_COMPLETED_CONTENT_SID",
"UPSTASH_REDIS_REST_TOKEN",
"UPSTASH_REDIS_REST_URL",
"UNKEY_ROOT_KEY",
"USERNAME_BLACKLIST_URL",
"VERCEL_ENV",
"VERCEL_URL",
"VITAL_API_KEY",
"VITAL_DEVELOPMENT_MODE",
"VITAL_REGION",
"VITAL_WEBHOOK_SECRET",
"ZAPIER_INVITE_LINK",
"ZOHOCRM_CLIENT_ID",
"ZOHOCRM_CLIENT_SECRET",
"ZOOM_CLIENT_ID",
"ZOOM_CLIENT_SECRET",
"REVERT_API_KEY",
"REVERT_API_URL",
"REVERT_PUBLIC_TOKEN",
"RESEND_API_KEY",
"LOCAL_TESTING_DOMAIN_VERCEL",
"AUTH_BEARER_TOKEN_CLOUDFLARE",
"CLOUDFLARE_ZONE_ID",
"NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY",
"NEXT_PUBLIC_CHAT_API_URL",
"NEXT_PUBLIC_DOCS_URL",
"CLOUDFLARE_VERCEL_CNAME",
"CLOUDFLARE_DNS",
"EMBED_PUBLIC_EMBED_FINGER_PRINT",
"EMBED_PUBLIC_EMBED_VERSION",
"EMBED_PUBLIC_WEBAPP_URL",
"EMBED_PUBLIC_VERCEL_URL",
"EMBED_PUBLIC_EMBED_LIB_URL",
"NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER",
"NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS",
"NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS",
"NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS",
"NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM",
"NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT",
"NEXT_PUBLIC_HEAD_SCRIPTS",
"NEXT_PUBLIC_BODY_SCRIPTS",
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
"NEXT_PUBLIC_API_V2_ROOT_URL",
"NEXT_PUBLIC_VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY",
"CAL_VIDEO_BUCKET_NAME",
"CAL_VIDEO_BUCKET_REGION",
"CAL_VIDEO_ASSUME_ROLE_ARN",
"CAL_VIDEO_MEETING_LINK_FOR_TESTING",
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_POSTHOG_HOST",
"VAPID_PRIVATE_KEY",
"HUDDLE01_API_TOKEN",
"LINGO_DOT_DEV_API_KEY",
"DIRECTORY_IDS_TO_LOG",
"NEXT_PUBLIC_SINGLE_ORG_SLUG",
"GOOGLE_REFRESH_TOKEN",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"ZOOM_REFRESH_TOKEN",
"CALCOM_ADMIN_API_KEY",
"NEXT_PUBLIC_SINGLE_ORG_MODE_ENABLED",
"CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY",
"CAL_VIDEO_RECORDING_TOKEN_SECRET",
"ORGANIZER_EMAIL_EXEMPT_DOMAINS",
"SLOTS_CACHE_TTL"
],
"tasks": {
"@calcom/prisma#build": {
"cache": false,
"dependsOn": ["post-install"]
@@ -207,7 +458,7 @@
},
"@calcom/app-store-cli#build": {
"cache": false,
"inputs": ["../../app-store/**/**"],
"inputs": ["../../app-store/**"],
"outputs": ["../../app-store/apps.server.generated.ts", "../../app-store/apps.browser.generated.tsx"]
},
"@calcom/embed-react#type-check": {
@@ -247,255 +498,5 @@
"//#test": {
"cache": false
}
},
"globalDependencies": ["yarn.lock"],
"globalEnv": [
"ALLOWED_HOSTNAMES",
"ANALYZE",
"API_KEY_PREFIX",
"APP_USER_NAME",
"ATOMS_E2E_API_URL",
"ATOMS_E2E_OAUTH_CLIENT_ID",
"ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED",
"ATOMS_E2E_OAUTH_CLIENT_SECRET",
"ATOMS_E2E_ORG_ID",
"BASECAMP3_CLIENT_ID",
"BASECAMP3_CLIENT_SECRET",
"BASECAMP3_USER_AGENT",
"BLACKLISTED_GUEST_EMAILS",
"AUTH_BEARER_TOKEN_VERCEL",
"BUILD_ID",
"CAL_SIGNATURE_TOKEN",
"CALCOM_PRIVATE_API_ROUTE",
"CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY",
"CALCOM_CREDENTIAL_SYNC_ENDPOINT",
"CALCOM_CREDENTIAL_SYNC_SECRET",
"CALCOM_CREDENTIAL_SYNC_SECRET_HEADER_NAME",
"CALCOM_ENV",
"CALCOM_LICENSE_KEY",
"CALCOM_QA_EMAIL",
"CALCOM_QA_PASSWORD",
"CALCOM_TELEMETRY_DISABLED",
"CALCOM_CREDENTIAL_SYNC_HEADER_NAME",
"CALENDSO_ENCRYPTION_KEY",
"CALCOM_CREDENTIAL_SYNC_SECRET",
"CI",
"CLOSECOM_CLIENT_ID",
"CLOSECOM_CLIENT_SECRET",
"CRON_API_KEY",
"CRON_SECRET",
"CRON_ENABLE_APP_SYNC",
"CLOUDFLARE_TURNSTILE_SECRET",
"DAILY_API_KEY",
"DAILY_SCALE_PLAN",
"DAILY_WEBHOOK_SECRET",
"DAILY_MEETING_ENDED_WEBHOOK_SECRET",
"DAILY_VIDEO_REGION",
"DATABASE_CHUNK_SIZE",
"DATABASE_DIRECT_URL",
"DATABASE_URL",
"DEBUG",
"DUB_API_KEY",
"NEXT_PUBLIC_DUB_PROGRAM_ID",
"E2E_TEST_APPLE_CALENDAR_EMAIL",
"E2E_TEST_APPLE_CALENDAR_PASSWORD",
"E2E_TEST_CALCOM_QA_EMAIL",
"E2E_TEST_CALCOM_QA_PASSWORD",
"E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS",
"E2E_TEST_CALCOM_GCAL_KEYS",
"E2E_TEST_MAILHOG_ENABLED",
"E2E_TEST_OIDC_CLIENT_ID",
"E2E_TEST_OIDC_CLIENT_SECRET",
"E2E_TEST_OIDC_PROVIDER_DOMAIN",
"E2E_TEST_OIDC_USER_EMAIL",
"E2E_TEST_OIDC_USER_PASSWORD",
"E2E_TEST_SAML_ADMIN_EMAIL",
"E2E_TEST_SAML_ADMIN_PASSWORD",
"EMAIL_FROM",
"EMAIL_FROM_NAME",
"EMAIL_SERVER_HOST",
"EMAIL_SERVER_PASSWORD",
"EMAIL_SERVER_PORT",
"EMAIL_SERVER_USER",
"EMAIL_SERVER",
"ENABLE_EXPANDED_CACHE",
"EXCHANGE_DEFAULT_EWS_URL",
"FORMBRICKS_FEEDBACK_SURVEY_ID",
"AVATARAPI_USERNAME",
"AVATARAPI_PASSWORD",
"GIPHY_API_KEY",
"GITHUB_API_REPO_TOKEN",
"GOOGLE_API_CREDENTIALS",
"GOOGLE_LOGIN_ENABLED",
"GOOGLE_WEBHOOK_TOKEN",
"HEROKU_APP_NAME",
"HUBSPOT_CLIENT_ID",
"HUBSPOT_CLIENT_SECRET",
"IFFY_API_KEY",
"INTEGRATION_TEST_MODE",
"IS_E2E",
"INTERCOM_SECRET",
"INTERCOM_SECRET",
"INSIGHTS_DATABASE_URL",
"IP_BANLIST",
"LARK_OPEN_APP_ID",
"LARK_OPEN_APP_SECRET",
"LARK_OPEN_VERIFICATION_TOKEN",
"MOCK_PAYMENT_APP_ENABLED",
"MS_GRAPH_CLIENT_ID",
"MS_GRAPH_CLIENT_SECRET",
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_APP_NAME",
"NEXT_PUBLIC_AUTH_URL",
"NEXT_PUBLIC_CALCOM_VERSION",
"NEXT_PUBLIC_COMPANY_NAME",
"NEXT_PUBLIC_CONSOLE_URL",
"NEXT_PUBLIC_LOGGER_LEVEL",
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PUBLIC_EMBED_LIB_URL",
"NEXT_PUBLIC_FORMBRICKS_HOST_URL",
"NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID",
"NEXT_PUBLIC_HOSTED_CAL_FEATURES",
"NEXT_PUBLIC_MINUTES_TO_BOOK",
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
"NEXT_PUBLIC_SENDER_ID",
"NEXT_PUBLIC_SENDGRID_SENDER_NAME",
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID",
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_PUBLISHABLE_KEY",
"NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS",
"NEXT_PUBLIC_SWAGGER_DOCS_URL",
"NEXT_PUBLIC_TEAM_IMPERSONATION",
"NEXT_PUBLIC_VERCEL_URL",
"NEXT_PUBLIC_CLOUDFLARE_SITEKEY",
"NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER",
"NEXT_PUBLIC_VERCEL_ENV",
"NEXT_PUBLIC_VERCEL_BRANCH_URL",
"NEXT_PUBLIC_GTM_ID",
"NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS",
"NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW",
"NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL",
"NEXT_PUBLIC_WEBSITE_TERMS_URL",
"NEXT_RUNTIME",
"NEXTAUTH_COOKIE_DOMAIN",
"NEXTAUTH_SECRET",
"NEXTAUTH_URL",
"NODE_ENV",
"ORGANIZATIONS_ENABLED",
"ORGANIZATIONS_AUTOLINK",
"PAYMENT_FEE_FIXED",
"PAYMENT_FEE_PERCENTAGE",
"PLAYWRIGHT_HEADLESS",
"PLAYWRIGHT_TEST_BASE_URL",
"PRISMA_FIELD_ENCRYPTION_KEY",
"PRISMA_GENERATE_DATAPROXY",
"PROJECT_ID_VERCEL",
"QUICK",
"RAILWAY_STATIC_URL",
"RENDER_EXTERNAL_URL",
"RESERVED_SUBDOMAINS",
"RETELL_AI_KEY",
"SALESFORCE_CONSUMER_KEY",
"SALESFORCE_CONSUMER_SECRET",
"SAML_ADMINS",
"SAML_CLIENT_SECRET_VERIFIER",
"SAML_DATABASE_URL",
"SEND_FEEDBACK_EMAIL",
"SENDGRID_API_KEY",
"SENDGRID_EMAIL",
"SENDGRID_SYNC_API_KEY",
"SKIP_DB_MIGRATIONS",
"SLACK_CLIENT_ID",
"SLACK_CLIENT_SECRET",
"SLACK_SIGNING_SECRET",
"STRIPE_CLIENT_ID",
"STRIPE_PRIVATE_KEY",
"STRIPE_PRODUCT_ID_ESSENTIALS",
"STRIPE_PRODUCT_ID_SCALE",
"STRIPE_PRODUCT_ID_STARTER",
"STRIPE_WEBHOOK_SECRET",
"STRIPE_WEBHOOK_SECRET_APPS",
"STRIPE_WEBHOOK_SECRET_BILLING",
"TANDEM_BASE_URL",
"TANDEM_CLIENT_ID",
"TANDEM_CLIENT_SECRET",
"TASKER_ENABLE_WEBHOOKS",
"TEAM_ID_VERCEL",
"TELEMETRY_DEBUG",
"TWILIO_MESSAGING_SID",
"TWILIO_OPT_OUT_ENABLED",
"TWILIO_PHONE_NUMBER",
"TWILIO_WHATSAPP_PHONE_NUMBER",
"TWILIO_SID",
"TWILIO_TOKEN",
"TWILIO_VERIFY_SID",
"TWILIO_WHATSAPP_REMINDER_CONTENT_SID",
"TWILIO_WHATSAPP_CANCELLED_CONTENT_SID",
"TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID",
"TWILIO_WHATSAPP_COMPLETED_CONTENT_SID",
"UPSTASH_REDIS_REST_TOKEN",
"UPSTASH_REDIS_REST_URL",
"UNKEY_ROOT_KEY",
"USERNAME_BLACKLIST_URL",
"VERCEL_ENV",
"VERCEL_URL",
"VITAL_API_KEY",
"VITAL_DEVELOPMENT_MODE",
"VITAL_REGION",
"VITAL_WEBHOOK_SECRET",
"ZAPIER_INVITE_LINK",
"ZOHOCRM_CLIENT_ID",
"ZOHOCRM_CLIENT_SECRET",
"ZOOM_CLIENT_ID",
"ZOOM_CLIENT_SECRET",
"REVERT_API_KEY",
"REVERT_API_URL",
"REVERT_PUBLIC_TOKEN",
"RESEND_API_KEY",
"LOCAL_TESTING_DOMAIN_VERCEL",
"AUTH_BEARER_TOKEN_CLOUDFLARE",
"CLOUDFLARE_ZONE_ID",
"NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY",
"NEXT_PUBLIC_CHAT_API_URL",
"NEXT_PUBLIC_DOCS_URL",
"CLOUDFLARE_VERCEL_CNAME",
"CLOUDFLARE_DNS",
"EMBED_PUBLIC_EMBED_FINGER_PRINT",
"EMBED_PUBLIC_EMBED_VERSION",
"EMBED_PUBLIC_WEBAPP_URL",
"EMBED_PUBLIC_VERCEL_URL",
"EMBED_PUBLIC_EMBED_LIB_URL",
"NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER",
"NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS",
"NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS",
"NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS",
"NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM",
"NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT",
"NEXT_PUBLIC_HEAD_SCRIPTS",
"NEXT_PUBLIC_BODY_SCRIPTS",
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
"NEXT_PUBLIC_API_V2_ROOT_URL",
"NEXT_PUBLIC_VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY",
"CAL_VIDEO_BUCKET_NAME",
"CAL_VIDEO_BUCKET_REGION",
"CAL_VIDEO_ASSUME_ROLE_ARN",
"CAL_VIDEO_MEETING_LINK_FOR_TESTING",
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_POSTHOG_HOST",
"VAPID_PRIVATE_KEY",
"HUDDLE01_API_TOKEN",
"LINGO_DOT_DEV_API_KEY",
"DIRECTORY_IDS_TO_LOG",
"NEXT_PUBLIC_SINGLE_ORG_SLUG",
"GOOGLE_REFRESH_TOKEN",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"ZOOM_REFRESH_TOKEN",
"CALCOM_ADMIN_API_KEY",
"NEXT_PUBLIC_SINGLE_ORG_MODE_ENABLED",
"CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY",
"CAL_VIDEO_RECORDING_TOKEN_SECRET",
"ORGANIZER_EMAIL_EXEMPT_DOMAINS",
"SLOTS_CACHE_TTL"
]
}
}
+34 -34
View File
@@ -2500,7 +2500,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*"
"@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.306"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.314"
"@calcom/platform-types": "*"
"@calcom/platform-utils": "*"
"@calcom/prisma": "*"
@@ -3559,13 +3559,13 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.306":
version: 0.0.306
resolution: "@calcom/platform-libraries@npm:0.0.306"
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.314":
version: 0.0.314
resolution: "@calcom/platform-libraries@npm:0.0.314"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: 602b8b446508429096b8e03e9edbe1ff744cf7c7af3fbe8a683aaaf0d3c96d1cd30ff356f72682449ca072beadaae95fdaa5c44b26e30cfce3bb838f519216f9
checksum: b59eb591273334ed7d7d07a8f0518eb8cb40255883dff49626f85d3bd804db4da357bf77cd4f7b14ae0743d0a699647232b39956af9123bdc5e7b2458cab30eb
languageName: node
linkType: hard
@@ -21694,7 +21694,7 @@ __metadata:
prismock: ^1.33.4
resize-observer-polyfill: ^1.5.1
tsc-absolute: ^1.0.0
turbo: ^1.10.1
turbo: ^2.5.5
typescript: 5.9.0-beta
vitest: ^2.1.1
vitest-fetch-mock: ^0.3.0
@@ -44926,58 +44926,58 @@ __metadata:
languageName: node
linkType: hard
"turbo-darwin-64@npm:1.10.1":
version: 1.10.1
resolution: "turbo-darwin-64@npm:1.10.1"
"turbo-darwin-64@npm:2.5.5":
version: 2.5.5
resolution: "turbo-darwin-64@npm:2.5.5"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"turbo-darwin-arm64@npm:1.10.1":
version: 1.10.1
resolution: "turbo-darwin-arm64@npm:1.10.1"
"turbo-darwin-arm64@npm:2.5.5":
version: 2.5.5
resolution: "turbo-darwin-arm64@npm:2.5.5"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"turbo-linux-64@npm:1.10.1":
version: 1.10.1
resolution: "turbo-linux-64@npm:1.10.1"
"turbo-linux-64@npm:2.5.5":
version: 2.5.5
resolution: "turbo-linux-64@npm:2.5.5"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"turbo-linux-arm64@npm:1.10.1":
version: 1.10.1
resolution: "turbo-linux-arm64@npm:1.10.1"
"turbo-linux-arm64@npm:2.5.5":
version: 2.5.5
resolution: "turbo-linux-arm64@npm:2.5.5"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"turbo-windows-64@npm:1.10.1":
version: 1.10.1
resolution: "turbo-windows-64@npm:1.10.1"
"turbo-windows-64@npm:2.5.5":
version: 2.5.5
resolution: "turbo-windows-64@npm:2.5.5"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"turbo-windows-arm64@npm:1.10.1":
version: 1.10.1
resolution: "turbo-windows-arm64@npm:1.10.1"
"turbo-windows-arm64@npm:2.5.5":
version: 2.5.5
resolution: "turbo-windows-arm64@npm:2.5.5"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"turbo@npm:^1.10.1":
version: 1.10.1
resolution: "turbo@npm:1.10.1"
"turbo@npm:^2.5.5":
version: 2.5.5
resolution: "turbo@npm:2.5.5"
dependencies:
turbo-darwin-64: 1.10.1
turbo-darwin-arm64: 1.10.1
turbo-linux-64: 1.10.1
turbo-linux-arm64: 1.10.1
turbo-windows-64: 1.10.1
turbo-windows-arm64: 1.10.1
turbo-darwin-64: 2.5.5
turbo-darwin-arm64: 2.5.5
turbo-linux-64: 2.5.5
turbo-linux-arm64: 2.5.5
turbo-windows-64: 2.5.5
turbo-windows-arm64: 2.5.5
dependenciesMeta:
turbo-darwin-64:
optional: true
@@ -44993,7 +44993,7 @@ __metadata:
optional: true
bin:
turbo: bin/turbo
checksum: 3551c57be67e833583de549d74d5ec991e0f1e38047e25600b8acc085a1a8a4007992259a015eb9f2c0abd68156545800bd34e7b5ad337d1998b32461952d3fb
checksum: 8da6525097ba17bb59571eb512f216362b046bbf88765ddb05fb9937a44e04eeba3bb883c55fca53a7e8a104e02366b4f6f99ef90d12078d0dc95d4659004af6
languageName: node
linkType: hard