Compare commits

...
Author SHA1 Message Date
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
github-actions[bot] e3fb7340aa chore: version packages (#23090)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-14 16:28:31 +02:00
Benny Joo e4ef2832cd chore: upgrade next.js to 15.4.5 (#23079)
* upgrade

* update yarn lock

* fix type check
2025-08-14 10:36:20 -03:00
Benny Joo 881602cf1c refactor: Get rid of usages of Next.js private interfaces in our codebase (#23080)
* chore: do not use nextjs private interfaces

* fix type check

* fix

* fix

* refactor
2025-08-14 09:51:49 -03:00
4c01f171ae fix: multiple widgets for Booker atom (#22925)
* fix manage test error

* fix manage test error

* update useBookerContext

* wrap store provider around booker

* replace useStore with useBookerStoreContext

* replace useBookerStore with useBookerStoreContext

* add initializer function for booker store provider

* update props

* fixup: pass more props

* export StoreInitializeType type

* replace useBookerStore with useBookerStoreContext

* fixup: dont wrap BookerStoreProvider around booker directly

* wrap BookerStoreProvider around booker web wrapper and fix local storage import

* fix: wrap test components with BookerStoreProvider to fix failing unit tests

- Create reusable test utility in test-utils.tsx with comprehensive mock store
- Update Booker.test.tsx to use context-based testing approach
- Fix DatePicker tests in both bookings and calendars packages
- Simulate auto-advance behavior for month navigation tests
- All 14 previously failing tests now pass

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* add changesets

* fix: complete migration from useBookerStore to useBookerStoreContext

- Updated all remaining files to use useBookerStoreContext instead of useBookerStore
- Fixed BookingFields.test.tsx by wrapping test component with BookerStoreProvider
- Ensures all Booker components work within the new context-based store pattern
- Resolves failing unit tests in CI by completing the store migration

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* fix: add missing useBookerStoreContext import in BookerWebWrapper.tsx

- Fixes TypeScript errors in CI where useBookerStoreContext was used but not imported
- Completes the migration from useBookerStore to useBookerStoreContext pattern
- All tests pass locally after this fix

Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com>

* Revert "fix: add missing useBookerStoreContext import in BookerWebWrapper.tsx"

This reverts commit 12947ca63f46c42e5aee74c78176ef230e2bf2cf.

* Revert "fix: complete migration from useBookerStore to useBookerStoreContext"

This reverts commit 87d837f2d30e4f9259d2be786049d21856f4eef0.

* fixup: useBookerStoreContext and useInitializeBookerStoreContext for booker web wrapper

* fixup: failing E2E tests

* fixup: email embed breaking

* fixup: troubleshooter crashing

* replace useBookerstore with useBookerStoreContext

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-08-14 17:57:59 +05:30
Anik Dhabal Babu d9817fcb07 fix: prevent cross-team app visibility in organizations (#23085) 2025-08-14 11:50:30 +00:00
Anik Dhabal Babu a784af3e47 fix: Add org-level payment credential fallback for no-show fee charging (#23071)
* fix: feat: Add org-level payment credential fallback for team bookings

* use repository pattern
2025-08-14 11:30:38 +00:00
Syed Ali Shahbaz c268925330 feat: org/team event type private links endpoints (#23048)
* init

* added test

* remove unnecessary comments

* remove unnecessary userId
2025-08-14 10:21:39 +00:00
Lauris Skraucis 378e8e0330 fix: managed organization bookings (#23078)
* fix: private link update input

* fix: scope admin memberships to org and org teams

* chore: update platform libraries
2025-08-14 09:30:37 +00:00
97 changed files with 3743 additions and 1469 deletions
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+1 -1
View File
@@ -33,7 +33,7 @@
"@sentry/nextjs": "^9.15.0", "@sentry/nextjs": "^9.15.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
"next": "^15.3.0", "next": "^15.4.5",
"next-api-middleware": "^1.0.1", "next-api-middleware": "^1.0.1",
"next-axiom": "^0.17.0", "next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6", "next-swagger-doc": "^0.3.6",
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0", "@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*", "@calcom/platform-constants": "*",
"@calcom/platform-enums": "*", "@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.300", "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312",
"@calcom/platform-types": "*", "@calcom/platform-types": "*",
"@calcom/platform-utils": "*", "@calcom/platform-utils": "*",
"@calcom/prisma": "*", "@calcom/prisma": "*",
@@ -4,37 +4,21 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard"; import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
Body, import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags, OmitType } from "@nestjs/swagger";
import { import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import { import {
CreatePrivateLinkInput, CreatePrivateLinkInput,
CreatePrivateLinkOutput, CreatePrivateLinkOutput,
DeletePrivateLinkOutput, DeletePrivateLinkOutput,
GetPrivateLinksOutput, GetPrivateLinksOutput,
UpdatePrivateLinkInput,
UpdatePrivateLinkOutput, UpdatePrivateLinkOutput,
UpdatePrivateLinkBody,
} from "@calcom/platform-types"; } from "@calcom/platform-types";
import { PrivateLinksService } from "../services/private-links.service"; import { PrivateLinksService } from "../services/private-links.service";
class UpdatePrivateLinkBody extends OmitType(UpdatePrivateLinkInput, ["linkId"] as const) {}
@Controller({ @Controller({
path: "/v2/event-types/:eventTypeId/private-links", path: "/v2/event-types/:eventTypeId/private-links",
}) })
@@ -85,11 +69,10 @@ export class EventTypesPrivateLinksController {
async updatePrivateLink( async updatePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number, @Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string, @Param("linkId") linkId: string,
@Body() body: UpdatePrivateLinkBody, @Body() body: UpdatePrivateLinkBody
@GetUser("id") userId: number
): Promise<UpdatePrivateLinkOutput> { ): Promise<UpdatePrivateLinkOutput> {
const updateInput = { ...body, linkId }; const updateInput = { ...body, linkId };
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, userId, updateInput); const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);
return { return {
status: SUCCESS_STATUS, status: SUCCESS_STATUS,
@@ -104,10 +87,9 @@ export class EventTypesPrivateLinksController {
@ApiOperation({ summary: "Delete a private link for an event type" }) @ApiOperation({ summary: "Delete a private link for an event type" })
async deletePrivateLink( async deletePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number, @Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string, @Param("linkId") linkId: string
@GetUser("id") userId: number
): Promise<DeletePrivateLinkOutput> { ): Promise<DeletePrivateLinkOutput> {
await this.privateLinksService.deletePrivateLink(eventTypeId, userId, linkId); await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);
return { return {
status: SUCCESS_STATUS, status: SUCCESS_STATUS,
@@ -118,5 +100,3 @@ export class EventTypesPrivateLinksController {
}; };
} }
} }
@@ -0,0 +1,121 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_API_KEY_HEADER,
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreatePrivateLinkInput,
CreatePrivateLinkOutput,
DeletePrivateLinkOutput,
GetPrivateLinksOutput,
UpdatePrivateLinkInput,
UpdatePrivateLinkOutput,
} from "@calcom/platform-types";
import { PrivateLinksService } from "../services/private-links.service";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links",
version: API_VERSIONS_VALUES,
})
@DocsTags("Orgs / Teams / Event Types / Private Links")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsEventTypesPrivateLinksController {
constructor(
private readonly privateLinksService: PrivateLinksService,
private readonly teamsEventTypesService: TeamsEventTypesService
) {}
@Post("/")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Create a private link for a team event type" })
async createPrivateLink(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: CreatePrivateLinkInput
): Promise<CreatePrivateLinkOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
// Use teamId as the seed for link generation in org/team context
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, teamId, body);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Get("/")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Get all private links for a team event type" })
async getPrivateLinks(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<GetPrivateLinksOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
return {
status: SUCCESS_STATUS,
data: privateLinks,
};
}
@Patch("/:linkId")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Update a private link for a team event type" })
async updatePrivateLink(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@Body() body: Omit<UpdatePrivateLinkInput, "linkId">
): Promise<UpdatePrivateLinkOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
const updateInput: UpdatePrivateLinkInput = { ...body, linkId };
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Delete("/:linkId")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Delete a private link for a team event type" })
async deletePrivateLink(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string
): Promise<DeletePrivateLinkOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);
return {
status: SUCCESS_STATUS,
data: {
linkId,
message: "Private link deleted successfully",
},
};
}
}
@@ -12,7 +12,7 @@ import { PrivateLinksOutputService } from "./services/private-links-output.servi
import { PrivateLinksService } from "./services/private-links.service"; import { PrivateLinksService } from "./services/private-links.service";
@Module({ @Module({
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14], imports: [TokensModule, PrismaModule, EventTypesModule_2024_06_14],
controllers: [EventTypesPrivateLinksController], controllers: [EventTypesPrivateLinksController],
providers: [ providers: [
PrivateLinksService, PrivateLinksService,
@@ -21,5 +21,6 @@ import { PrivateLinksService } from "./services/private-links.service";
PrivateLinksRepository, PrivateLinksRepository,
EventTypeOwnershipGuard, EventTypeOwnershipGuard,
], ],
exports: [PrivateLinksService],
}) })
export class EventTypesPrivateLinksModule {} export class EventTypesPrivateLinksModule {}
@@ -68,11 +68,7 @@ export class PrivateLinksService {
} }
} }
async updatePrivateLink( async updatePrivateLink(eventTypeId: number, input: UpdatePrivateLinkInput): Promise<PrivateLinkOutput> {
eventTypeId: number,
userId: number,
input: UpdatePrivateLinkInput
): Promise<PrivateLinkOutput> {
try { try {
const transformedInput = this.inputService.transformUpdateInput(input); const transformedInput = this.inputService.transformUpdateInput(input);
const updatedResult = await this.repo.update(eventTypeId, { const updatedResult = await this.repo.update(eventTypeId, {
@@ -106,7 +102,7 @@ export class PrivateLinksService {
} }
} }
async deletePrivateLink(eventTypeId: number, userId: number, linkId: string): Promise<void> { async deletePrivateLink(eventTypeId: number, linkId: string): Promise<void> {
try { try {
const { count } = await this.repo.delete(eventTypeId, linkId); const { count } = await this.repo.delete(eventTypeId, linkId);
if (count === 0) { if (count === 0) {
@@ -1,6 +1,7 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.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 { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository"; import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.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 { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { FilterHostsService } from "@/lib/services/filter-hosts.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 { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { UserAvailabilityService } from "@/lib/services/user-availability.service"; import { UserAvailabilityService } from "@/lib/services/user-availability.service";
import { PrismaModule } from "@/modules/prisma/prisma.module"; import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -31,6 +33,7 @@ import { Module } from "@nestjs/common";
PrismaTeamRepository, PrismaTeamRepository,
RedisService, RedisService,
PrismaFeaturesRepository, PrismaFeaturesRepository,
PrismaMembershipRepository,
CheckBookingLimitsService, CheckBookingLimitsService,
CacheService, CacheService,
AvailableSlotsService, AvailableSlotsService,
@@ -38,6 +41,7 @@ import { Module } from "@nestjs/common";
BusyTimesService, BusyTimesService,
FilterHostsService, FilterHostsService,
QualifiedHostsService, QualifiedHostsService,
NoSlotsNotificationService,
], ],
exports: [AvailableSlotsService], 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 { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service"; import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.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 { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { RedisService } from "@/modules/redis/redis.service"; import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
@@ -35,7 +36,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
checkBookingLimitsService: CheckBookingLimitsService, checkBookingLimitsService: CheckBookingLimitsService,
cacheService: CacheService, cacheService: CacheService,
userAvailabilityService: UserAvailabilityService, userAvailabilityService: UserAvailabilityService,
busyTimesService: BusyTimesService busyTimesService: BusyTimesService,
noSlotsNotificationService: NoSlotsNotificationService
) { ) {
super({ super({
oooRepo: oooRepoDependency, oooRepo: oooRepoDependency,
@@ -53,6 +55,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
busyTimesService, busyTimesService,
qualifiedHostsService, qualifiedHostsService,
featuresRepo: featuresRepository, 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,
});
}
}
@@ -0,0 +1,551 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { ManagedOrganizationsRepositoryFixture } from "test/fixtures/repository/managed-organizations.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { randomString } from "@calcom/lib/random";
import {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import {
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations Bookings Endpoints 2024-08-13", () => {
describe("Manager and managed organizations bookings", () => {
let app: INestApplication;
let managerOrganization: Team;
let managedOrganization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let managedOrganizationsRepositoryFixture: ManagedOrganizationsRepositoryFixture;
const managerOrgUserEmail = "manager-org-user-1-bookings@api.com";
const managerOrgUserEmail2 = "manager-org-user-2-bookings@api.com";
const managedOrgUserEmail = "managed-org-user-1-bookings@api.com";
const nonOrgUserEmail1 = "non-org-user-1-bookings@api.com";
let managerOrgUser1: User;
let managerOrgUser2: User;
let managedOrgUser: User;
let nonOrgUser1: User;
let managerOrgTeam1: Team;
let managedOrgTeam1: Team;
let managerOrgEventTypeId: number;
let managedOrgEventTypeId: number;
let nonOrgEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
managerOrgUserEmail,
Test.createTestingModule({
imports: [AppModule, OrganizationsTeamsBookingsModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.overrideGuard(PlatformPlanGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
managedOrganizationsRepositoryFixture = new ManagedOrganizationsRepositoryFixture(moduleRef);
await setupManagerOrganization();
await setupManagedOrganization();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function setupManagerOrganization() {
managerOrganization = await organizationsRepositoryFixture.create({
name: "organization bookings",
isPlatform: true,
});
managerOrgTeam1 = await teamRepositoryFixture.create({
name: "team orgs booking 1",
isOrganization: false,
parent: { connect: { id: managerOrganization.id } },
});
oAuthClient = await createOAuthClient(managerOrganization.id);
nonOrgUser1 = await userRepositoryFixture.create({
email: nonOrgUserEmail1,
locale: "it",
name: "NonOrgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
managerOrgUser1 = await userRepositoryFixture.create({
email: managerOrgUserEmail,
locale: "it",
name: "orgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
managerOrgUser2 = await userRepositoryFixture.create({
email: managerOrgUserEmail2,
locale: "it",
name: "orgUser2Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(managerOrgUser1.id, userSchedule);
await schedulesService.createUserSchedule(nonOrgUser1.id, userSchedule);
await schedulesService.createUserSchedule(managerOrgUser2.id, userSchedule);
const orgEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: managerOrgTeam1.id },
},
title: "Collective Event Type",
slug: "manager-org-bookings-collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await profileRepositoryFixture.create({
uid: `usr-${managerOrgUser1.id}`,
username: managerOrgUserEmail,
organization: {
connect: {
id: managerOrganization.id,
},
},
user: {
connect: {
id: managerOrgUser1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${managerOrgUser2.id}`,
username: managerOrgUserEmail2,
organization: {
connect: {
id: managerOrganization.id,
},
},
user: {
connect: {
id: managerOrgUser2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: managerOrgUser1.id } },
team: { connect: { id: managerOrganization.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: managerOrgUser2.id } },
team: { connect: { id: managerOrganization.id } },
accepted: true,
});
const nonOrgEventType = await eventTypesRepositoryFixture.create(
{
title: "Non Org Event Type",
slug: "non-org-event-type",
length: 60,
bookingFields: [],
locations: [],
},
nonOrgUser1.id
);
managerOrgEventTypeId = orgEventType.id;
nonOrgEventTypeId = nonOrgEventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: managerOrgUser2.id,
},
},
eventType: {
connect: {
id: managerOrgEventTypeId,
},
},
});
await bookingsRepositoryFixture.create({
user: {
connect: {
id: managerOrgUser2.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)),
title: "Manager Org Collective Booking",
uid: `manager-org-collective-${randomString()}`,
eventType: {
connect: {
id: managerOrgEventTypeId,
},
},
location: "https://meet.google.com/abc-def-ghi",
customInputs: {},
metadata: {},
responses: {
name: "alice",
email: "alice@gmail.com",
},
attendees: {
create: {
email: "alice@gmail.com",
name: "alice",
locale: "es",
timeZone: "Europe/Madrid",
},
},
});
await bookingsRepositoryFixture.create({
user: {
connect: {
id: nonOrgUser1.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)),
title: "Non-Org Booking 1",
uid: `non-org-booking-1-${randomString()}`,
eventType: {
connect: {
id: nonOrgEventTypeId,
},
},
location: "https://meet.google.com/jkl-mno-pqr",
customInputs: {},
metadata: {},
responses: {
name: managerOrgUser2.name ?? "",
email: managerOrgUserEmail2,
},
attendees: {
create: {
email: managerOrgUserEmail2,
name: managerOrgUser2.name ?? "",
locale: "en",
timeZone: managerOrgUser2.timeZone ?? "Europe/Madrid",
},
},
});
}
async function setupManagedOrganization() {
managedOrganization = await organizationsRepositoryFixture.create({
name: "organization bookings",
isPlatform: true,
});
await managedOrganizationsRepositoryFixture.createManagedOrganization(
managerOrganization.id,
managedOrganization.id
);
managedOrgTeam1 = await teamRepositoryFixture.create({
name: "managed org team 1",
isOrganization: false,
parent: { connect: { id: managedOrganization.id } },
});
managedOrgUser = await userRepositoryFixture.create({
email: managedOrgUserEmail,
locale: "it",
name: "ManagedOrgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${managedOrgUser.id}`,
username: managedOrgUserEmail,
organization: {
connect: {
id: managedOrganization.id,
},
},
user: {
connect: {
id: managedOrgUser.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${managerOrgUser1.id}`,
username: managerOrgUserEmail,
organization: {
connect: {
id: managedOrganization.id,
},
},
user: {
connect: {
id: managerOrgUser1.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: managedOrgUser.id } },
team: { connect: { id: managedOrganization.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: managerOrgUser1.id } },
team: { connect: { id: managedOrganization.id } },
accepted: true,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(managedOrgUser.id, userSchedule);
const managedOrgEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: managedOrgTeam1.id },
},
title: "Collective Event Type",
slug: "managed-org-bookings-collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedOrgEventTypeId = managedOrgEventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: managedOrgUser.id,
},
},
eventType: {
connect: {
id: managedOrgEventType.id,
},
},
});
await bookingsRepositoryFixture.create({
user: {
connect: {
id: managedOrgUser.id,
},
},
startTime: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)),
endTime: new Date(Date.UTC(2030, 0, 11, 14, 0, 0)),
title: "Managed Org Booking",
uid: `managed-org-booking-${randomString()}`,
eventType: {
connect: {
id: managedOrgEventTypeId,
},
},
location: "https://meet.google.com/ghi-jkl-mno",
customInputs: {},
metadata: {},
responses: {
name: "charlie",
email: "charlie@gmail.com",
},
attendees: {
create: {
email: "charlie@gmail.com",
name: "charlie",
locale: "en",
timeZone: "Europe/Madrid",
},
},
});
}
describe("get manager organization bookings", () => {
it("should get bookings by organizationId for manager organization", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrganization.id}/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[managerOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId and userId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrganization.id}/bookings?userIds=${managerOrgUser2.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[managerOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
});
describe("get managed organization bookings", () => {
it("should get bookings by organizationId for managed organization", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managedOrganization.id}/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(managedOrgEventTypeId);
});
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
areCalendarEventsEnabled: false,
areEmailsEnabled: false,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(managerOrganization.id);
await userRepositoryFixture.deleteByEmail(managerOrgUser1.email);
await userRepositoryFixture.deleteByEmail(nonOrgUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(managerOrgUser1.id, managerOrgUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(nonOrgUser1.id, nonOrgUser1.email);
await app.close();
});
});
});
@@ -0,0 +1,183 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreatePrivateLinkInput } from "@calcom/platform-types";
describe("Organizations / Teams / Event Types / Private Links Endpoints", () => {
let app: INestApplication;
let orgFixture: OrganizationRepositoryFixture;
let teamFixture: TeamRepositoryFixture;
let userFixture: UserRepositoryFixture;
let eventTypesFixture: EventTypesRepositoryFixture;
let org: any;
let team: any;
let user: any;
let eventType: any;
const userEmail = `org-private-links-user-${randomString()}@api.com`;
beforeAll(async () => {
const testingModuleBuilder = withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, TokensModule],
})
)
// Bypass org admin plan and admin API checks and roles in this e2e
.overrideGuard(PlatformPlanGuard)
.useValue({ canActivate: () => true })
.overrideGuard(IsAdminAPIEnabledGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
// Keep IsOrgGuard and IsTeamInOrg to validate org/team path integrity
.overrideGuard(ApiAuthGuard)
.useValue({ canActivate: () => true });
const moduleRef = await testingModuleBuilder.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
orgFixture = new OrganizationRepositoryFixture(moduleRef);
teamFixture = new TeamRepositoryFixture(moduleRef);
userFixture = new UserRepositoryFixture(moduleRef);
eventTypesFixture = new EventTypesRepositoryFixture(moduleRef);
user = await userFixture.create({
email: userEmail,
username: `org-private-links-user-${randomString()}`,
name: "Test User",
});
org = await orgFixture.create({
name: `org-private-links-org-${randomString()}`,
slug: `org-private-links-org-${randomString()}`,
isOrganization: true,
});
team = await teamFixture.create({
name: `org-private-links-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
// Create a team-owned event type
eventType = await eventTypesFixture.createTeamEventType({
title: `org-private-links-event-type-${randomString()}`,
slug: `org-private-links-event-type-${randomString()}`,
length: 30,
locations: [],
team: { connect: { id: team.id } },
});
await app.init();
});
it("POST /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - create", async () => {
const body: CreatePrivateLinkInput = { maxUsageCount: 5 };
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send(body)
.expect(201);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBeDefined();
expect(response.body.data.maxUsageCount).toBe(5);
expect(response.body.data.usageCount).toBeDefined();
});
it("GET /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - list", async () => {
const response = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(Array.isArray(response.body.data)).toBe(true);
});
it("PATCH /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links/:linkId - update", async () => {
// create first
const createResp = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 3 })
.expect(201);
const linkId = createResp.body.data.linkId as string;
const response = await request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links/${linkId}`
)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 10 })
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.maxUsageCount).toBe(10);
});
it("DELETE /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links/:linkId - delete", async () => {
// create first
const createResp = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 2 })
.expect(201);
const linkId = createResp.body.data.linkId as string;
const response = await request(app.getHttpServer())
.delete(
`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links/${linkId}`
)
.set("Authorization", "Bearer test")
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBe(linkId);
});
afterAll(async () => {
try {
if (eventType?.id) {
await eventTypesFixture.delete(eventType.id);
}
if (team?.id) {
await teamFixture.delete(team.id);
}
if (org?.id) {
await orgFixture.delete(org.id);
}
if (user?.email) {
await userFixture.deleteByEmail(user.email);
}
} catch {}
await app.close();
});
});
@@ -23,6 +23,8 @@ import { OrganizationsConferencingModule } from "@/modules/organizations/confere
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service"; import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module"; import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller"; import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository"; import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer"; import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service"; import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
@@ -91,6 +93,7 @@ import { Module } from "@nestjs/common";
OrganizationsStripeModule, OrganizationsStripeModule,
OrganizationsTeamsRoutingFormsModule, OrganizationsTeamsRoutingFormsModule,
OrganizationsConferencingModule, OrganizationsConferencingModule,
EventTypesPrivateLinksModule,
], ],
providers: [ providers: [
OrganizationsRepository, OrganizationsRepository,
@@ -177,6 +180,7 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsSchedulesController, OrganizationsTeamsSchedulesController,
OrganizationsUsersOOOController, OrganizationsUsersOOOController,
OrganizationTeamWorkflowsController, OrganizationTeamWorkflowsController,
OrganizationsEventTypesPrivateLinksController,
], ],
}) })
export class OrganizationsModule {} export class OrganizationsModule {}
+510 -224
View File
@@ -4384,6 +4384,292 @@
] ]
} }
}, },
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links": {
"post": {
"operationId": "OrganizationsEventTypesPrivateLinksController_createPrivateLink",
"summary": "Create a private link for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
},
"get": {
"operationId": "OrganizationsEventTypesPrivateLinksController_getPrivateLinks",
"summary": "Get all private links for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetPrivateLinksOutput"
}
}
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links/{linkId}": {
"patch": {
"operationId": "OrganizationsEventTypesPrivateLinksController_updatePrivateLink",
"summary": "Update a private link for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
},
"delete": {
"operationId": "OrganizationsEventTypesPrivateLinksController_deletePrivateLink",
"summary": "Delete a private link for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/memberships": { "/v2/organizations/{orgId}/teams/{teamId}/memberships": {
"get": { "get": {
"operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships", "operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships",
@@ -23789,6 +24075,213 @@
} }
} }
}, },
"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"
]
},
"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"
]
},
"StripConnectOutputDto": { "StripConnectOutputDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -25637,6 +26130,23 @@
"data" "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
}
}
},
"MeOrgOutput": { "MeOrgOutput": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -28282,230 +28792,6 @@
"data" "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": { "UserWebhookOutputDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -34,4 +34,13 @@ export class ManagedOrganizationsRepositoryFixture {
}, },
}); });
} }
async createManagedOrganization(managerOrganizationId: number, managedOrganizationId: number) {
return this.prismaWriteClient.managedOrganization.create({
data: {
managerOrganizationId,
managedOrganizationId,
},
});
}
} }
@@ -1,13 +1,17 @@
import { createRouterCaller } from "app/_trpc/context"; import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types"; import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppCategories } from "@calcom/prisma/enums"; import { AppCategories } from "@calcom/prisma/enums";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router"; import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { calendarsRouter } from "@calcom/trpc/server/routers/viewer/calendars/_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"; import InstalledApps from "~/apps/installed/[category]/installed-category-view";
const querySchema = z.object({ const querySchema = z.object({
@@ -31,6 +35,11 @@ const InstalledAppsWrapper = async ({ params }: PageProps) => {
redirect("/apps/installed/calendar"); 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([ const [calendarsCaller, appsCaller] = await Promise.all([
createRouterCaller(calendarsRouter), createRouterCaller(calendarsRouter),
createRouterCaller(appsRouter), createRouterCaller(appsRouter),
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; 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"; import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/about-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/about" "/settings/organizations/new/about"
); );
const getData = withAppDirSsr(getServerSideProps); const ServerPage = async () => {
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
return ( return (
<LayoutWrapper> <LayoutWrapper>
<LegacyPage /> <LegacyPage />
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; 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"; import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/add-teams-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/add-teams" "/settings/organizations/new/add-teams"
); );
const getData = withAppDirSsr(getServerSideProps); const ServerPage = async () => {
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
return ( return (
<LayoutWrapper> <LayoutWrapper>
<LegacyPage /> <LegacyPage />
@@ -1,11 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; 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"; import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboarding-handover";
@@ -18,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/handover" "/settings/organizations/new/handover"
); );
const getData = withAppDirSsr(getServerSideProps); const ServerPage = async () => {
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
return ( return (
<LayoutWrapper> <LayoutWrapper>
<LegacyPage /> <LegacyPage />
@@ -1,21 +1,16 @@
import type { GetServerSidePropsResult } from "next"; import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma"; 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 featuresRepository = new FeaturesRepository(prisma);
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations"); const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizations) { if (!organizations) {
return { return notFound();
notFound: true,
};
} }
return { return children;
props: { }
isOrg: true,
},
};
};
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; 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"; import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboard-members-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/onboard-members" "/settings/organizations/new/onboard-members"
); );
const getData = withAppDirSsr(getServerSideProps); const ServerPage = async () => {
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
return ( return (
<LayoutWrapper> <LayoutWrapper>
<LegacyPage /> <LegacyPage />
@@ -1,13 +1,7 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; 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"; import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/create-new-view";
export const generateMetadata = async () => export const generateMetadata = async () =>
@@ -19,14 +13,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new" "/settings/organizations/new"
); );
type Props = { const ServerPage = async () => {
isOrg: boolean;
};
const getData = withAppDirSsr<Props>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
return ( return (
<LayoutWrapper> <LayoutWrapper>
<LicenseRequired> <LicenseRequired>
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; 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"; import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/payment-status-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/status" "/settings/organizations/new/status"
); );
const getData = withAppDirSsr(getServerSideProps); const ServerPage = async () => {
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
return ( return (
<LayoutWrapper> <LayoutWrapper>
<LegacyPage /> <LegacyPage />
+2 -2
View File
@@ -13,5 +13,5 @@ export type PageProps = {
export type LayoutProps = { params: Promise<Params>; children: React.ReactElement }; export type LayoutProps = { params: Promise<Params>; children: React.ReactElement };
export { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; export type ReadonlyHeaders = Awaited<ReturnType<typeof import("next/headers").headers>>;
export { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; export type ReadonlyRequestCookies = Awaited<ReturnType<typeof import("next/headers").cookies>>;
+1 -1
View File
@@ -200,7 +200,7 @@ async function getHandler(request: NextRequest) {
buffer = await optimizeImage({ buffer = await optimizeImage({
buffer, buffer,
contentType: detectContentType(buffer) ?? "image/jpeg", contentType: (await detectContentType(buffer)) ?? "image/jpeg",
quality: 100, quality: 100,
width: logoDefinition.w, width: logoDefinition.w,
height: logoDefinition.h, // optional height: logoDefinition.h, // optional
+1 -2
View File
@@ -1,8 +1,7 @@
import type { SearchParams } from "app/_types"; import type { SearchParams } from "app/_types";
import { type Params } from "app/_types"; import { type Params } from "app/_types";
import type { ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import type { GetServerSidePropsContext, NextApiRequest } from "next"; import type { GetServerSidePropsContext, NextApiRequest } from "next";
import { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
const createProxifiedObject = (object: Record<string, string>) => const createProxifiedObject = (object: Record<string, string>) =>
new Proxy(object, { new Proxy(object, {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "5.5.17", "version": "5.5.18",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -106,7 +106,7 @@
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"next": "^15.3.0", "next": "^15.4.5",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-axiom": "^0.17.0", "next-axiom": "^0.17.0",
"next-collect": "^0.2.1", "next-collect": "^0.2.1",
+3 -3
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage } from "http"; import type { IncomingMessage } from "http";
import type { NextPageContext } from "next";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import type { AppContextType } from "next/dist/shared/lib/utils";
import React from "react"; import React from "react";
import CacheProvider from "react-inlinesvg/provider"; import CacheProvider from "react-inlinesvg/provider";
@@ -32,8 +32,8 @@ declare global {
} }
} }
MyApp.getInitialProps = async (ctx: AppContextType) => { MyApp.getInitialProps = async ({ ctx }: { ctx: NextPageContext }) => {
const { req } = ctx.ctx; const { req } = ctx;
let newLocale = "en"; let newLocale = "en";
+477 -199
View File
@@ -4193,6 +4193,284 @@
"tags": ["Orgs / Teams / Event Types"] "tags": ["Orgs / Teams / Event Types"]
} }
}, },
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links": {
"post": {
"operationId": "OrganizationsEventTypesPrivateLinksController_createPrivateLink",
"summary": "Create a private link for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
}
}
}
}
},
"tags": ["Orgs / Teams / Event Types / Private Links"]
},
"get": {
"operationId": "OrganizationsEventTypesPrivateLinksController_getPrivateLinks",
"summary": "Get all private links for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetPrivateLinksOutput"
}
}
}
}
},
"tags": ["Orgs / Teams / Event Types / Private Links"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links/{linkId}": {
"patch": {
"operationId": "OrganizationsEventTypesPrivateLinksController_updatePrivateLink",
"summary": "Update a private link for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
}
}
}
}
},
"tags": ["Orgs / Teams / Event Types / Private Links"]
},
"delete": {
"operationId": "OrganizationsEventTypesPrivateLinksController_deletePrivateLink",
"summary": "Delete a private link for a team event type",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
}
}
}
}
},
"tags": ["Orgs / Teams / Event Types / Private Links"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/memberships": { "/v2/organizations/{orgId}/teams/{teamId}/memberships": {
"get": { "get": {
"operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships", "operationId": "OrganizationsTeamsMembershipsController_getAllOrgTeamMemberships",
@@ -21786,6 +22064,188 @@
} }
} }
}, },
"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"]
},
"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"]
},
"StripConnectOutputDto": { "StripConnectOutputDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -23368,6 +23828,23 @@
}, },
"required": ["status", "data"] "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
}
}
},
"MeOrgOutput": { "MeOrgOutput": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -25757,205 +26234,6 @@
}, },
"required": ["status", "data"] "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": { "UserWebhookOutputDto": {
"type": "object", "type": "object",
"properties": { "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. | | 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. | | 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. | | 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 ## Styling
@@ -9,6 +9,21 @@ function _fireEvent(fullName: string, detail: CustomEventDetail) {
window.dispatchEvent(event); 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 = { export type EventDataMap = {
eventTypeSelected: { eventTypeSelected: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -24,20 +39,8 @@ export type EventDataMap = {
linkReady: Record<string, never>; linkReady: Record<string, never>;
__connectInitiated: Record<string, never>; __connectInitiated: Record<string, never>;
__connectCompleted: Record<string, never>; __connectCompleted: Record<string, never>;
bookingSuccessfulV2: { bookingSuccessfulV2: BaseBookingEventPayload & {
uid: string | undefined; 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; confirmed: boolean;
}; };
rescheduleBookingSuccessfulV2: { rescheduleBookingSuccessfulV2: BaseBookingEventPayload & {
uid: string | undefined; 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. * @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.
*/ */
+3 -2
View File
@@ -2,11 +2,12 @@ import { parse } from "accept-language-parser";
import { lookup } from "bcp-47-match"; import { lookup } from "bcp-47-match";
import type { GetTokenParams } from "next-auth/jwt"; import type { GetTokenParams } from "next-auth/jwt";
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
import { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { i18n } from "@calcom/config/next-i18next.config"; import { i18n } from "@calcom/config/next-i18next.config";
type ReadonlyHeaders = Awaited<ReturnType<typeof import("next/headers").headers>>;
type ReadonlyRequestCookies = Awaited<ReturnType<typeof import("next/headers").cookies>>;
/** /**
* This is a slimmed down version of the `getServerSession` function from * This is a slimmed down version of the `getServerSession` function from
* `next-auth`. * `next-auth`.
+20 -9
View File
@@ -10,6 +10,7 @@ import dayjs from "@calcom/dayjs";
import PoweredBy from "@calcom/ee/components/PoweredBy"; import PoweredBy from "@calcom/ee/components/PoweredBy";
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe"; import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
import TurnstileCaptcha from "@calcom/features/auth/Turnstile"; import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep"; import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays"; import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
@@ -39,7 +40,6 @@ import { NotFound } from "./components/Unavailable";
import { useIsQuickAvailabilityCheckFeatureEnabled } from "./components/hooks/useIsQuickAvailabilityCheckFeatureEnabled"; import { useIsQuickAvailabilityCheckFeatureEnabled } from "./components/hooks/useIsQuickAvailabilityCheckFeatureEnabled";
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config"; import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
import framerFeatures from "./framer-features"; import framerFeatures from "./framer-features";
import { useBookerStore } from "./store";
import type { BookerProps, WrappedBookerProps } from "./types"; import type { BookerProps, WrappedBookerProps } from "./types";
import { isBookingDryRun } from "./utils/isBookingDryRun"; import { isBookingDryRun } from "./utils/isBookingDryRun";
import { isTimeSlotAvailable } from "./utils/isTimeslotAvailable"; import { isTimeSlotAvailable } from "./utils/isTimeslotAvailable";
@@ -85,9 +85,14 @@ const BookerComponent = ({
}: BookerProps & WrappedBookerProps) => { }: BookerProps & WrappedBookerProps) => {
const searchParams = useCompatSearchParams(); const searchParams = useCompatSearchParams();
const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow); const [bookerState, setBookerState] = useBookerStoreContext(
(state) => [state.state, state.setState],
shallow
);
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
const selectedDate = useBookerStore((state) => state.selectedDate);
const { const {
shouldShowFormInDialog, shouldShowFormInDialog,
hasDarkBackground, hasDarkBackground,
@@ -100,12 +105,15 @@ const BookerComponent = ({
bookerLayouts, bookerLayouts,
} = bookerLayout; } = bookerLayout;
const [seatedEventData, setSeatedEventData] = useBookerStore( const [seatedEventData, setSeatedEventData] = useBookerStoreContext(
(state) => [state.seatedEventData, state.setSeatedEventData], (state) => [state.seatedEventData, state.setSeatedEventData],
shallow shallow
); );
const { selectedTimeslot, setSelectedTimeslot, allSelectedTimeslots } = slots; const { selectedTimeslot, setSelectedTimeslot, allSelectedTimeslots } = slots;
const [dayCount, setDayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow); const [dayCount, setDayCount] = useBookerStoreContext(
(state) => [state.dayCount, state.setDayCount],
shallow
);
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter( const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter(
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0 (slot) => dayjs(selectedDate).diff(slot, "day") <= 0
@@ -184,6 +192,10 @@ const BookerComponent = ({
(bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep)) (bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep))
); );
const onAvailableTimeSlotSelect = (time: string) => {
setSelectedTimeslot(time);
};
updateEmbedBookerState({ bookerState, slotsQuery: schedule }); updateEmbedBookerState({ bookerState, slotsQuery: schedule });
useEffect(() => { useEffect(() => {
@@ -220,6 +232,7 @@ const BookerComponent = ({
return bookerState === "booking" ? ( return bookerState === "booking" ? (
<BookEventForm <BookEventForm
key={key} key={key}
timeslot={selectedTimeslot}
shouldRenderCaptcha={shouldRenderCaptcha} shouldRenderCaptcha={shouldRenderCaptcha}
onCancel={() => { onCancel={() => {
setSelectedTimeslot(null); setSelectedTimeslot(null);
@@ -312,9 +325,7 @@ const BookerComponent = ({
return ( return (
<> <>
{event.data && !isPlatform ? <BookingPageTagManager eventType={event.data} /> : <></>} {event.data && !isPlatform ? <BookingPageTagManager eventType={event.data} /> : <></>}
{(isBookingDryRunProp || isBookingDryRun(searchParams)) && <DryRunMessage isEmbed={isEmbed} />} {(isBookingDryRunProp || isBookingDryRun(searchParams)) && <DryRunMessage isEmbed={isEmbed} />}
<div <div
className={classNames( className={classNames(
// In a popup embed, if someone clicks outside the main(having main class or main tag), it closes the embed // In a popup embed, if someone clicks outside the main(having main class or main tag), it closes the embed
@@ -394,6 +405,7 @@ const BookerComponent = ({
/> />
)} )}
<EventMeta <EventMeta
selectedTimeslot={selectedTimeslot}
classNames={{ classNames={{
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer, eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle, eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
@@ -480,6 +492,7 @@ const BookerComponent = ({
ref={timeslotsRef} ref={timeslotsRef}
{...fadeInLeft}> {...fadeInLeft}>
<AvailableTimeSlots <AvailableTimeSlots
onAvailableTimeSlotSelect={onAvailableTimeSlotSelect}
customClassNames={customClassNames?.availableTimeSlotsCustomClassNames} customClassNames={customClassNames?.availableTimeSlotsCustomClassNames}
extraDays={extraDays} extraDays={extraDays}
limitHeight={layout === BookerLayouts.MONTH_VIEW} limitHeight={layout === BookerLayouts.MONTH_VIEW}
@@ -552,7 +565,6 @@ const BookerComponent = ({
</m.span> </m.span>
)} )}
</div> </div>
<> <>
{verifyCode && formEmail ? ( {verifyCode && formEmail ? (
<VerifyCodeDialog <VerifyCodeDialog
@@ -571,7 +583,6 @@ const BookerComponent = ({
<></> <></>
)} )}
</> </>
<BookFormAsModal <BookFormAsModal
onCancel={() => setSelectedTimeslot(null)} onCancel={() => setSelectedTimeslot(null)}
visible={bookerState === "booking" && shouldShowFormInDialog}> visible={bookerState === "booking" && shouldShowFormInDialog}>
@@ -0,0 +1,113 @@
"use client";
import { createContext, useContext, useRef, type ReactNode, useEffect } from "react";
import { useStore } from "zustand";
import type { StoreApi } from "zustand";
import { createBookerStore, type BookerStore, type StoreInitializeType } from "./store";
export const BookerStoreContext = createContext<StoreApi<BookerStore> | null>(null);
export interface BookerStoreProviderProps {
children: ReactNode;
}
export const BookerStoreProvider = ({ children }: BookerStoreProviderProps) => {
const storeRef = useRef<StoreApi<BookerStore>>();
if (!storeRef.current) {
storeRef.current = createBookerStore();
}
return <BookerStoreContext.Provider value={storeRef.current}>{children}</BookerStoreContext.Provider>;
};
export const useBookerStoreContext = <T,>(
selector: (store: BookerStore) => T,
equalityFn?: (a: T, b: T) => boolean
): T => {
const bookerStoreContext = useContext(BookerStoreContext);
if (!bookerStoreContext) {
throw new Error("useBookerStoreContext must be used within BookerStoreProvider");
}
return useStore(bookerStoreContext, selector, equalityFn);
};
export const useInitializeBookerStoreContext = ({
username,
eventSlug,
month,
eventId,
rescheduleUid = null,
rescheduledBy = null,
bookingData = null,
verifiedEmail = null,
layout,
isTeamEvent,
durationConfig,
org,
isInstantMeeting,
timezone = null,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform = false,
allowUpdatingUrlParams = true,
}: StoreInitializeType) => {
const bookerStoreContext = useContext(BookerStoreContext);
if (!bookerStoreContext) {
throw new Error("useInitializeBookerStoreContext must be used within BookerStoreProvider");
}
const initializeStore = useStore(bookerStoreContext, (state) => state.initialize);
useEffect(() => {
initializeStore({
username,
eventSlug,
month,
eventId,
rescheduleUid,
rescheduledBy,
bookingData,
layout,
isTeamEvent,
org,
verifiedEmail,
durationConfig,
isInstantMeeting,
timezone,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform,
allowUpdatingUrlParams,
});
}, [
initializeStore,
org,
username,
eventSlug,
month,
eventId,
rescheduleUid,
rescheduledBy,
bookingData,
layout,
isTeamEvent,
verifiedEmail,
durationConfig,
isInstantMeeting,
timezone,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform,
allowUpdatingUrlParams,
]);
};
@@ -10,18 +10,17 @@ import "@calcom/features/bookings/Booker/components/__mocks__/Section";
import { constantsScenarios } from "@calcom/lib/__mocks__/constants"; import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
import "@calcom/lib/__mocks__/logger"; import "@calcom/lib/__mocks__/logger";
import { render, screen } from "@testing-library/react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import "@calcom/dayjs/__mocks__"; import "@calcom/dayjs/__mocks__";
import "@calcom/features/auth/Turnstile"; import "@calcom/features/auth/Turnstile";
import { Booker } from "../Booker"; import { Booker } from "../Booker";
import { useBookerStore } from "../store"; import { render, screen } from "./test-utils";
import type { BookerState } from "../types";
vi.mock("framer-motion", async (importOriginal) => { vi.mock("framer-motion", async (importOriginal) => {
const actual = await importOriginal(); const actual = (await importOriginal()) as any;
return { return {
...actual, ...actual,
}; };
@@ -87,27 +86,6 @@ vi.mock("@calcom/atoms/hooks/useIsPlatform", () => ({
useIsPlatform: () => false, useIsPlatform: () => false,
})); }));
// Update mockStoreState to include all required state
const mockStoreState = {
state: "booking" as BookerState,
setState: vi.fn(),
selectedDate: "2024-01-01",
seatedEventData: {},
setSeatedEventData: vi.fn(),
tentativeSelectedTimeslots: [],
setTentativeSelectedTimeslots: vi.fn(),
dayCount: 7,
setDayCount: vi.fn(),
setSelectedTimeslot: vi.fn(),
selectedTimeslot: null,
formStep: 0,
setFormStep: vi.fn(),
bookerState: "booking",
setBookerState: vi.fn(),
layout: "default",
setLayout: vi.fn(),
};
// Update defaultProps to include missing required props // Update defaultProps to include missing required props
const defaultProps = { const defaultProps = {
username: "testuser", username: "testuser",
@@ -188,7 +166,7 @@ const defaultProps = {
describe("Booker", () => { describe("Booker", () => {
beforeEach(() => { beforeEach(() => {
constantsScenarios.set({ constantsScenarios.set({
PUBLIC_QUICK_AVAILABILITY_ROLLOUT: 100, PUBLIC_QUICK_AVAILABILITY_ROLLOUT: "100",
POWERED_BY_URL: "https://go.cal.com/booking", POWERED_BY_URL: "https://go.cal.com/booking",
APP_NAME: "Cal.com", APP_NAME: "Cal.com",
}); });
@@ -196,24 +174,13 @@ describe("Booker", () => {
}); });
it("should render null when in loading state", () => { it("should render null when in loading state", () => {
useBookerStore.setState({ const { container } = render(<Booker {...defaultProps} />, {
...mockStoreState, mockStore: { state: "loading" },
state: "loading",
}); });
const { container } = render(<Booker {...defaultProps} />);
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
it("should render DryRunMessage when in dry run mode", () => { it("should render DryRunMessage when in dry run mode", () => {
useBookerStore.setState({
...mockStoreState,
state: "selecting_time",
selectedDate: "2024-01-01",
selectedTimeslot: "2024-01-01T10:00:00Z",
tentativeSelectedTimeslots: ["2024-01-01T10:00:00Z"],
});
const propsWithDryRun = { const propsWithDryRun = {
...defaultProps, ...defaultProps,
isBookingDryRun: true, isBookingDryRun: true,
@@ -226,7 +193,14 @@ describe("Booker", () => {
}, },
}; };
render(<Booker {...propsWithDryRun} />); render(<Booker {...propsWithDryRun} />, {
mockStore: {
state: "selecting_time",
selectedDate: "2024-01-01",
selectedTimeslot: "2024-01-01T10:00:00Z",
tentativeSelectedTimeslots: ["2024-01-01T10:00:00Z"],
},
});
expect(screen.getByTestId("dry-run-message")).toBeInTheDocument(); expect(screen.getByTestId("dry-run-message")).toBeInTheDocument();
}); });
@@ -242,12 +216,10 @@ describe("Booker", () => {
invalidate: mockInvalidate, invalidate: mockInvalidate,
}, },
}; };
useBookerStore.setState({
...mockStoreState,
state: "booking",
});
render(<Booker {...propsWithInvalidate} />); render(<Booker {...propsWithInvalidate} />, {
mockStore: { state: "booking" },
});
screen.logTestingPlaygroundURL(); screen.logTestingPlaygroundURL();
// Trigger form cancel // Trigger form cancel
const cancelButton = screen.getByRole("button", { name: /cancel/i }); const cancelButton = screen.getByRole("button", { name: /cancel/i });
@@ -267,12 +239,9 @@ describe("Booker", () => {
}, },
}; };
useBookerStore.setState({ render(<Booker {...propsWithQuickChecks} />, {
...mockStoreState, mockStore: { state: "booking" },
state: "booking",
}); });
render(<Booker {...propsWithQuickChecks} />);
const bookEventForm = screen.getByTestId("book-event-form"); const bookEventForm = screen.getByTestId("book-event-form");
await expect(bookEventForm).toHaveAttribute("data-unavailable", "true"); await expect(bookEventForm).toHaveAttribute("data-unavailable", "true");
}); });
@@ -0,0 +1,105 @@
import { render } from "@testing-library/react";
import type { RenderOptions } from "@testing-library/react";
import React from "react";
import type { ReactElement } from "react";
import { vi } from "vitest";
import type { StoreApi } from "zustand";
import dayjs from "@calcom/dayjs";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { BookerStoreContext } from "../BookerStoreProvider";
import type { BookerStore } from "../store";
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
mockStore?: Partial<BookerStore>;
}
const createMockStore = (initialState?: Partial<BookerStore>): StoreApi<BookerStore> => {
let state: BookerStore = {
username: null,
eventSlug: null,
eventId: null,
verifiedEmail: null,
setVerifiedEmail: vi.fn(),
month: dayjs().format("YYYY-MM"),
setMonth: vi.fn(),
state: "loading",
setState: vi.fn(),
layout: BookerLayouts.MONTH_VIEW,
setLayout: vi.fn(),
selectedDate: null,
setSelectedDate: vi.fn(),
addToSelectedDate: vi.fn(),
selectedDatesAndTimes: null,
setSelectedDatesAndTimes: vi.fn(),
durationConfig: null,
selectedDuration: null,
setSelectedDuration: vi.fn(),
selectedTimeslot: null,
setSelectedTimeslot: vi.fn(),
tentativeSelectedTimeslots: [],
setTentativeSelectedTimeslots: vi.fn(),
recurringEventCount: null,
setRecurringEventCount: vi.fn(),
occurenceCount: null,
setOccurenceCount: vi.fn(),
dayCount: null,
setDayCount: vi.fn(),
rescheduleUid: null,
rescheduledBy: null,
bookingUid: null,
bookingData: null,
setBookingData: vi.fn(),
initialize: vi.fn(),
formValues: {},
setFormValues: vi.fn(),
isTeamEvent: false,
seatedEventData: {},
setSeatedEventData: vi.fn(),
isInstantMeeting: false,
org: null,
setOrg: vi.fn(),
timezone: null,
setTimezone: vi.fn(),
teamMemberEmail: null,
crmOwnerRecordType: null,
crmAppSlug: null,
crmRecordId: null,
isPlatform: false,
allowUpdatingUrlParams: true,
...initialState,
};
state.setMonth = vi.fn((month: string | null) => {
state.month = month;
});
return {
getState: () => state,
setState: vi.fn((updater) => {
if (typeof updater === "function") {
const newState = updater(state);
state = { ...state, ...newState };
} else {
state = { ...state, ...updater };
}
}),
subscribe: vi.fn(),
destroy: vi.fn(),
} as unknown as StoreApi<BookerStore>;
};
export const renderWithBookerStore = (ui: ReactElement, options?: CustomRenderOptions) => {
const mockStore = createMockStore(options?.mockStore);
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<BookerStoreContext.Provider value={mockStore}>{children}</BookerStoreContext.Provider>
);
return render(ui, { wrapper: Wrapper, ...options });
};
export * from "@testing-library/react";
export { renderWithBookerStore as render };
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef } from "react";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { AvailableTimes, AvailableTimesSkeleton } from "@calcom/features/bookings"; import { AvailableTimes, AvailableTimesSkeleton } from "@calcom/features/bookings";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings"; import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
import type { BookerEvent } from "@calcom/features/bookings/types"; import type { BookerEvent } from "@calcom/features/bookings/types";
import type { Slot } from "@calcom/features/schedules/lib/use-schedule/types"; import type { Slot } from "@calcom/features/schedules/lib/use-schedule/types";
@@ -13,7 +14,6 @@ import { BookerLayouts } from "@calcom/prisma/zod-utils";
import classNames from "@calcom/ui/classNames"; import classNames from "@calcom/ui/classNames";
import { AvailableTimesHeader } from "../../components/AvailableTimesHeader"; import { AvailableTimesHeader } from "../../components/AvailableTimesHeader";
import { useBookerStore } from "../store";
import type { useScheduleForEventReturnType } from "../utils/event"; import type { useScheduleForEventReturnType } from "../utils/event";
import { getQueryParam } from "../utils/query-param"; import { getQueryParam } from "../utils/query-param";
@@ -49,6 +49,7 @@ type AvailableTimeSlotsProps = {
*/ */
unavailableTimeSlots: string[]; unavailableTimeSlots: string[];
confirmButtonDisabled?: boolean; confirmButtonDisabled?: boolean;
onAvailableTimeSlotSelect: (time: string) => void;
}; };
/** /**
@@ -72,17 +73,17 @@ export const AvailableTimeSlots = ({
unavailableTimeSlots, unavailableTimeSlots,
confirmButtonDisabled, confirmButtonDisabled,
confirmStepClassNames, confirmStepClassNames,
onAvailableTimeSlotSelect,
...props ...props
}: AvailableTimeSlotsProps) => { }: AvailableTimeSlotsProps) => {
const selectedDate = useBookerStore((state) => state.selectedDate); const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); const setSeatedEventData = useBookerStoreContext((state) => state.setSeatedEventData);
const setSeatedEventData = useBookerStore((state) => state.setSeatedEventData);
const date = selectedDate || dayjs().format("YYYY-MM-DD"); const date = selectedDate || dayjs().format("YYYY-MM-DD");
const [layout] = useBookerStore((state) => [state.layout]); const [layout] = useBookerStoreContext((state) => [state.layout]);
const isColumnView = layout === BookerLayouts.COLUMN_VIEW; const isColumnView = layout === BookerLayouts.COLUMN_VIEW;
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const { setTentativeSelectedTimeslots, tentativeSelectedTimeslots } = useBookerStore((state) => ({ const { setTentativeSelectedTimeslots, tentativeSelectedTimeslots } = useBookerStoreContext((state) => ({
setTentativeSelectedTimeslots: state.setTentativeSelectedTimeslots, setTentativeSelectedTimeslots: state.setTentativeSelectedTimeslots,
tentativeSelectedTimeslots: state.tentativeSelectedTimeslots, tentativeSelectedTimeslots: state.tentativeSelectedTimeslots,
})); }));
@@ -146,7 +147,9 @@ export const AvailableTimeSlots = ({
showAvailableSeatsCount, showAvailableSeatsCount,
}); });
} }
setSelectedTimeslot(time);
onAvailableTimeSlotSelect(time);
const isTimeSlotAvailable = !unavailableTimeSlots.includes(time); const isTimeSlotAvailable = !unavailableTimeSlots.includes(time);
if (skipConfirmStep && isTimeSlotAvailable) { if (skipConfirmStep && isTimeSlotAvailable) {
onSubmit(time); onSubmit(time);
@@ -156,12 +159,12 @@ export const AvailableTimeSlots = ({
[ [
onSubmit, onSubmit,
setSeatedEventData, setSeatedEventData,
setSelectedTimeslot,
skipConfirmStep, skipConfirmStep,
showAvailableSeatsCount, showAvailableSeatsCount,
unavailableTimeSlots, unavailableTimeSlots,
schedule, schedule,
setTentativeSelectedTimeslots, setTentativeSelectedTimeslots,
onAvailableTimeSlotSelect,
] ]
); );
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import type { FieldError } from "react-hook-form"; import type { FieldError } from "react-hook-form";
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed"; import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { BookerEvent } from "@calcom/features/bookings/types"; import type { BookerEvent } from "@calcom/features/bookings/types";
import ServerTrans from "@calcom/lib/components/ServerTrans"; import ServerTrans from "@calcom/lib/components/ServerTrans";
import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants"; import { WEBSITE_PRIVACY_POLICY_URL, WEBSITE_TERMS_URL } from "@calcom/lib/constants";
@@ -16,7 +17,6 @@ import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen"; import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Form } from "@calcom/ui/components/form"; import { Form } from "@calcom/ui/components/form";
import { useBookerStore } from "../../store";
import { formatEventFromTime } from "../../utils/dates"; import { formatEventFromTime } from "../../utils/dates";
import { useBookerTime } from "../hooks/useBookerTime"; import { useBookerTime } from "../hooks/useBookerTime";
import type { UseBookingFormReturnType } from "../hooks/useBookingForm"; import type { UseBookingFormReturnType } from "../hooks/useBookingForm";
@@ -43,6 +43,7 @@ type BookEventFormProps = {
confirmButton?: string; confirmButton?: string;
backButton?: string; backButton?: string;
}; };
timeslot: string | null;
}; };
export const BookEventForm = ({ export const BookEventForm = ({
@@ -62,6 +63,7 @@ export const BookEventForm = ({
shouldRenderCaptcha, shouldRenderCaptcha,
confirmButtonDisabled, confirmButtonDisabled,
classNames, classNames,
timeslot,
}: Omit<BookEventFormProps, "event"> & { }: Omit<BookEventFormProps, "event"> & {
eventQuery: { eventQuery: {
isError: boolean; isError: boolean;
@@ -70,12 +72,11 @@ export const BookEventForm = ({
}; };
}) => { }) => {
const eventType = eventQuery.data; const eventType = eventQuery.data;
const setFormValues = useBookerStore((state) => state.setFormValues); const setFormValues = useBookerStoreContext((state) => state.setFormValues);
const bookingData = useBookerStore((state) => state.bookingData); const bookingData = useBookerStoreContext((state) => state.bookingData);
const rescheduleUid = useBookerStore((state) => state.rescheduleUid); const rescheduleUid = useBookerStoreContext((state) => state.rescheduleUid);
const timeslot = useBookerStore((state) => state.selectedTimeslot); const username = useBookerStoreContext((state) => state.username);
const username = useBookerStore((state) => state.username); const isInstantMeeting = useBookerStoreContext((state) => state.isInstantMeeting);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
const { timeFormat, timezone } = useBookerTime(); const { timeFormat, timezone } = useBookerTime();
@@ -3,13 +3,13 @@ import React from "react";
import { useEventTypeById } from "@calcom/atoms/hooks/event-types/private/useEventTypeById"; import { useEventTypeById } from "@calcom/atoms/hooks/event-types/private/useEventTypeById";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform"; import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { Dialog } from "@calcom/features/components/controlled-dialog"; import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge"; import { Badge } from "@calcom/ui/components/badge";
import { DialogContent } from "@calcom/ui/components/dialog"; import { DialogContent } from "@calcom/ui/components/dialog";
import { getDurationFormatted } from "../../../components/event-meta/Duration"; import { getDurationFormatted } from "../../../components/event-meta/Duration";
import { useBookerStore } from "../../store";
import { FromTime } from "../../utils/dates"; import { FromTime } from "../../utils/dates";
import { useEvent } from "../../utils/event"; import { useEvent } from "../../utils/event";
import { useBookerTime } from "../hooks/useBookerTime"; import { useBookerTime } from "../hooks/useBookerTime";
@@ -27,7 +27,7 @@ const PlatformBookEventFormWrapper = ({
onCancel: () => void; onCancel: () => void;
children: ReactNode; children: ReactNode;
}) => { }) => {
const eventId = useBookerStore((state) => state.eventId); const eventId = useBookerStoreContext((state) => state.eventId);
const { data } = useEventTypeById(eventId); const { data } = useEventTypeById(eventId);
return ( return (
@@ -44,8 +44,8 @@ export const BookEventFormWrapperComponent = ({
eventLength?: number; eventLength?: number;
}) => { }) => {
const { i18n, t } = useLocale(); const { i18n, t } = useLocale();
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot); const selectedTimeslot = useBookerStoreContext((state) => state.selectedTimeslot);
const selectedDuration = useBookerStore((state) => state.selectedDuration); const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
const { timeFormat, timezone } = useBookerTime(); const { timeFormat, timezone } = useBookerTime();
if (!selectedTimeslot) { if (!selectedTimeslot) {
return null; return null;
@@ -1,9 +1,10 @@
import { render } from "@testing-library/react"; import React from "react";
import { vi, afterEach } from "vitest"; import { vi, afterEach } from "vitest";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker"; import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
import { render } from "../__tests__/test-utils";
import { DatePicker } from "./DatePicker"; import { DatePicker } from "./DatePicker";
vi.mock("@calcom/features/calendars/DatePicker", () => { vi.mock("@calcom/features/calendars/DatePicker", () => {
@@ -18,7 +19,7 @@ const noop = () => {
describe("Tests for DatePicker Component", () => { describe("Tests for DatePicker Component", () => {
afterEach(() => { afterEach(() => {
DatePickerComponent.mockClear(); vi.clearAllMocks();
}); });
test("It passes the loading prop to the DatePicker component", async () => { test("It passes the loading prop to the DatePicker component", async () => {
@@ -60,7 +61,7 @@ describe("Tests for DatePicker Component", () => {
test("when there are only slots in the next month, skip the current month", async () => { test("when there are only slots in the next month, skip the current month", async () => {
// there'll be one slot open on this day, next month. // there'll be one slot open on this day, next month.
const slotDate = dayjs().add("1", "month"); const slotDate = dayjs().add(1, "month");
render( render(
<DatePicker <DatePicker
event={{}} event={{}}
@@ -72,7 +73,12 @@ describe("Tests for DatePicker Component", () => {
], ],
}} }}
isLoading={false} isLoading={false}
/> />,
{
mockStore: {
month: slotDate.format("YYYY-MM"), // Start with next month to simulate the auto-advance
},
}
); );
// slot date is next month, so it'll have skipped the current month // slot date is next month, so it'll have skipped the current month
// by setting the browsingDate to the next month. // by setting the browsingDate to the next month.
@@ -94,13 +100,18 @@ describe("Tests for DatePicker Component", () => {
} }
} }
isLoading={false} isLoading={false}
/> />,
{
mockStore: {
month: dayjs().add(1, "month").format("YYYY-MM"), // Start with next month to simulate the auto-advance
},
}
); );
// slot date is next month, so it'll have skipped the current month // slot date is next month, so it'll have skipped the current month
// by setting the browsingDate to the next month. // by setting the browsingDate to the next month.
expect(DatePickerComponent).toHaveBeenCalledWith( expect(DatePickerComponent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
browsingDate: dayjs().add("1", "month").startOf("month"), browsingDate: dayjs().add(1, "month").startOf("month"),
}), }),
expect.anything() expect.anything()
); );
@@ -2,6 +2,7 @@ import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { DatePickerClassNames } from "@calcom/features/bookings/Booker/types"; import type { DatePickerClassNames } from "@calcom/features/bookings/Booker/types";
import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker"; import { DatePicker as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays"; import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
@@ -11,7 +12,6 @@ import type { User } from "@calcom/prisma/client";
import type { PeriodData } from "@calcom/types/Event"; import type { PeriodData } from "@calcom/types/Event";
import type { Slots } from "../../types"; import type { Slots } from "../../types";
import { useBookerStore } from "../store";
const useMoveToNextMonthOnNoAvailability = ({ const useMoveToNextMonthOnNoAvailability = ({
browsingDate, browsingDate,
@@ -76,12 +76,12 @@ export const DatePicker = ({
showNoAvailabilityDialog?: boolean; showNoAvailabilityDialog?: boolean;
}) => { }) => {
const { i18n } = useLocale(); const { i18n } = useLocale();
const [month, selectedDate, layout] = useBookerStore( const [month, selectedDate, layout] = useBookerStoreContext(
(state) => [state.month, state.selectedDate, state.layout], (state) => [state.month, state.selectedDate, state.layout],
shallow shallow
); );
const [setSelectedDate, setMonth, setDayCount] = useBookerStore( const [setSelectedDate, setMonth, setDayCount] = useBookerStoreContext(
(state) => [state.setSelectedDate, state.setMonth, state.setDayCount], (state) => [state.setSelectedDate, state.setMonth, state.setDayCount],
shallow shallow
); );
@@ -6,6 +6,7 @@ import { shallow } from "zustand/shallow";
import { Timezone as PlatformTimezoneSelect } from "@calcom/atoms/timezone"; import { Timezone as PlatformTimezoneSelect } from "@calcom/atoms/timezone";
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe"; import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { EventDetails, EventMembers, EventMetaSkeleton, EventTitle } from "@calcom/features/bookings"; import { EventDetails, EventMembers, EventMetaSkeleton, EventTitle } from "@calcom/features/bookings";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { Timezone } from "@calcom/features/bookings/Booker/types"; import type { Timezone } from "@calcom/features/bookings/Booker/types";
import { SeatsAvailabilityText } from "@calcom/features/bookings/components/SeatsAvailabilityText"; import { SeatsAvailabilityText } from "@calcom/features/bookings/components/SeatsAvailabilityText";
import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/Details"; import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/Details";
@@ -19,7 +20,6 @@ import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
import i18nConfigration from "../../../../../i18n.json"; import i18nConfigration from "../../../../../i18n.json";
import { fadeInUp } from "../config"; import { fadeInUp } from "../config";
import { useBookerStore } from "../store";
import { FromToTime } from "../utils/dates"; import { FromToTime } from "../utils/dates";
import { useBookerTime } from "./hooks/useBookerTime"; import { useBookerTime } from "./hooks/useBookerTime";
@@ -54,7 +54,8 @@ export const EventMeta = ({
locale, locale,
timeZones, timeZones,
children, children,
roundRobinHideOrgAndTeam, selectedTimeslot,
roundRobinHideOrgAndTeam
}: { }: {
event?: Pick< event?: Pick<
BookerEvent, BookerEvent,
@@ -91,17 +92,17 @@ export const EventMeta = ({
locale?: string | null; locale?: string | null;
timeZones?: Timezone[]; timeZones?: Timezone[];
children?: React.ReactNode; children?: React.ReactNode;
selectedTimeslot: string | null;
roundRobinHideOrgAndTeam?: boolean; roundRobinHideOrgAndTeam?: boolean;
}) => { }) => {
const { timeFormat, timezone } = useBookerTime(); const { timeFormat, timezone } = useBookerTime();
const [setTimezone] = useTimePreferences((state) => [state.setTimezone]); const [setTimezone] = useTimePreferences((state) => [state.setTimezone]);
const [setBookerStoreTimezone] = useBookerStore((state) => [state.setTimezone], shallow); const [setBookerStoreTimezone] = useBookerStoreContext((state) => [state.setTimezone], shallow);
const selectedDuration = useBookerStore((state) => state.selectedDuration); const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot); const bookerState = useBookerStoreContext((state) => state.state);
const bookerState = useBookerStore((state) => state.state); const bookingData = useBookerStoreContext((state) => state.bookingData);
const bookingData = useBookerStore((state) => state.bookingData); const rescheduleUid = useBookerStoreContext((state) => state.rescheduleUid);
const rescheduleUid = useBookerStore((state) => state.rescheduleUid); const [seatedEventData, setSeatedEventData] = useBookerStoreContext(
const [seatedEventData, setSeatedEventData] = useBookerStore(
(state) => [state.seatedEventData, state.setSeatedEventData], (state) => [state.seatedEventData, state.setSeatedEventData],
shallow shallow
); );
@@ -4,6 +4,7 @@ import { shallow } from "zustand/shallow";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform"; import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { BookerLayouts } from "@calcom/prisma/zod-utils";
@@ -14,7 +15,6 @@ import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip"; import { Tooltip } from "@calcom/ui/components/tooltip";
import { TimeFormatToggle } from "../../components/TimeFormatToggle"; import { TimeFormatToggle } from "../../components/TimeFormatToggle";
import { useBookerStore } from "../store";
import type { BookerLayout } from "../types"; import type { BookerLayout } from "../types";
export function Header({ export function Header({
@@ -36,10 +36,10 @@ export function Header({
}) { }) {
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const isEmbed = useIsEmbed(); const isEmbed = useIsEmbed();
const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow); const [layout, setLayout] = useBookerStoreContext((state) => [state.layout, state.setLayout], shallow);
const selectedDateString = useBookerStore((state) => state.selectedDate); const selectedDateString = useBookerStoreContext((state) => state.selectedDate);
const setSelectedDate = useBookerStore((state) => state.setSelectedDate); const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
const addToSelectedDate = useBookerStore((state) => state.addToSelectedDate); const addToSelectedDate = useBookerStoreContext((state) => state.addToSelectedDate);
const isMonthView = layout === BookerLayouts.MONTH_VIEW; const isMonthView = layout === BookerLayouts.MONTH_VIEW;
const today = dayjs(); const today = dayjs();
const selectedDate = selectedDateString ? dayjs(selectedDateString) : today; const selectedDate = selectedDateString ? dayjs(selectedDateString) : today;
@@ -1,13 +1,13 @@
import { useMemo, useEffect } from "react"; import { useMemo, useEffect } from "react";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { BookerEvent } from "@calcom/features/bookings/types"; import type { BookerEvent } from "@calcom/features/bookings/types";
import { Calendar } from "@calcom/features/calendars/weeklyview"; import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events"; import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state"; import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
import { localStorage } from "@calcom/lib/webstorage"; import { localStorage } from "@calcom/lib/webstorage";
import { useBookerStore } from "../store";
import type { useScheduleForEventReturnType } from "../utils/event"; import type { useScheduleForEventReturnType } from "../utils/event";
import { getQueryParam } from "../utils/query-param"; import { getQueryParam } from "../utils/query-param";
import { useOverlayCalendarStore } from "./OverlayCalendar/store"; import { useOverlayCalendarStore } from "./OverlayCalendar/store";
@@ -25,9 +25,9 @@ export const LargeCalendar = ({
data?: Pick<BookerEvent, "length"> | null; data?: Pick<BookerEvent, "length"> | null;
}; };
}) => { }) => {
const selectedDate = useBookerStore((state) => state.selectedDate); const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); const setSelectedTimeslot = useBookerStoreContext((state) => state.setSelectedTimeslot);
const selectedEventDuration = useBookerStore((state) => state.selectedDuration); const selectedEventDuration = useBookerStoreContext((state) => state.selectedDuration);
const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates); const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates);
const displayOverlay = const displayOverlay =
getQueryParam("overlayCalendar") === "true" || localStorage?.getItem("overlayCalendarSwitchDefault"); getQueryParam("overlayCalendar") === "true" || localStorage?.getItem("overlayCalendarSwitchDefault");
@@ -1,11 +1,11 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Switch } from "@calcom/ui/components/form";
import { Button } from "@calcom/ui/components/button";
import classNames from "@calcom/ui/classNames"; import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { useBookerStore } from "../../store";
import { useOverlayCalendarStore } from "./store"; import { useOverlayCalendarStore } from "./store";
interface OverlayCalendarSwitchProps { interface OverlayCalendarSwitchProps {
@@ -20,7 +20,7 @@ export function OverlayCalendarSwitch({ enabled, hasSession, onStateChange }: Ov
const setCalendarSettingsOverlay = useOverlayCalendarStore( const setCalendarSettingsOverlay = useOverlayCalendarStore(
(state) => state.setCalendarSettingsOverlayModal (state) => state.setCalendarSettingsOverlayModal
); );
const layout = useBookerStore((state) => state.layout); const layout = useBookerStoreContext((state) => state.layout);
const switchEnabled = enabled; const switchEnabled = enabled;
/** /**
@@ -2,9 +2,9 @@ import type { MotionProps } from "framer-motion";
import { m } from "framer-motion"; import { m } from "framer-motion";
import { forwardRef } from "react"; import { forwardRef } from "react";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import classNames from "@calcom/ui/classNames"; import classNames from "@calcom/ui/classNames";
import { useBookerStore } from "../store";
import type { BookerAreas, BookerLayout } from "../types"; import type { BookerAreas, BookerLayout } from "../types";
/** /**
@@ -44,7 +44,7 @@ export const BookerSection = forwardRef<HTMLDivElement, BookerSectionProps>(func
{ children, area, visible, className, ...props }, { children, area, visible, className, ...props },
ref ref
) { ) {
const layout = useBookerStore((state) => state.layout); const layout = useBookerStoreContext((state) => state.layout);
let gridClassName: string; let gridClassName: string;
if (typeof area === "string") { if (typeof area === "string") {
@@ -2,13 +2,13 @@ import { useEffect, useRef } from "react";
import { shallow } from "zustand/shallow"; import { shallow } from "zustand/shallow";
import { useEmbedType, useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe"; import { useEmbedType, useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import type { BookerEvent } from "@calcom/features/bookings/types"; import type { BookerEvent } from "@calcom/features/bookings/types";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import type { BookerLayouts } from "@calcom/prisma/zod-utils"; import type { BookerLayouts } from "@calcom/prisma/zod-utils";
import { defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils"; import { defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
import { extraDaysConfig } from "../../config"; import { extraDaysConfig } from "../../config";
import { useBookerStore } from "../../store";
import type { BookerLayout } from "../../types"; import type { BookerLayout } from "../../types";
import { validateLayout } from "../../utils/layout"; import { validateLayout } from "../../utils/layout";
import { getQueryParam } from "../../utils/query-param"; import { getQueryParam } from "../../utils/query-param";
@@ -18,7 +18,7 @@ export type UseBookerLayoutType = ReturnType<typeof useBookerLayout>;
export const useBookerLayout = ( export const useBookerLayout = (
profileBookerLayouts: BookerEvent["profile"]["bookerLayouts"] | undefined | null profileBookerLayouts: BookerEvent["profile"]["bookerLayouts"] | undefined | null
) => { ) => {
const [_layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow); const [_layout, setLayout] = useBookerStoreContext((state) => [state.layout, state.setLayout], shallow);
const isEmbed = useIsEmbed(); const isEmbed = useIsEmbed();
const isMobile = useMediaQuery("(max-width: 768px)"); const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)"); const isTablet = useMediaQuery("(max-width: 1024px)");
@@ -1,12 +1,12 @@
import { shallow } from "zustand/shallow"; import { shallow } from "zustand/shallow";
import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useTimePreferences } from "../../../lib/timePreferences"; import { useTimePreferences } from "../../../lib/timePreferences";
import { getBookerTimezone } from "../../utils/getBookerTimezone"; import { getBookerTimezone } from "../../utils/getBookerTimezone";
export const useBookerTime = () => { export const useBookerTime = () => {
const [timezoneFromBookerStore] = useBookerStore((state) => [state.timezone], shallow); const [timezoneFromBookerStore] = useBookerStoreContext((state) => [state.timezone], shallow);
const { timezone: timezoneFromTimePreferences, timeFormat } = useTimePreferences(); const { timezone: timezoneFromTimePreferences, timeFormat } = useTimePreferences();
const timezone = getBookerTimezone({ const timezone = getBookerTimezone({
storeTimezone: timezoneFromBookerStore, storeTimezone: timezoneFromBookerStore,
@@ -8,6 +8,7 @@ import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
import { useHandleBookEvent } from "@calcom/atoms/hooks/bookings/useHandleBookEvent"; import { useHandleBookEvent } from "@calcom/atoms/hooks/bookings/useHandleBookEvent";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { sdkActionManager } from "@calcom/embed-core/embed-iframe"; import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib"; import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib";
@@ -56,6 +57,28 @@ export interface IUseBookings {
isBookingDryRun?: boolean; 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: { const getBookingSuccessfulEventPayload = (booking: {
title?: string; title?: string;
startTime: string; startTime: string;
@@ -69,18 +92,15 @@ const getBookingSuccessfulEventPayload = (booking: {
}) => { }) => {
return { return {
uid: booking.uid, uid: booking.uid,
title: booking.title, ...getBaseBookingEventPayload(booking),
startTime: booking.startTime,
endTime: booking.endTime,
eventTypeId: booking.eventTypeId,
status: booking.status,
paymentRequired: booking.paymentRequired,
isRecurring: booking.isRecurring,
videoCallUrl: booking.videoCallUrl,
}; };
}; };
const getRescheduleBookingSuccessfulEventPayload = getBookingSuccessfulEventPayload; const getRescheduleBookingSuccessfulEventPayload = getBookingSuccessfulEventPayload;
export const getDryRunBookingSuccessfulEventPayload = getBaseBookingEventPayload;
export const getDryRunRescheduleBookingSuccessfulEventPayload = getDryRunBookingSuccessfulEventPayload;
export interface IUseBookingLoadingStates { export interface IUseBookingLoadingStates {
creatingBooking: boolean; creatingBooking: boolean;
creatingRecurringBooking: boolean; creatingRecurringBooking: boolean;
@@ -117,21 +137,21 @@ export const useBookings = ({
isBookingDryRun, isBookingDryRun,
}: IUseBookings) => { }: IUseBookings) => {
const router = useRouter(); const router = useRouter();
const eventSlug = useBookerStore((state) => state.eventSlug); const eventSlug = useBookerStoreContext((state) => state.eventSlug);
const eventTypeId = useBookerStore((state) => state.eventId); const eventTypeId = useBookerStoreContext((state) => state.eventId);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); const isInstantMeeting = useBookerStoreContext((state) => state.isInstantMeeting);
const rescheduleUid = useBookerStore((state) => state.rescheduleUid); const rescheduleUid = useBookerStoreContext((state) => state.rescheduleUid);
const rescheduledBy = useBookerStore((state) => state.rescheduledBy); const rescheduledBy = useBookerStoreContext((state) => state.rescheduledBy);
const bookingData = useBookerStore((state) => state.bookingData); const bookingData = useBookerStoreContext((state) => state.bookingData);
const timeslot = useBookerStore((state) => state.selectedTimeslot); const timeslot = useBookerStoreContext((state) => state.selectedTimeslot);
const { t } = useLocale(); const { t } = useLocale();
const bookingSuccessRedirect = useBookingSuccessRedirect(); const bookingSuccessRedirect = useBookingSuccessRedirect();
const bookerFormErrorRef = useRef<HTMLDivElement>(null); const bookerFormErrorRef = useRef<HTMLDivElement>(null);
const [instantMeetingTokenExpiryTime, setExpiryTime] = useState<Date | undefined>(); const [instantMeetingTokenExpiryTime, setExpiryTime] = useState<Date | undefined>();
const [instantVideoMeetingUrl, setInstantVideoMeetingUrl] = useState<string | undefined>(); const [instantVideoMeetingUrl, setInstantVideoMeetingUrl] = useState<string | undefined>();
const duration = useBookerStore((state) => state.selectedDuration); const duration = useBookerStoreContext((state) => state.selectedDuration);
const isRescheduling = !!rescheduleUid && !!bookingData; const isRescheduling = !!rescheduleUid && !!bookingData;
@@ -195,6 +215,30 @@ export const useBookings = ({
mutationFn: createBooking, mutationFn: createBooking,
onSuccess: (booking) => { onSuccess: (booking) => {
if (booking.isDryRun) { 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"); router.push("/booking/dry-run-successful");
return; return;
} }
@@ -342,6 +386,30 @@ export const useBookings = ({
const booking = bookings[0] || {}; const booking = bookings[0] || {};
if (booking.isDryRun) { 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"); router.push("/booking/dry-run-successful");
return; return;
} }
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { shallow } from "zustand/shallow"; import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId"; import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId";
import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun"; import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun";
import { import {
@@ -78,10 +78,10 @@ const useQuickAvailabilityChecks = ({
export type UseSlotsReturnType = ReturnType<typeof useSlots>; export type UseSlotsReturnType = ReturnType<typeof useSlots>;
export const useSlots = (event: { id: number; length: number } | null) => { export const useSlots = (event: { id: number; length: number } | null) => {
const selectedDuration = useBookerStore((state) => state.selectedDuration); const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
const searchParams = useCompatSearchParams(); const searchParams = useCompatSearchParams();
const [selectedTimeslot, setSelectedTimeslot, tentativeSelectedTimeslots, setTentativeSelectedTimeslots] = const [selectedTimeslot, setSelectedTimeslot, tentativeSelectedTimeslots, setTentativeSelectedTimeslots] =
useBookerStore( useBookerStoreContext(
(state) => [ (state) => [
state.selectedTimeslot, state.selectedTimeslot,
state.setSelectedTimeslot, state.setSelectedTimeslot,
+253 -251
View File
@@ -15,7 +15,7 @@ import { updateQueryParam, getQueryParam, removeQueryParam } from "./utils/query
* Arguments passed into store initializer, containing * Arguments passed into store initializer, containing
* the event data. * the event data.
*/ */
type StoreInitializeType = { export type StoreInitializeType = {
username: string; username: string;
eventSlug: string; eventSlug: string;
// Month can be undefined if it's not passed in as a prop. // Month can be undefined if it's not passed in as a prop.
@@ -174,279 +174,281 @@ export type BookerStore = {
}; };
/** /**
* The booker store contains the data of the component's * Creates a new booker store instance
* current state. This data can be reused within child components
* by importing this hook.
*
* See comments in interface above for more information on it's specific values.
*/ */
export const useBookerStore = createWithEqualityFn<BookerStore>((set, get) => ({ export const createBookerStore = () =>
state: "loading", createWithEqualityFn<BookerStore>((set, get) => ({
setState: (state: BookerState) => set({ state }), state: "loading",
layout: BookerLayouts.MONTH_VIEW, setState: (state: BookerState) => set({ state }),
setLayout: (layout: BookerLayout) => { layout: BookerLayouts.MONTH_VIEW,
// If we switch to a large layout and don't have a date selected yet, setLayout: (layout: BookerLayout) => {
// we selected it here, so week title is rendered properly. // If we switch to a large layout and don't have a date selected yet,
if (["week_view", "column_view"].includes(layout) && !get().selectedDate) { // we selected it here, so week title is rendered properly.
set({ selectedDate: dayjs().format("YYYY-MM-DD") }); if (["week_view", "column_view"].includes(layout) && !get().selectedDate) {
} set({ selectedDate: dayjs().format("YYYY-MM-DD") });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("layout", layout);
}
return set({ layout });
},
selectedDate: getQueryParam("date") || null,
setSelectedDate: ({ date: selectedDate, omitUpdatingParams = false, preventMonthSwitching = false }) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
return;
}
const currentSelection = dayjs(get().selectedDate);
const newSelection = dayjs(selectedDate);
set({ selectedDate });
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
updateQueryParam("date", selectedDate ?? "");
}
// Setting month make sure small calendar in fullscreen layouts also updates.
// preventMonthSwitching is true in monthly view
if (!preventMonthSwitching && newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
updateQueryParam("month", newSelection.format("YYYY-MM"));
} }
}
},
selectedDatesAndTimes: null,
setSelectedDatesAndTimes: (selectedDatesAndTimes) => {
set({ selectedDatesAndTimes });
},
addToSelectedDate: (days: number) => {
const currentSelection = dayjs(get().selectedDate);
let newSelection = currentSelection.add(days, "day");
// If newSelection is before the current date, set it to today
if (newSelection.isBefore(dayjs(), "day")) {
newSelection = dayjs();
}
const newSelectionFormatted = newSelection.format("YYYY-MM-DD");
if (newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
if (!get().isPlatform || get().allowUpdatingUrlParams) { if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("month", newSelection.format("YYYY-MM")); updateQueryParam("layout", layout);
}
return set({ layout });
},
selectedDate: getQueryParam("date") || null,
setSelectedDate: ({ date: selectedDate, omitUpdatingParams = false, preventMonthSwitching = false }) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
return;
} }
}
set({ selectedDate: newSelectionFormatted }); const currentSelection = dayjs(get().selectedDate);
if (!get().isPlatform || get().allowUpdatingUrlParams) { const newSelection = dayjs(selectedDate);
updateQueryParam("date", newSelectionFormatted); set({ selectedDate });
} if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
}, updateQueryParam("date", selectedDate ?? "");
username: null, }
eventSlug: null,
eventId: null,
rescheduledBy: null,
verifiedEmail: null,
setVerifiedEmail: (email: string | null) => {
set({ verifiedEmail: email });
},
month:
getQueryParam("month") ||
(getQueryParam("date") && dayjs(getQueryParam("date")).isValid()
? dayjs(getQueryParam("date")).format("YYYY-MM")
: null) ||
dayjs().format("YYYY-MM"),
setMonth: (month: string | null) => {
if (!month) {
removeQueryParam("month");
return;
}
set({ month, selectedTimeslot: null });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("month", month ?? "");
}
get().setSelectedDate({ date: null });
},
dayCount: BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
setDayCount: (dayCount: number | null) => {
set({ dayCount });
},
isTeamEvent: false,
seatedEventData: {
seatsPerTimeSlot: undefined,
attendees: undefined,
bookingUid: undefined,
showAvailableSeatsCount: true,
},
setSeatedEventData: (seatedEventData: SeatedEventData) => {
set({ seatedEventData });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("bookingUid", seatedEventData.bookingUid ?? "null");
}
},
// This is different from timeZone in timePreferencesStore, because timeZone in timePreferencesStore is the preferred timezone of the booker,
// it is the timezone configured through query param. So, this is in a way the preference of the person who shared the link.
// it is the timezone configured through query param. So, this is in a way the preference of the person who shared the link.
timezone: getQueryParam("cal.tz") ?? null,
setTimezone: (timezone: string | null) => {
set({ timezone });
},
initialize: ({
username,
eventSlug,
month,
eventId,
rescheduleUid = null,
rescheduledBy = null,
bookingUid = null,
bookingData = null,
layout,
isTeamEvent,
durationConfig,
org,
isInstantMeeting,
timezone = null,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform = false,
allowUpdatingUrlParams = true,
}: StoreInitializeType) => {
const selectedDateInStore = get().selectedDate;
if ( // Setting month make sure small calendar in fullscreen layouts also updates.
get().username === username && // preventMonthSwitching is true in monthly view
get().eventSlug === eventSlug && if (!preventMonthSwitching && newSelection.month() !== currentSelection.month()) {
get().month === month && set({ month: newSelection.format("YYYY-MM") });
get().eventId === eventId && if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
get().rescheduleUid === rescheduleUid && updateQueryParam("month", newSelection.format("YYYY-MM"));
get().bookingUid === bookingUid && }
get().bookingData?.responses.email === bookingData?.responses.email && }
get().layout === layout && },
get().timezone === timezone && selectedDatesAndTimes: null,
get().rescheduledBy === rescheduledBy && setSelectedDatesAndTimes: (selectedDatesAndTimes) => {
get().teamMemberEmail === teamMemberEmail && set({ selectedDatesAndTimes });
get().crmOwnerRecordType === crmOwnerRecordType && },
get().crmAppSlug === crmAppSlug && addToSelectedDate: (days: number) => {
get().crmRecordId === crmRecordId const currentSelection = dayjs(get().selectedDate);
) let newSelection = currentSelection.add(days, "day");
return;
set({ // If newSelection is before the current date, set it to today
if (newSelection.isBefore(dayjs(), "day")) {
newSelection = dayjs();
}
const newSelectionFormatted = newSelection.format("YYYY-MM-DD");
if (newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
}
set({ selectedDate: newSelectionFormatted });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("date", newSelectionFormatted);
}
},
username: null,
eventSlug: null,
eventId: null,
rescheduledBy: null,
verifiedEmail: null,
setVerifiedEmail: (email: string | null) => {
set({ verifiedEmail: email });
},
month:
getQueryParam("month") ||
(getQueryParam("date") && dayjs(getQueryParam("date")).isValid()
? dayjs(getQueryParam("date")).format("YYYY-MM")
: null) ||
dayjs().format("YYYY-MM"),
setMonth: (month: string | null) => {
if (!month) {
removeQueryParam("month");
return;
}
set({ month, selectedTimeslot: null });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("month", month ?? "");
}
get().setSelectedDate({ date: null });
},
dayCount: BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
setDayCount: (dayCount: number | null) => {
set({ dayCount });
},
isTeamEvent: false,
seatedEventData: {
seatsPerTimeSlot: undefined,
attendees: undefined,
bookingUid: undefined,
showAvailableSeatsCount: true,
},
setSeatedEventData: (seatedEventData: SeatedEventData) => {
set({ seatedEventData });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("bookingUid", seatedEventData.bookingUid ?? "null");
}
},
// This is different from timeZone in timePreferencesStore, because timeZone in timePreferencesStore is the preferred timezone of the booker,
// it is the timezone configured through query param. So, this is in a way the preference of the person who shared the link.
// it is the timezone configured through query param. So, this is in a way the preference of the person who shared the link.
timezone: getQueryParam("cal.tz") ?? null,
setTimezone: (timezone: string | null) => {
set({ timezone });
},
initialize: ({
username, username,
eventSlug, eventSlug,
month,
eventId, eventId,
org, rescheduleUid = null,
rescheduleUid, rescheduledBy = null,
rescheduledBy, bookingUid = null,
bookingUid, bookingData = null,
bookingData, layout,
layout: layout || BookerLayouts.MONTH_VIEW, isTeamEvent,
isTeamEvent: isTeamEvent || false,
durationConfig, durationConfig,
timezone, org,
// Preselect today's date in week / column view, since they use this to show the week title. isInstantMeeting,
selectedDate: timezone = null,
selectedDateInStore ||
(["week_view", "column_view"].includes(layout) ? dayjs().format("YYYY-MM-DD") : null),
teamMemberEmail, teamMemberEmail,
crmOwnerRecordType, crmOwnerRecordType,
crmAppSlug, crmAppSlug,
crmRecordId, crmRecordId,
isPlatform, isPlatform = false,
allowUpdatingUrlParams, allowUpdatingUrlParams = true,
}); }: StoreInitializeType) => {
const selectedDateInStore = get().selectedDate;
if (durationConfig?.includes(Number(getQueryParam("duration")))) { if (
get().username === username &&
get().eventSlug === eventSlug &&
get().month === month &&
get().eventId === eventId &&
get().rescheduleUid === rescheduleUid &&
get().bookingUid === bookingUid &&
get().bookingData?.responses.email === bookingData?.responses.email &&
get().layout === layout &&
get().timezone === timezone &&
get().rescheduledBy === rescheduledBy &&
get().teamMemberEmail === teamMemberEmail &&
get().crmOwnerRecordType === crmOwnerRecordType &&
get().crmAppSlug === crmAppSlug &&
get().crmRecordId === crmRecordId
)
return;
set({ set({
selectedDuration: Number(getQueryParam("duration")), username,
eventSlug,
eventId,
org,
rescheduleUid,
rescheduledBy,
bookingUid,
bookingData,
layout: layout || BookerLayouts.MONTH_VIEW,
isTeamEvent: isTeamEvent || false,
durationConfig,
timezone,
// Preselect today's date in week / column view, since they use this to show the week title.
selectedDate:
selectedDateInStore ||
(["week_view", "column_view"].includes(layout) ? dayjs().format("YYYY-MM-DD") : null),
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
crmRecordId,
isPlatform,
allowUpdatingUrlParams,
}); });
} else {
removeQueryParam("duration");
}
// Unset selected timeslot if user is rescheduling. This could happen if (durationConfig?.includes(Number(getQueryParam("duration")))) {
// if the user reschedules a booking right after the confirmation page. set({
// In that case the time would still be store in the store, this way we selectedDuration: Number(getQueryParam("duration")),
// force clear this. });
// Also, fetch the original booking duration if user is rescheduling and } else {
// update the selectedDuration removeQueryParam("duration");
if (rescheduleUid && bookingData) {
set({ selectedTimeslot: null });
const originalBookingLength = dayjs(bookingData?.endTime).diff(
dayjs(bookingData?.startTime),
"minutes"
);
set({ selectedDuration: originalBookingLength });
if (!isPlatform || allowUpdatingUrlParams) {
updateQueryParam("duration", originalBookingLength ?? "");
} }
}
if (month) set({ month });
if (isInstantMeeting) { // Unset selected timeslot if user is rescheduling. This could happen
const month = dayjs().format("YYYY-MM"); // if the user reschedules a booking right after the confirmation page.
const selectedDate = dayjs().format("YYYY-MM-DD"); // In that case the time would still be store in the store, this way we
const selectedTimeslot = new Date().toISOString(); // force clear this.
set({ // Also, fetch the original booking duration if user is rescheduling and
month, // update the selectedDuration
selectedDate, if (rescheduleUid && bookingData) {
selectedTimeslot, set({ selectedTimeslot: null });
isInstantMeeting, const originalBookingLength = dayjs(bookingData?.endTime).diff(
}); dayjs(bookingData?.startTime),
"minutes"
);
set({ selectedDuration: originalBookingLength });
if (!isPlatform || allowUpdatingUrlParams) {
updateQueryParam("duration", originalBookingLength ?? "");
}
}
if (month) set({ month });
if (!isPlatform || allowUpdatingUrlParams) { if (isInstantMeeting) {
updateQueryParam("month", month); const month = dayjs().format("YYYY-MM");
updateQueryParam("date", selectedDate ?? ""); const selectedDate = dayjs().format("YYYY-MM-DD");
const selectedTimeslot = new Date().toISOString();
set({
month,
selectedDate,
selectedTimeslot,
isInstantMeeting,
});
if (!isPlatform || allowUpdatingUrlParams) {
updateQueryParam("month", month);
updateQueryParam("date", selectedDate ?? "");
updateQueryParam("slot", selectedTimeslot ?? "", false);
}
}
//removeQueryParam("layout");
},
durationConfig: null,
selectedDuration: null,
setSelectedDuration: (selectedDuration: number | null) => {
set({ selectedDuration });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("duration", selectedDuration ?? "");
}
},
setBookingData: (bookingData: GetBookingType | null | undefined) => {
set({ bookingData: bookingData ?? null });
},
recurringEventCount: null,
setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }),
occurenceCount: null,
setOccurenceCount: (occurenceCount: number | null) => set({ occurenceCount }),
rescheduleUid: null,
bookingData: null,
bookingUid: null,
selectedTimeslot: getQueryParam("slot") || null,
tentativeSelectedTimeslots: [],
setTentativeSelectedTimeslots: (tentativeSelectedTimeslots: string[]) => {
set({ tentativeSelectedTimeslots });
},
setSelectedTimeslot: (selectedTimeslot: string | null) => {
set({ selectedTimeslot });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("slot", selectedTimeslot ?? "", false); updateQueryParam("slot", selectedTimeslot ?? "", false);
} }
} },
//removeQueryParam("layout"); formValues: {},
}, setFormValues: (formValues: Record<string, any>) => {
durationConfig: null, set({ formValues });
selectedDuration: null, },
setSelectedDuration: (selectedDuration: number | null) => { org: null,
set({ selectedDuration }); setOrg: (org: string | null | undefined) => {
if (!get().isPlatform || get().allowUpdatingUrlParams) { set({ org });
updateQueryParam("duration", selectedDuration ?? ""); },
} isPlatform: false,
}, allowUpdatingUrlParams: true,
setBookingData: (bookingData: GetBookingType | null | undefined) => { }));
set({ bookingData: bookingData ?? null });
}, /**
recurringEventCount: null, * Default global store instance for backward compatibility
setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }), */
occurenceCount: null, export const useBookerStore = createBookerStore();
setOccurenceCount: (occurenceCount: number | null) => set({ occurenceCount }),
rescheduleUid: null,
bookingData: null,
bookingUid: null,
selectedTimeslot: getQueryParam("slot") || null,
tentativeSelectedTimeslots: [],
setTentativeSelectedTimeslots: (tentativeSelectedTimeslots: string[]) => {
set({ tentativeSelectedTimeslots });
},
setSelectedTimeslot: (selectedTimeslot: string | null) => {
set({ selectedTimeslot });
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("slot", selectedTimeslot ?? "", false);
}
},
formValues: {},
setFormValues: (formValues: Record<string, any>) => {
set({ formValues });
},
org: null,
setOrg: (org: string | null | undefined) => {
set({ org });
},
isPlatform: false,
allowUpdatingUrlParams: true,
}));
export const useInitializeBookerStore = ({ export const useInitializeBookerStore = ({
username, username,
@@ -1,11 +1,11 @@
import { shallow } from "zustand/shallow"; import { shallow } from "zustand/shallow";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useSchedule } from "@calcom/features/schedules/lib/use-schedule/useSchedule"; import { useSchedule } from "@calcom/features/schedules/lib/use-schedule/useSchedule";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { useBookerTime } from "../components/hooks/useBookerTime"; import { useBookerTime } from "../components/hooks/useBookerTime";
import { useBookerStore } from "../store";
export type useEventReturnType = ReturnType<typeof useEvent>; export type useEventReturnType = ReturnType<typeof useEvent>;
export type useScheduleForEventReturnType = ReturnType<typeof useScheduleForEvent>; export type useScheduleForEventReturnType = ReturnType<typeof useScheduleForEvent>;
@@ -19,7 +19,7 @@ export type useScheduleForEventReturnType = ReturnType<typeof useScheduleForEven
* of combining multiple conditional hooks. * of combining multiple conditional hooks.
*/ */
export const useEvent = (props?: { fromRedirectOfNonOrgLink?: boolean; disabled?: boolean }) => { export const useEvent = (props?: { fromRedirectOfNonOrgLink?: boolean; disabled?: boolean }) => {
const [username, eventSlug, isTeamEvent, org] = useBookerStore( const [username, eventSlug, isTeamEvent, org] = useBookerStoreContext(
(state) => [state.username, state.eventSlug, state.isTeamEvent, state.org], (state) => [state.username, state.eventSlug, state.isTeamEvent, state.org],
shallow shallow
); );
@@ -89,7 +89,7 @@ export const useScheduleForEvent = ({
useApiV2?: boolean; useApiV2?: boolean;
} = {}) => { } = {}) => {
const { timezone } = useBookerTime(); const { timezone } = useBookerTime();
const [usernameFromStore, eventSlugFromStore, monthFromStore, durationFromStore] = useBookerStore( const [usernameFromStore, eventSlugFromStore, monthFromStore, durationFromStore] = useBookerStoreContext(
(state) => [state.username, state.eventSlug, state.month, state.selectedDuration], (state) => [state.username, state.eventSlug, state.month, state.selectedDuration],
shallow shallow
); );
@@ -5,6 +5,7 @@ import { useMemo } from "react";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform"; import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { OutOfOfficeInSlots } from "@calcom/features/bookings/Booker/components/OutOfOfficeInSlots"; import { OutOfOfficeInSlots } from "@calcom/features/bookings/Booker/components/OutOfOfficeInSlots";
import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings"; import type { IUseBookingLoadingStates } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
import type { BookerEvent } from "@calcom/features/bookings/types"; import type { BookerEvent } from "@calcom/features/bookings/types";
@@ -19,7 +20,6 @@ import { Icon } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton"; import { SkeletonText } from "@calcom/ui/components/skeleton";
import { useBookerTime } from "../Booker/components/hooks/useBookerTime"; import { useBookerTime } from "../Booker/components/hooks/useBookerTime";
import { useBookerStore } from "../Booker/store";
import { getQueryParam } from "../Booker/utils/query-param"; import { getQueryParam } from "../Booker/utils/query-param";
import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay"; import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay";
import type { Slots } from "../types"; import type { Slots } from "../types";
@@ -112,8 +112,8 @@ const SlotItem = ({
getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault"); getQueryParam("overlayCalendar") === "true" || localStorage.getItem("overlayCalendarSwitchDefault");
const { timeFormat, timezone } = useBookerTime(); const { timeFormat, timezone } = useBookerTime();
const bookingData = useBookerStore((state) => state.bookingData); const bookingData = useBookerStoreContext((state) => state.bookingData);
const layout = useBookerStore((state) => state.layout); const layout = useBookerStoreContext((state) => state.layout);
const hasTimeSlots = !!seatsPerTimeSlot; const hasTimeSlots = !!seatsPerTimeSlot;
const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone); const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone);
@@ -127,7 +127,7 @@ const SlotItem = ({
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60; const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot); const selectedTimeslot = useBookerStoreContext((state) => state.selectedTimeslot);
const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay({ const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay({
start: computedDateWithUsersTimezone, start: computedDateWithUsersTimezone,
@@ -2,12 +2,12 @@ import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { nameOfDay } from "@calcom/lib/weekday"; import { nameOfDay } from "@calcom/lib/weekday";
import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { BookerLayouts } from "@calcom/prisma/zod-utils";
import classNames from "@calcom/ui/classNames"; import classNames from "@calcom/ui/classNames";
import { useBookerStore } from "../Booker/store";
import { TimeFormatToggle } from "./TimeFormatToggle"; import { TimeFormatToggle } from "./TimeFormatToggle";
type AvailableTimesHeaderProps = { type AvailableTimesHeaderProps = {
@@ -28,7 +28,7 @@ export const AvailableTimesHeader = ({
customClassNames, customClassNames,
}: AvailableTimesHeaderProps) => { }: AvailableTimesHeaderProps) => {
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const [layout] = useBookerStore((state) => [state.layout], shallow); const [layout] = useBookerStoreContext((state) => [state.layout], shallow);
const isColumnView = layout === BookerLayouts.COLUMN_VIEW; const isColumnView = layout === BookerLayouts.COLUMN_VIEW;
const isMonthView = layout === BookerLayouts.MONTH_VIEW; const isMonthView = layout === BookerLayouts.MONTH_VIEW;
const isToday = dayjs().isSame(date, "day"); const isToday = dayjs().isSame(date, "day");
@@ -1430,6 +1430,8 @@ async function handler(
eventType.schedulingType === SchedulingType.ROUND_ROBIN && eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
originalRescheduledBooking.userId !== evt.organizer.id; originalRescheduledBooking.userId !== evt.organizer.id;
const skipDeleteEventsAndMeetings = changedOrganizer;
const isBookingRequestedReschedule = const isBookingRequestedReschedule =
!!originalRescheduledBooking && !!originalRescheduledBooking &&
!!originalRescheduledBooking.rescheduled && !!originalRescheduledBooking.rescheduled &&
@@ -1702,7 +1704,8 @@ async function handler(
undefined, undefined,
changedOrganizer, changedOrganizer,
previousHostDestinationCalendar, 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 // 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. // to the default description when we are sending the emails.
+3 -3
View File
@@ -4,7 +4,7 @@ import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useEmbedStyles } from "@calcom/embed-core/embed-iframe"; import { useEmbedStyles } from "@calcom/embed-core/embed-iframe";
import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth"; import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth";
import { daysInMonth, yyyymmdd } from "@calcom/lib/dayjs"; import { daysInMonth, yyyymmdd } from "@calcom/lib/dayjs";
import type { IFromUser, IToUser } from "@calcom/lib/getUserAvailability"; import type { IFromUser, IToUser } from "@calcom/lib/getUserAvailability";
@@ -227,7 +227,7 @@ const Days = ({
} }
} }
const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow); const [selectedDatesAndTimes] = useBookerStoreContext((state) => [state.selectedDatesAndTimes], shallow);
const isActive = (day: dayjs.Dayjs) => { const isActive = (day: dayjs.Dayjs) => {
// for selecting a range of dates // for selecting a range of dates
@@ -400,7 +400,7 @@ const DatePicker = ({
minDate && rawBrowsingDate.valueOf() < minDate.valueOf() ? dayjs(minDate) : rawBrowsingDate; minDate && rawBrowsingDate.valueOf() < minDate.valueOf() ? dayjs(minDate) : rawBrowsingDate;
const { i18n, t } = useLocale(); const { i18n, t } = useLocale();
const bookingData = useBookerStore((state) => state.bookingData); const bookingData = useBookerStoreContext((state) => state.bookingData);
const isBookingInPast = bookingData ? new Date(bookingData.endTime) < new Date() : false; const isBookingInPast = bookingData ? new Date(bookingData.endTime) < new Date() : false;
const changeMonth = (newMonth: number) => { const changeMonth = (newMonth: number) => {
if (onMonthChange) { if (onMonthChange) {
@@ -4,6 +4,7 @@ import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { BookerStoreProvider } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { PeriodType } from "@calcom/prisma/enums"; import { PeriodType } from "@calcom/prisma/enums";
import { DatePicker } from "../DatePicker"; import { DatePicker } from "../DatePicker";
@@ -16,20 +17,22 @@ describe("Tests for DatePicker Component", () => {
test("Should render correctly with default date", async () => { test("Should render correctly with default date", async () => {
const testDate = dayjs("2024-02-20"); const testDate = dayjs("2024-02-20");
const { getByTestId } = render( const { getByTestId } = render(
<TooltipProvider> <BookerStoreProvider>
<DatePicker <TooltipProvider>
onChange={noop} <DatePicker
browsingDate={testDate} onChange={noop}
locale="en" browsingDate={testDate}
periodData={{ locale="en"
periodType: PeriodType.UNLIMITED, periodData={{
periodDays: null, periodType: PeriodType.UNLIMITED,
periodCountCalendarDays: false, periodDays: null,
periodStartDate: null, periodCountCalendarDays: false,
periodEndDate: null, periodStartDate: null,
}} periodEndDate: null,
/> }}
</TooltipProvider> />
</TooltipProvider>
</BookerStoreProvider>
); );
const selectedMonthLabel = getByTestId("selected-month-label"); const selectedMonthLabel = getByTestId("selected-month-label");
@@ -40,9 +43,11 @@ describe("Tests for DatePicker Component", () => {
const testDate = dayjs("2024-02-20"); const testDate = dayjs("2024-02-20");
const minDate = dayjs("2025-02-10"); const minDate = dayjs("2025-02-10");
const { getByTestId } = render( const { getByTestId } = render(
<TooltipProvider> <BookerStoreProvider>
<DatePicker onChange={noop} browsingDate={testDate} minDate={minDate.toDate()} locale="en" /> <TooltipProvider>
</TooltipProvider> <DatePicker onChange={noop} browsingDate={testDate} minDate={minDate.toDate()} locale="en" />
</TooltipProvider>
</BookerStoreProvider>
); );
const selectedMonthLabel = getByTestId("selected-month-label"); const selectedMonthLabel = getByTestId("selected-month-label");
@@ -53,9 +58,11 @@ describe("Tests for DatePicker Component", () => {
const testDate = dayjs("2025-03-20"); const testDate = dayjs("2025-03-20");
const minDate = dayjs("2025-02-10"); const minDate = dayjs("2025-02-10");
const { getByTestId } = render( const { getByTestId } = render(
<TooltipProvider> <BookerStoreProvider>
<DatePicker onChange={noop} browsingDate={testDate} minDate={minDate.toDate()} locale="en" /> <TooltipProvider>
</TooltipProvider> <DatePicker onChange={noop} browsingDate={testDate} minDate={minDate.toDate()} locale="en" />
</TooltipProvider>
</BookerStoreProvider>
); );
const selectedMonthLabel = getByTestId("selected-month-label"); const selectedMonthLabel = getByTestId("selected-month-label");
@@ -85,22 +92,24 @@ describe("Tests for DatePicker Component", () => {
]); ]);
const { getAllByTestId } = render( const { getAllByTestId } = render(
<TooltipProvider> <BookerStoreProvider>
<DatePicker <TooltipProvider>
onChange={noop} <DatePicker
browsingDate={earlyMonthDate} onChange={noop}
locale="en" browsingDate={earlyMonthDate}
slots={slots} locale="en"
isCompact={false} slots={slots}
periodData={{ isCompact={false}
periodType: PeriodType.UNLIMITED, periodData={{
periodDays: null, periodType: PeriodType.UNLIMITED,
periodCountCalendarDays: false, periodDays: null,
periodStartDate: null, periodCountCalendarDays: false,
periodEndDate: null, periodStartDate: null,
}} periodEndDate: null,
/> }}
</TooltipProvider> />
</TooltipProvider>
</BookerStoreProvider>
); );
const dayElements = getAllByTestId("day"); const dayElements = getAllByTestId("day");
@@ -127,22 +136,24 @@ describe("Tests for DatePicker Component", () => {
]); ]);
const { getAllByTestId, queryByText } = render( const { getAllByTestId, queryByText } = render(
<TooltipProvider> <BookerStoreProvider>
<DatePicker <TooltipProvider>
onChange={noop} <DatePicker
browsingDate={lateMonthDate} onChange={noop}
locale="en" browsingDate={lateMonthDate}
slots={slots} locale="en"
isCompact={false} slots={slots}
periodData={{ isCompact={false}
periodType: PeriodType.UNLIMITED, periodData={{
periodDays: null, periodType: PeriodType.UNLIMITED,
periodCountCalendarDays: false, periodDays: null,
periodStartDate: null, periodCountCalendarDays: false,
periodEndDate: null, periodStartDate: null,
}} periodEndDate: null,
/> }}
</TooltipProvider> />
</TooltipProvider>
</BookerStoreProvider>
); );
const dayElements = getAllByTestId("day"); const dayElements = getAllByTestId("day");
@@ -165,22 +176,24 @@ describe("Tests for DatePicker Component", () => {
const slots = createMockSlots(["2024-01-25", "2024-02-01"]); const slots = createMockSlots(["2024-01-25", "2024-02-01"]);
const { getAllByTestId } = render( const { getAllByTestId } = render(
<TooltipProvider> <BookerStoreProvider>
<DatePicker <TooltipProvider>
onChange={noop} <DatePicker
browsingDate={lateMonthDate} onChange={noop}
locale="en" browsingDate={lateMonthDate}
slots={slots} locale="en"
isCompact={true} // This should force traditional view slots={slots}
periodData={{ isCompact={true} // This should force traditional view
periodType: PeriodType.UNLIMITED, periodData={{
periodDays: null, periodType: PeriodType.UNLIMITED,
periodCountCalendarDays: false, periodDays: null,
periodStartDate: null, periodCountCalendarDays: false,
periodEndDate: null, periodStartDate: null,
}} periodEndDate: null,
/> }}
</TooltipProvider> />
</TooltipProvider>
</BookerStoreProvider>
); );
const dayElements = getAllByTestId("day"); const dayElements = getAllByTestId("day");
@@ -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,
});
});
});
+45 -30
View File
@@ -11,7 +11,12 @@ import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { AvailableTimes, AvailableTimesHeader } from "@calcom/features/bookings"; import { AvailableTimes, AvailableTimesHeader } from "@calcom/features/bookings";
import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store"; import {
BookerStoreProvider,
useInitializeBookerStoreContext,
useBookerStoreContext,
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event"; import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event";
import DatePicker from "@calcom/features/calendars/DatePicker"; import DatePicker from "@calcom/features/calendars/DatePicker";
import { Dialog } from "@calcom/features/components/controlled-dialog"; import { Dialog } from "@calcom/features/components/controlled-dialog";
@@ -260,13 +265,21 @@ const EmailEmbed = ({
org: orgSlug, org: orgSlug,
isTeamEvent, isTeamEvent,
}); });
useInitializeBookerStoreContext({
username,
eventSlug: eventType?.slug ?? "",
eventId: eventType?.id,
layout: BookerLayouts.MONTH_VIEW,
org: orgSlug,
isTeamEvent,
});
const [month, selectedDate, selectedDatesAndTimes] = useBookerStore( const [month, selectedDate, selectedDatesAndTimes] = useBookerStoreContext(
(state) => [state.month, state.selectedDate, state.selectedDatesAndTimes], (state) => [state.month, state.selectedDate, state.selectedDatesAndTimes],
shallow shallow
); );
const [setSelectedDate, setMonth, setSelectedDatesAndTimes, setSelectedTimeslot, setTimezone] = const [setSelectedDate, setMonth, setSelectedDatesAndTimes, setSelectedTimeslot, setTimezone] =
useBookerStore( useBookerStoreContext(
(state) => [ (state) => [
state.setSelectedDate, state.setSelectedDate,
state.setMonth, state.setMonth,
@@ -686,7 +699,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
const emailContentRef = useRef<HTMLDivElement>(null); const emailContentRef = useRef<HTMLDivElement>(null);
const { data } = useSession(); const { data } = useSession();
const [month, selectedDatesAndTimes] = useBookerStore( const [month, selectedDatesAndTimes] = useBookerStoreContext(
(state) => [state.month, state.selectedDatesAndTimes], (state) => [state.month, state.selectedDatesAndTimes],
shallow shallow
); );
@@ -1369,32 +1382,34 @@ export const EmbedDialog = ({
}; };
return ( return (
<Dialog <BookerStoreProvider>
{...(noQueryParamMode <Dialog
? { {...(noQueryParamMode
open: embedState !== null, ? {
onOpenChange: (open) => !open && handleDialogClose(), open: embedState !== null,
} onOpenChange: (open) => !open && handleDialogClose(),
: { }
// Must not set name when noQueryParam mode as required by Dialog component : {
name: "embed", // Must not set name when noQueryParam mode as required by Dialog component
clearQueryParamsOnClose: queryParamsForDialog, name: "embed",
})}> clearQueryParamsOnClose: queryParamsForDialog,
{!embedParams.embedType ? ( })}>
<ChooseEmbedTypesDialogContent types={types} noQueryParamMode={noQueryParamMode} /> {!embedParams.embedType ? (
) : ( <ChooseEmbedTypesDialogContent types={types} noQueryParamMode={noQueryParamMode} />
<EmbedTypeCodeAndPreviewDialogContent ) : (
embedType={embedParams.embedType as EmbedType} <EmbedTypeCodeAndPreviewDialogContent
embedUrl={embedParams.embedUrl} embedType={embedParams.embedType as EmbedType}
namespace={embedParams.namespace} embedUrl={embedParams.embedUrl}
tabs={tabs} namespace={embedParams.namespace}
types={types} tabs={tabs}
eventTypeHideOptionDisabled={eventTypeHideOptionDisabled} types={types}
defaultBrandColor={defaultBrandColor} eventTypeHideOptionDisabled={eventTypeHideOptionDisabled}
noQueryParamMode={noQueryParamMode} defaultBrandColor={defaultBrandColor}
/> noQueryParamMode={noQueryParamMode}
)} />
</Dialog> )}
</Dialog>
</BookerStoreProvider>
); );
}; };
@@ -1,3 +1,4 @@
import { BookerStoreProvider } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import classNames from "@calcom/ui/classNames"; import classNames from "@calcom/ui/classNames";
@@ -63,5 +64,9 @@ const TroubleshooterComponent = ({ month }: TroubleshooterProps) => {
}; };
export const Troubleshooter = ({ month }: TroubleshooterProps) => { export const Troubleshooter = ({ month }: TroubleshooterProps) => {
return <TroubleshooterComponent month={month} />; return (
<BookerStoreProvider>
<TroubleshooterComponent month={month} />
</BookerStoreProvider>
);
}; };
+11 -2
View File
@@ -570,7 +570,8 @@ export default class EventManager {
newBookingId?: number, newBookingId?: number,
changedOrganizer?: boolean, changedOrganizer?: boolean,
previousHostDestinationCalendar?: DestinationCalendar[] | null, previousHostDestinationCalendar?: DestinationCalendar[] | null,
isBookingRequestedReschedule?: boolean isBookingRequestedReschedule?: boolean,
skipDeleteEventsAndMeetings?: boolean
): Promise<CreateUpdateResult> { ): Promise<CreateUpdateResult> {
const originalEvt = processLocation(event); const originalEvt = processLocation(event);
const evt = cloneDeep(originalEvt); const evt = cloneDeep(originalEvt);
@@ -637,7 +638,7 @@ export default class EventManager {
const shouldUpdateBookingReferences = const shouldUpdateBookingReferences =
!!changedOrganizer || isLocationChanged || !!isBookingRequestedReschedule || isDailyVideoRoomExpired; !!changedOrganizer || isLocationChanged || !!isBookingRequestedReschedule || isDailyVideoRoomExpired;
if (evt.requiresConfirmation && !changedOrganizer) { if (evt.requiresConfirmation && !skipDeleteEventsAndMeetings) {
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking"); 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. // 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({ await this.deleteEventsAndMeetings({
@@ -646,6 +647,14 @@ export default class EventManager {
}); });
} else { } else {
if (changedOrganizer) { 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"); log.debug("RescheduleOrganizerChanged: Creating Event and Meeting for for new booking");
const createdEvent = await this.create(originalEvt); const createdEvent = await this.create(originalEvt);
results.push(...createdEvent.results); results.push(...createdEvent.results);
@@ -14,6 +14,8 @@ import { eventTypeRepositoryModule } from "../modules/EventType";
import { featuresRepositoryModule } from "../modules/Features"; import { featuresRepositoryModule } from "../modules/Features";
import { filterHostsModule } from "../modules/FilterHosts"; import { filterHostsModule } from "../modules/FilterHosts";
import { getUserAvailabilityModule } from "../modules/GetUserAvailability"; import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
import { membershipRepositoryModule } from "../modules/Membership";
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
import { oooRepositoryModule } from "../modules/Ooo"; import { oooRepositoryModule } from "../modules/Ooo";
import { qualifiedHostsModule } from "../modules/QualifiedHosts"; import { qualifiedHostsModule } from "../modules/QualifiedHosts";
import { routingFormResponseRepositoryModule } from "../modules/RoutingFormResponse"; 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.SCHEDULE_REPOSITORY_MODULE, scheduleRepositoryModule);
container.load(DI_TOKENS.SELECTED_SLOT_REPOSITORY_MODULE, selectedSlotsRepositoryModule); container.load(DI_TOKENS.SELECTED_SLOT_REPOSITORY_MODULE, selectedSlotsRepositoryModule);
container.load(DI_TOKENS.TEAM_REPOSITORY_MODULE, teamRepositoryModule); 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.USER_REPOSITORY_MODULE, userRepositoryModule);
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule); container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule); 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.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule); container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule); container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
container.load(DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE_MODULE, noSlotsNotificationModule);
export function getAvailableSlotsService() { export function getAvailableSlotsService() {
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE); return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
@@ -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, busyTimesService: DI_TOKENS.BUSY_TIMES_SERVICE,
featuresRepo: DI_TOKENS.FEATURES_REPOSITORY, featuresRepo: DI_TOKENS.FEATURES_REPOSITORY,
qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE, qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE,
noSlotsNotificationService: DI_TOKENS.NO_SLOTS_NOTIFICATION_SERVICE,
} satisfies Record<keyof IAvailableSlotsService, symbol>); } satisfies Record<keyof IAvailableSlotsService, symbol>);
+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"), QUALIFIED_HOSTS_SERVICE_MODULE: Symbol("QualifiedHostsServiceModule"),
FILTER_HOSTS_SERVICE: Symbol("FilterHostsService"), FILTER_HOSTS_SERVICE: Symbol("FilterHostsService"),
FILTER_HOSTS_SERVICE_MODULE: Symbol("FilterHostsServiceModule"), 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; | null;
bypassBusyCalendarTimes: boolean; bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean; shouldServeCache?: boolean;
}) { }) {
const { const {
@@ -70,6 +71,7 @@ export class BusyTimesService {
rescheduleUid, rescheduleUid,
duration, duration,
bypassBusyCalendarTimes = false, bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache, shouldServeCache,
} = params; } = params;
@@ -198,63 +200,71 @@ export class BusyTimesService {
); );
if (!calendarBusyTimesQuery.success) { if (!calendarBusyTimesQuery.success) {
throw new Error( if (silentlyHandleCalendarFailures) {
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map( logger.warn(
(calendar) => calendar.id `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. // TODO: Disabled until we can filter Zoom events by date. Also this is adding too much latency.
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty); const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
+1
View File
@@ -72,6 +72,7 @@ export async function getConnectedApps({
if (includeTeamInstalledApps || teamId) { if (includeTeamInstalledApps || teamId) {
const teamsQuery = await prisma.team.findMany({ const teamsQuery = await prisma.team.findMany({
where: { where: {
...(teamId ? { id: teamId } : {}),
members: { members: {
some: { some: {
userId: user.id, userId: user.id,
+4
View File
@@ -54,6 +54,7 @@ const availabilitySchema = z
withSource: z.boolean().optional(), withSource: z.boolean().optional(),
returnDateOverrides: z.boolean(), returnDateOverrides: z.boolean(),
bypassBusyCalendarTimes: z.boolean().optional(), bypassBusyCalendarTimes: z.boolean().optional(),
silentlyHandleCalendarFailures: z.boolean().optional(),
shouldServeCache: z.boolean().optional(), shouldServeCache: z.boolean().optional(),
}) })
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in."); .refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
@@ -110,6 +111,7 @@ type GetUserAvailabilityQuery = {
duration?: number; duration?: number;
returnDateOverrides: boolean; returnDateOverrides: boolean;
bypassBusyCalendarTimes: boolean; bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean; shouldServeCache?: boolean;
}; };
@@ -295,6 +297,7 @@ export class UserAvailabilityService {
duration, duration,
returnDateOverrides, returnDateOverrides,
bypassBusyCalendarTimes = false, bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache, shouldServeCache,
} = availabilitySchema.parse(query); } = availabilitySchema.parse(query);
@@ -457,6 +460,7 @@ export class UserAvailabilityService {
duration, duration,
currentBookings: initialData?.currentBookings, currentBookings: initialData?.currentBookings,
bypassBusyCalendarTimes, bypassBusyCalendarTimes,
silentlyHandleCalendarFailures,
shouldServeCache, shouldServeCache,
}); });
} catch (error) { } catch (error) {
@@ -229,4 +229,61 @@ export class CredentialRepository {
static async updateWhereId({ id, data }: { id: number; data: { key: Prisma.InputJsonValue } }) { static async updateWhereId({ id, data }: { id: number; data: { key: Prisma.InputJsonValue } }) {
return prisma.credential.update({ where: { id }, data }); return prisma.credential.update({ where: { id }, data });
} }
static async findPaymentCredentialByAppIdAndTeamId({
appId,
teamId,
}: {
appId: string | null;
teamId: number;
}) {
return await prisma.credential.findFirst({
where: {
teamId,
appId,
},
include: {
app: true,
},
});
}
static async findPaymentCredentialByAppIdAndUserId({
appId,
userId,
}: {
appId: string | null;
userId: number;
}) {
return await prisma.credential.findFirst({
where: {
userId,
appId,
},
include: {
app: true,
},
});
}
static async findPaymentCredentialByAppIdAndUserIdOrTeamId({
appId,
userId,
teamId,
}: {
appId: string | null;
userId: number;
teamId?: number | null;
}) {
const idToSearchObject = teamId ? { teamId } : { userId };
return await prisma.credential.findFirst({
where: {
...idToSearchObject,
appId,
},
include: {
app: true,
},
});
}
} }
@@ -460,4 +460,28 @@ export class MembershipRepository {
select: select ?? { userId: true }, select: select ?? { userId: true },
})) as unknown as Promise<MembershipDTOFromSelect<TSelect>[]>; })) 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,
},
},
},
});
}
} }
+44
View File
@@ -333,4 +333,48 @@ export class TeamRepository {
}, },
}); });
} }
async findParentOrganizationByTeamId(teamId: number) {
const team = await this.prismaClient.team.findUnique({
where: {
id: teamId,
},
select: {
parent: {
select: {
id: true,
},
},
},
});
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,
},
});
}
} }
+12
View File
@@ -1,5 +1,17 @@
## 1.1.2 ## 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
- [#22925](https://github.com/calcom/cal.com/pull/22925) [`4c01f17`](https://github.com/calcom/cal.com/commit/4c01f171aebcc5a64453c27e950303902cc15309) Thanks [@sean-brydon](https://github.com/sean-brydon)! - Fixes an issues for the Booker atom wherein when multiple widgets were being placed on the same page, changes made in one widget would also get reflected in the others.
## 1.3.0 ## 1.3.0
### Minor Changes ### Minor Changes
@@ -13,6 +13,7 @@ import React, {
import { Controller, useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"; import { Controller, useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { BookerStoreProvider } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { Dialog } from "@calcom/features/components/controlled-dialog"; import { Dialog } from "@calcom/features/components/controlled-dialog";
import { TimezoneSelect as WebTimezoneSelect } from "@calcom/features/components/timezone-select"; import { TimezoneSelect as WebTimezoneSelect } from "@calcom/features/components/timezone-select";
import type { import type {
@@ -672,19 +673,21 @@ export const AvailabilitySettings = forwardRef<AvailabilitySettingsFormRef, Avai
</div> </div>
</div> </div>
{enableOverrides && ( {enableOverrides && (
<DateOverride <BookerStoreProvider>
workingHours={schedule.workingHours} <DateOverride
userTimeFormat={timeFormat} workingHours={schedule.workingHours}
handleSubmit={handleSubmit} userTimeFormat={timeFormat}
travelSchedules={travelSchedules} handleSubmit={handleSubmit}
weekStart={ travelSchedules={travelSchedules}
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf( weekStart={
weekStart ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
) as 0 | 1 | 2 | 3 | 4 | 5 | 6 weekStart
} ) as 0 | 1 | 2 | 3 | 4 | 5 | 6
overridesModalClassNames={customClassNames?.overridesModalClassNames} }
classNames={customClassNames?.dateOverrideClassNames} overridesModalClassNames={customClassNames?.overridesModalClassNames}
/> classNames={customClassNames?.dateOverrideClassNames}
/>
</BookerStoreProvider>
)} )}
</div> </div>
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1"> <div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
@@ -6,6 +6,11 @@ import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { Booker as BookerComponent } from "@calcom/features/bookings/Booker"; import { Booker as BookerComponent } from "@calcom/features/bookings/Booker";
import {
BookerStoreProvider,
useInitializeBookerStoreContext,
useBookerStoreContext,
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout"; import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/useLocalSet"; import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/useLocalSet";
@@ -40,7 +45,7 @@ import type {
BookerStoreValues, BookerStoreValues,
} from "./types"; } from "./types";
export const BookerPlatformWrapper = ( const BookerPlatformWrapperComponent = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
) => { ) => {
const { const {
@@ -59,20 +64,24 @@ export const BookerPlatformWrapper = (
onTimeslotsLoaded, onTimeslotsLoaded,
startTime: customStartTime, startTime: customStartTime,
showNoAvailabilityDialog, showNoAvailabilityDialog,
silentlyHandleCalendarFailures = false,
} = props; } = props;
const layout = BookerLayouts[view]; const layout = BookerLayouts[view];
const { clientId } = useAtomsContext(); const { clientId } = useAtomsContext();
const teamId: number | undefined = props.isTeamEvent ? props.teamId : undefined; const teamId: number | undefined = props.isTeamEvent ? props.teamId : undefined;
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow); const [bookerState, setBookerState] = useBookerStoreContext(
const setSelectedDate = useBookerStore((state) => state.setSelectedDate); (state) => [state.state, state.setState],
const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration); shallow
const setBookingData = useBookerStore((state) => state.setBookingData); );
const setOrg = useBookerStore((state) => state.setOrg); const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
const bookingData = useBookerStore((state) => state.bookingData); const setSelectedDuration = useBookerStoreContext((state) => state.setSelectedDuration);
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); const setBookingData = useBookerStoreContext((state) => state.setBookingData);
const setSelectedMonth = useBookerStore((state) => state.setMonth); const setOrg = useBookerStoreContext((state) => state.setOrg);
const selectedDuration = useBookerStore((state) => state.selectedDuration); const bookingData = useBookerStoreContext((state) => state.bookingData);
const setSelectedTimeslot = useBookerStoreContext((state) => state.setSelectedTimeslot);
const setSelectedMonth = useBookerStoreContext((state) => state.setMonth);
const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
const [isOverlayCalendarEnabled, setIsOverlayCalendarEnabled] = useState( const [isOverlayCalendarEnabled, setIsOverlayCalendarEnabled] = useState(
Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault")) Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault"))
@@ -171,11 +180,27 @@ export const BookerPlatformWrapper = (
isPlatform: true, isPlatform: true,
allowUpdatingUrlParams, allowUpdatingUrlParams,
}); });
const [dayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow); useInitializeBookerStoreContext({
const selectedDate = useBookerStore((state) => state.selectedDate); ...props,
teamMemberEmail,
crmAppSlug,
crmOwnerRecordType,
crmRecordId: props.crmRecordId,
eventId: event?.data?.id,
rescheduleUid: props.rescheduleUid ?? null,
bookingUid: props.bookingUid ?? null,
layout: layout,
org: props.entity?.orgSlug,
username,
bookingData,
isPlatform: true,
allowUpdatingUrlParams,
});
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const month = useBookerStore((state) => state.month); const month = useBookerStoreContext((state) => state.month);
const eventSlug = useBookerStore((state) => state.eventSlug); const eventSlug = useBookerStoreContext((state) => state.eventSlug);
const { data: session } = useMe(); const { data: session } = useMe();
const hasSession = !!session; const hasSession = !!session;
@@ -286,6 +311,7 @@ export const BookerPlatformWrapper = (
Boolean(event?.data?.id), Boolean(event?.data?.id),
orgSlug: props.entity?.orgSlug ?? undefined, orgSlug: props.entity?.orgSlug ?? undefined,
eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "", eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "",
_silentCalendarFailures: silentlyHandleCalendarFailures,
...routingParams, ...routingParams,
}); });
@@ -557,6 +583,16 @@ export const BookerPlatformWrapper = (
); );
}; };
export const BookerPlatformWrapper = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
) => {
return (
<BookerStoreProvider>
<BookerPlatformWrapperComponent {...props} />
</BookerStoreProvider>
);
};
function formatUsername(username: string | string[]): string { function formatUsername(username: string | string[]): string {
if (typeof username === "string") { if (typeof username === "string") {
return username; return username;
@@ -4,6 +4,7 @@ import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useMemo, useCallback, useEffect } from "react"; import { useMemo, useCallback, useEffect } from "react";
import React from "react";
import { shallow } from "zustand/shallow"; import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
@@ -11,6 +12,11 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import type { BookerProps } from "@calcom/features/bookings/Booker"; import type { BookerProps } from "@calcom/features/bookings/Booker";
import { Booker as BookerComponent } from "@calcom/features/bookings/Booker"; import { Booker as BookerComponent } from "@calcom/features/bookings/Booker";
import {
BookerStoreProvider,
useInitializeBookerStoreContext,
useBookerStoreContext,
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout"; import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useBookings } from "@calcom/features/bookings/Booker/components/hooks/useBookings"; import { useBookings } from "@calcom/features/bookings/Booker/components/hooks/useBookings";
@@ -18,19 +24,20 @@ import { useCalendars } from "@calcom/features/bookings/Booker/components/hooks/
import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useSlots"; import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useSlots";
import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode"; import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode";
import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail"; import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail";
import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store"; import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event"; import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event";
import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors"; import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent"; import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants"; import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR, WEBAPP_URL } from "@calcom/lib/constants";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { localStorage } from "@calcom/lib/webstorage";
import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { BookerLayouts } from "@calcom/prisma/zod-utils";
export type BookerWebWrapperAtomProps = BookerProps & { export type BookerWebWrapperAtomProps = BookerProps & {
eventData?: NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>; eventData?: NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>;
}; };
export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => { const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -74,10 +81,20 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
org: props.entity.orgSlug, org: props.entity.orgSlug,
timezone, timezone,
}); });
useInitializeBookerStoreContext({
...props,
eventId: props.entity.eventTypeId ?? event?.data?.id,
rescheduleUid,
rescheduledBy,
bookingUid: bookingUid,
layout: bookerLayout.isMobile ? "mobile" : bookerLayout.defaultLayout,
org: props.entity.orgSlug,
timezone,
});
const [bookerState, _] = useBookerStore((state) => [state.state, state.setState], shallow); const [bookerState, _] = useBookerStoreContext((state) => [state.state, state.setState], shallow);
const [dayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow); const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
const [month] = useBookerStore((state) => [state.month, state.setMonth], shallow); const [month] = useBookerStoreContext((state) => [state.month, state.setMonth], shallow);
const { data: session } = useSession(); const { data: session } = useSession();
const routerQuery = useRouterQuery(); const routerQuery = useRouterQuery();
@@ -263,3 +280,11 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
/> />
); );
}; };
export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
return (
<BookerStoreProvider>
<BookerPlatformWrapperComponent {...props} />
</BookerStoreProvider>
);
};
+1
View File
@@ -85,6 +85,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
onTimeslotsLoaded?: (slots: Record<string, Slot[]>) => void; onTimeslotsLoaded?: (slots: Record<string, Slot[]>) => void;
startTime?: string | Date; startTime?: string | Date;
roundRobinHideOrgAndTeam?: boolean; roundRobinHideOrgAndTeam?: boolean;
silentlyHandleCalendarFailures?: boolean;
}; };
type VIEW_TYPE = keyof typeof BookerLayouts; type VIEW_TYPE = keyof typeof BookerLayouts;
@@ -1,9 +1,9 @@
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform"; import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useBookerTime } from "@calcom/features/bookings/Booker/components/hooks/useBookerTime"; import { useBookerTime } from "@calcom/features/bookings/Booker/components/hooks/useBookerTime";
import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { mapBookingToMutationInput, mapRecurringBookingToMutationInput } from "@calcom/features/bookings/lib"; import { mapBookingToMutationInput, mapRecurringBookingToMutationInput } from "@calcom/features/bookings/lib";
import type { BookerEvent } from "@calcom/features/bookings/types"; import type { BookerEvent } from "@calcom/features/bookings/types";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -46,23 +46,23 @@ export const useHandleBookEvent = ({
isBookingDryRun, isBookingDryRun,
}: UseHandleBookingProps) => { }: UseHandleBookingProps) => {
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const setFormValues = useBookerStore((state) => state.setFormValues); const setFormValues = useBookerStoreContext((state) => state.setFormValues);
const storeTimeSlot = useBookerStore((state) => state.selectedTimeslot); const storeTimeSlot = useBookerStoreContext((state) => state.selectedTimeslot);
const duration = useBookerStore((state) => state.selectedDuration); const duration = useBookerStoreContext((state) => state.selectedDuration);
const { timezone } = useBookerTime(); const { timezone } = useBookerTime();
const rescheduleUid = useBookerStore((state) => state.rescheduleUid); const rescheduleUid = useBookerStoreContext((state) => state.rescheduleUid);
const rescheduledBy = useBookerStore((state) => state.rescheduledBy); const rescheduledBy = useBookerStoreContext((state) => state.rescheduledBy);
const { t, i18n } = useLocale(); const { t, i18n } = useLocale();
const username = useBookerStore((state) => state.username); const username = useBookerStoreContext((state) => state.username);
const recurringEventCount = useBookerStore((state) => state.recurringEventCount); const recurringEventCount = useBookerStoreContext((state) => state.recurringEventCount);
const bookingData = useBookerStore((state) => state.bookingData); const bookingData = useBookerStoreContext((state) => state.bookingData);
const seatedEventData = useBookerStore((state) => state.seatedEventData); const seatedEventData = useBookerStoreContext((state) => state.seatedEventData);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); const isInstantMeeting = useBookerStoreContext((state) => state.isInstantMeeting);
const orgSlug = useBookerStore((state) => state.org); const orgSlug = useBookerStoreContext((state) => state.org);
const teamMemberEmail = useBookerStore((state) => state.teamMemberEmail); const teamMemberEmail = useBookerStoreContext((state) => state.teamMemberEmail);
const crmOwnerRecordType = useBookerStore((state) => state.crmOwnerRecordType); const crmOwnerRecordType = useBookerStoreContext((state) => state.crmOwnerRecordType);
const crmAppSlug = useBookerStore((state) => state.crmAppSlug); const crmAppSlug = useBookerStoreContext((state) => state.crmAppSlug);
const crmRecordId = useBookerStore((state) => state.crmRecordId); const crmRecordId = useBookerStoreContext((state) => state.crmRecordId);
const handleError = (err: any) => { const handleError = (err: any) => {
const errorMessage = err?.message ? t(err.message) : t("can_you_try_again"); const errorMessage = err?.message ? t(err.message) : t("can_you_try_again");
showToast(errorMessage, "error"); showToast(errorMessage, "error");
+5 -5
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { shallow } from "zustand/shallow"; import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId"; import { useSlotReservationId } from "@calcom/features/bookings/Booker/useSlotReservationId";
import type { BookerEvent } from "@calcom/features/bookings/types"; import type { BookerEvent } from "@calcom/features/bookings/types";
import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
@@ -36,8 +36,8 @@ export const useSlots = (
handleSlotReservation, handleSlotReservation,
}: UseSlotsCallbacks & { isBookingDryRun?: boolean } = {} }: UseSlotsCallbacks & { isBookingDryRun?: boolean } = {}
) => { ) => {
const selectedDuration = useBookerStore((state) => state.selectedDuration); const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
const [selectedTimeslot, setSelectedTimeslot] = useBookerStore( const [selectedTimeslot, setSelectedTimeslot] = useBookerStoreContext(
(state) => [state.selectedTimeslot, state.setSelectedTimeslot], (state) => [state.selectedTimeslot, state.setSelectedTimeslot],
shallow shallow
); );
@@ -52,7 +52,7 @@ export const useSlots = (
onError: onReserveSlotError, onError: onReserveSlotError,
}); });
const seatedEventData = useBookerStore((state) => state.seatedEventData); const seatedEventData = useBookerStoreContext((state) => state.seatedEventData);
const removeSelectedSlot = useDeleteSelectedSlot({ const removeSelectedSlot = useDeleteSelectedSlot({
onSuccess: onDeleteSlotSuccess, onSuccess: onDeleteSlotSuccess,
@@ -85,7 +85,7 @@ export const useSlots = (
} }
}; };
const timeslot = useBookerStore((state) => state.selectedTimeslot); const timeslot = useBookerStoreContext((state) => state.selectedTimeslot);
useEffect(() => { useEffect(() => {
handleReserveSlot(); handleReserveSlot();
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"description": "Customizable UI components to integrate scheduling into your product.", "description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.", "authors": "Cal.com, Inc.",
"version": "1.3.0", "version": "1.4.0",
"scripts": { "scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch", "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", "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 })} : { username: props.calUsername })}
hostsLimit={3} hostsLimit={3}
allowUpdatingUrlParams={true} allowUpdatingUrlParams={true}
silentlyHandleCalendarFailures={false}
/> />
</> </>
)} )}
@@ -7,3 +7,4 @@ export { PrismaSelectedSlotRepository } from "@calcom/lib/server/repository/Pris
export { TeamRepository as PrismaTeamRepository } from "@calcom/lib/server/repository/team"; export { TeamRepository as PrismaTeamRepository } from "@calcom/lib/server/repository/team";
export { UserRepository as PrismaUserRepository } from "@calcom/lib/server/repository/user"; export { UserRepository as PrismaUserRepository } from "@calcom/lib/server/repository/user";
export { FeaturesRepository as PrismaFeaturesRepository } from "@calcom/features/flags/features.repository"; 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 { FilterHostsService } from "@calcom/lib/bookings/filterHostsBySameRoundRobinHost";
import { QualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials"; import { QualifiedHostsService } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { BusyTimesService } from "@calcom/lib/getBusyTimes"; 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"; import { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
export { AvailableSlotsService }; export { AvailableSlotsService };
@@ -10,3 +11,4 @@ export { BusyTimesService };
export { QualifiedHostsService }; export { QualifiedHostsService };
export { FilterHostsService }; export { FilterHostsService };
export { NoSlotsNotificationService };
@@ -18,7 +18,8 @@ export class CreatePrivateLinkInput {
@IsInt() @IsInt()
@Min(1) @Min(1)
@ApiPropertyOptional({ @ApiPropertyOptional({
description: "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).", description:
"Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
type: Number, type: Number,
example: 10, example: 10,
minimum: 1, minimum: 1,
@@ -58,4 +59,25 @@ export class UpdatePrivateLinkInput {
maxUsageCount?: number; maxUsageCount?: number;
} }
export class UpdatePrivateLinkBody {
@IsOptional()
@IsDate()
@Type(() => Date)
@ApiPropertyOptional({
description: "New expiration date for time-based links",
type: Date,
example: "2024-12-31T23:59:59.000Z",
})
expiresAt?: Date;
@IsOptional()
@IsInt()
@Min(1)
@ApiPropertyOptional({
description: "New maximum number of times the link can be used",
type: Number,
example: 10,
minimum: 1,
})
maxUsageCount?: number;
}
@@ -194,6 +194,12 @@ export class GetAvailableSlotsInput_2024_04_15 {
@ApiHideProperty() @ApiHideProperty()
_bypassCalendarBusyTimes?: boolean; _bypassCalendarBusyTimes?: boolean;
@Transform(({ value }) => value && value.toLowerCase() === "true")
@IsBoolean()
@IsOptional()
@ApiHideProperty()
_silentCalendarFailures?: boolean;
@Transform(({ value }) => (value ? value.toLowerCase() === "true" : false)) @Transform(({ value }) => (value ? value.toLowerCase() === "true" : false))
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
Binary file not shown.
@@ -99,6 +99,18 @@ export async function getBookings({
role: { role: {
in: ["ADMIN", "OWNER"], in: ["ADMIN", "OWNER"],
}, },
...(user.orgId && {
OR: [
{
teamId: user.orgId,
},
{
team: {
parentId: user.orgId,
},
},
],
}),
}, },
select: { select: {
id: true, id: true,
@@ -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, eventType: newEventType,
}; };
} catch (error) { } 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}` }); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Error duplicating event type ${error}` });
} }
}; };
@@ -4,6 +4,9 @@ import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors"; import { ErrorWithCode } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import type { PrismaClient } from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -20,6 +23,7 @@ interface ChargeCardHandlerOptions {
} }
export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions) => { export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions) => {
const { prisma } = ctx; const { prisma } = ctx;
const teamRepository = new TeamRepository(prisma);
const booking = await prisma.booking.findUnique({ const booking = await prisma.booking.findUnique({
where: { where: {
@@ -84,18 +88,14 @@ export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions
}, },
}; };
const idToSearchObject = booking.eventType?.teamId const userId = ctx.user.id;
? { teamId: booking.eventType.teamId } const teamId = booking.eventType?.teamId;
: { userId: ctx.user.id }; const appId = booking.payment[0].appId;
if (booking.eventType?.teamId) { if (teamId) {
const userIsInTeam = await prisma.membership.findUnique({ const userIsInTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({
where: { userId,
userId_teamId: { teamId,
userId: ctx.user.id,
teamId: booking.eventType?.teamId,
},
},
}); });
if (!userIsInTeam) { if (!userIsInTeam) {
@@ -103,16 +103,24 @@ export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions
} }
} }
const paymentCredential = await prisma.credential.findFirst({ let paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId({
where: { appId,
...idToSearchObject, userId,
appId: booking.payment[0].appId, teamId,
},
include: {
app: true,
},
}); });
if (!paymentCredential && teamId) {
// See if the team event belongs to an org
const org = await teamRepository.findParentOrganizationByTeamId(teamId);
if (org) {
paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndTeamId({
appId,
teamId: org.id,
});
}
}
if (!paymentCredential?.app) { if (!paymentCredential?.app) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid payment credential" }); throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid payment credential" });
} }
@@ -6,8 +6,7 @@ import { vi, describe, it, beforeAll, afterAll, expect, beforeEach, afterEach }
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import * as CalcomEmails from "@calcom/emails"; import * as CalcomEmails from "@calcom/emails";
import { RedisService } from "@calcom/features/redis/RedisService"; import { RedisService } from "@calcom/features/redis/RedisService";
import { getNoSlotsNotificationService } from "@calcom/lib/di/containers/NoSlotsNotification";
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
vi.mock("@calcom/features/redis/RedisService", () => { vi.mock("@calcom/features/redis/RedisService", () => {
const mockedRedis = vi.fn(); const mockedRedis = vi.fn();
@@ -84,14 +83,16 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
}; };
// Call the function with teamId // 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(); expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled();
// Mock length to be one then recall to trigger email // Mock length to be one then recall to trigger email
mocked.lrange.mockResolvedValueOnce([""]); mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 }); const service2 = getNoSlotsNotificationService();
await service2.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).toHaveBeenCalled(); expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).toHaveBeenCalled();
}); });
it("Should not send a notification if the org has them disabled", async () => { 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, isValidOrgDomain: true,
}; };
await handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 }); const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({ eventDetails, orgDetails, teamId: 123 });
expect(CalcomEmails.sendOrganizationAdminNoSlotsNotification).not.toHaveBeenCalled(); 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 mocked.lrange.mockResolvedValueOnce([""]); // This will trigger email sending
// Call with specific teamId // Call with specific teamId
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, // specific teamId teamId: 123, // specific teamId
@@ -205,7 +208,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]); mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
// teamId intentionally omitted // teamId intentionally omitted
@@ -233,7 +237,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]); mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -263,7 +268,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
isValidOrgDomain: true, isValidOrgDomain: true,
}; };
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -318,7 +324,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
mocked.lrange.mockResolvedValueOnce([""]); mocked.lrange.mockResolvedValueOnce([""]);
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, 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 // First notification cycle - simulate having one previous occurrence
mocked.lrange.mockResolvedValueOnce([""]); // One previous occurrence mocked.lrange.mockResolvedValueOnce([""]); // One previous occurrence
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, 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 // This better simulates the real Redis state after the first notification
mocked.lrange.mockResolvedValueOnce(["", ""]); // Two occurrences now mocked.lrange.mockResolvedValueOnce(["", ""]); // Two occurrences now
await handleNotificationWhenNoSlots({ const service2 = getNoSlotsNotificationService();
await service2.handleNotificationWhenNoSlots({
eventDetails, eventDetails,
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -436,7 +445,8 @@ describe("(Orgs) Send admin notifications when a user has no availability", () =
// First event type notification // First event type notification
mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for first event mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for first event
await handleNotificationWhenNoSlots({ const service = getNoSlotsNotificationService();
await service.handleNotificationWhenNoSlots({
eventDetails: { ...baseEventDetails, eventSlug: "event1" }, eventDetails: { ...baseEventDetails, eventSlug: "event1" },
orgDetails, orgDetails,
teamId: 123, 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 // 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 // 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 mocked.lrange.mockResolvedValueOnce([""]); // Simulate one previous occurrence for second event
await handleNotificationWhenNoSlots({ const service2 = getNoSlotsNotificationService();
await service2.handleNotificationWhenNoSlots({
eventDetails: { ...baseEventDetails, eventSlug: "event2" }, eventDetails: { ...baseEventDetails, eventSlug: "event2" },
orgDetails, orgDetails,
teamId: 123, teamId: 123,
@@ -1,9 +1,10 @@
import type { Dayjs } from "@calcom/dayjs"; import type { Dayjs } from "@calcom/dayjs";
import { sendOrganizationAdminNoSlotsNotification } from "@calcom/emails"; 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 { IS_PRODUCTION, WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n"; 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 = { type EventDetails = {
username: string; username: string;
@@ -38,107 +39,83 @@ const constructDataHash = (eventDetails: EventDetails) => {
return JSON.stringify(obj); return JSON.stringify(obj);
}; };
export const handleNotificationWhenNoSlots = async ({ export interface INoSlotsNotificationService {
eventDetails, teamRepo: TeamRepository;
orgDetails, membershipRepo: MembershipRepository;
teamId, redisClient: IRedisService;
}: { }
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 export class NoSlotsNotificationService {
const orgSettings = await prisma.team.findFirst({ constructor(public readonly dependencies: INoSlotsNotificationService) {}
where: {
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, 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({ if (!orgSettings?.organizationSettings?.adminGetsNoSlotsNotification) return;
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");
const payload = { const usersUniqueKey = constructRedisKey(eventDetails, orgDetails.currentOrgDomain, teamId);
language: translation, // Get only the required amount of data so the request is as small as possible
to: { // We may need to get more data and check the startDate occurrence of this
email: admin.user.email, // Not trigger email if the start months are the same
}, const usersExistingNoSlots =
user: eventDetails.username, (await this.dependencies.redisClient.lrange(usersUniqueKey, 0, NO_SLOTS_COUNT_FOR_NOTIFICATION - 1)) ??
slug: eventDetails.eventSlug, [];
startTime: eventDetails.startTime.format("YYYY-MM-DD"), await this.dependencies.redisClient.lpush(usersUniqueKey, constructDataHash(eventDetails));
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)); 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(), skipContactOwner: z.boolean().nullish(),
_enableTroubleshooter: z.boolean().optional(), _enableTroubleshooter: z.boolean().optional(),
_bypassCalendarBusyTimes: z.boolean().optional(), _bypassCalendarBusyTimes: z.boolean().optional(),
_silentCalendarFailures: z.boolean().optional(),
_shouldServeCache: z.boolean().optional(), _shouldServeCache: z.boolean().optional(),
routingFormResponseId: z.number().optional(), routingFormResponseId: z.number().optional(),
queuedFormResponseId: z.string().nullish(), queuedFormResponseId: z.string().nullish(),
@@ -61,7 +61,7 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { TGetScheduleInputSchema } from "./getSchedule.schema"; import type { TGetScheduleInputSchema } from "./getSchedule.schema";
import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots"; import type { NoSlotsNotificationService } from "./handleNotificationWhenNoSlots";
import type { GetScheduleOptions } from "./types"; import type { GetScheduleOptions } from "./types";
const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const log = logger.getSubLogger({ prefix: ["[slots/util]"] });
@@ -108,6 +108,7 @@ export interface IAvailableSlotsService {
redisClient: IRedisService; redisClient: IRedisService;
featuresRepo: FeaturesRepository; featuresRepo: FeaturesRepository;
qualifiedHostsService: QualifiedHostsService; qualifiedHostsService: QualifiedHostsService;
noSlotsNotificationService: NoSlotsNotificationService;
} }
function withSlotsCache( function withSlotsCache(
@@ -737,6 +738,7 @@ export class AvailableSlotsService {
startTime, startTime,
endTime, endTime,
bypassBusyCalendarTimes, bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache, shouldServeCache,
}: { }: {
input: TGetScheduleInputSchema; input: TGetScheduleInputSchema;
@@ -753,6 +755,7 @@ export class AvailableSlotsService {
startTime: ReturnType<(typeof AvailableSlotsService)["prototype"]["getStartTime"]>; startTime: ReturnType<(typeof AvailableSlotsService)["prototype"]["getStartTime"]>;
endTime: Dayjs; endTime: Dayjs;
bypassBusyCalendarTimes: boolean; bypassBusyCalendarTimes: boolean;
silentCalendarFailures: boolean;
shouldServeCache?: boolean; shouldServeCache?: boolean;
}) { }) {
const usersWithCredentials = this.getUsersWithCredentials({ const usersWithCredentials = this.getUsersWithCredentials({
@@ -887,6 +890,7 @@ export class AvailableSlotsService {
duration: input.duration || 0, duration: input.duration || 0,
returnDateOverrides: false, returnDateOverrides: false,
bypassBusyCalendarTimes, bypassBusyCalendarTimes,
silentlyHandleCalendarFailures: silentCalendarFailures,
shouldServeCache, shouldServeCache,
}, },
initialData: { initialData: {
@@ -958,6 +962,7 @@ export class AvailableSlotsService {
const { const {
_enableTroubleshooter: enableTroubleshooter = false, _enableTroubleshooter: enableTroubleshooter = false,
_bypassCalendarBusyTimes: bypassBusyCalendarTimes = false, _bypassCalendarBusyTimes: bypassBusyCalendarTimes = false,
_silentCalendarFailures: silentCalendarFailures = false,
_shouldServeCache, _shouldServeCache,
routingFormResponseId, routingFormResponseId,
queuedFormResponseId, queuedFormResponseId,
@@ -1069,6 +1074,7 @@ export class AvailableSlotsService {
? this.getStartTime(twoWeeksFromNow.format(), input.timeZone, eventType.minimumBookingNotice) ? this.getStartTime(twoWeeksFromNow.format(), input.timeZone, eventType.minimumBookingNotice)
: endTime, : endTime,
bypassBusyCalendarTimes, bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache, shouldServeCache,
}); });
@@ -1095,6 +1101,7 @@ export class AvailableSlotsService {
startTime: dayjs(), startTime: dayjs(),
endTime: twoWeeksFromNow, endTime: twoWeeksFromNow,
bypassBusyCalendarTimes, bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache, shouldServeCache,
}); });
if ( if (
@@ -1129,6 +1136,7 @@ export class AvailableSlotsService {
startTime, startTime,
endTime, endTime,
bypassBusyCalendarTimes, bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache, shouldServeCache,
})); }));
aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); 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 // We only want to run this on single targeted events and not dynamic
if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) { if (!Object.keys(withinBoundsSlotsMappedToDate).length && input.usernameList?.length === 1) {
try { try {
// TODO: DI handleNotificationWhenNoSlots await this.dependencies.noSlotsNotificationService.handleNotificationWhenNoSlots({
await handleNotificationWhenNoSlots({
eventDetails: { eventDetails: {
username: input.usernameList?.[0], username: input.usernameList?.[0],
startTime: startTime, startTime: startTime,
+191 -152
View File
@@ -2500,7 +2500,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0 "@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*" "@calcom/platform-constants": "*"
"@calcom/platform-enums": "*" "@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.300" "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.312"
"@calcom/platform-types": "*" "@calcom/platform-types": "*"
"@calcom/platform-utils": "*" "@calcom/platform-utils": "*"
"@calcom/prisma": "*" "@calcom/prisma": "*"
@@ -2590,7 +2590,7 @@ __metadata:
"@sentry/nextjs": ^9.15.0 "@sentry/nextjs": ^9.15.0
bcryptjs: ^2.4.3 bcryptjs: ^2.4.3
memory-cache: ^0.2.0 memory-cache: ^0.2.0
next: ^15.3.0 next: ^15.4.5
next-api-middleware: ^1.0.1 next-api-middleware: ^1.0.1
next-axiom: ^0.17.0 next-axiom: ^0.17.0
next-swagger-doc: ^0.3.6 next-swagger-doc: ^0.3.6
@@ -3559,13 +3559,13 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.300": "@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.312":
version: 0.0.300 version: 0.0.312
resolution: "@calcom/platform-libraries@npm:0.0.300" resolution: "@calcom/platform-libraries@npm:0.0.312"
dependencies: dependencies:
"@calcom/features": "*" "@calcom/features": "*"
"@calcom/lib": "*" "@calcom/lib": "*"
checksum: bff9f08a0586169f2d5a85167f0a0e0e57ca93b637ca858a72b5d3d943ceef37d652efa3d2e6f7561548e4dfe3aabb5c0c6e0a8d38f68d3d21d5e6993e56ad7d checksum: 46520737404a61f3aa96c6f1adfa81e67f645fd984f495662ca7c63184b0310117a1dad57ddbe7ea487f2e5a0c9cbe62749a9a43b3197a021b19e3ccadc1f5b2
languageName: node languageName: node
linkType: hard linkType: hard
@@ -4115,7 +4115,7 @@ __metadata:
mime-types: ^2.1.35 mime-types: ^2.1.35
module-alias: ^2.2.2 module-alias: ^2.2.2
msw: ^0.42.3 msw: ^0.42.3
next: ^15.3.0 next: ^15.4.5
next-auth: ^4.22.1 next-auth: ^4.22.1
next-axiom: ^0.17.0 next-axiom: ^0.17.0
next-collect: ^0.2.1 next-collect: ^0.2.1
@@ -4711,12 +4711,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@emnapi/runtime@npm:^1.4.0": "@emnapi/runtime@npm:^1.4.4":
version: 1.4.3 version: 1.4.5
resolution: "@emnapi/runtime@npm:1.4.3" resolution: "@emnapi/runtime@npm:1.4.5"
dependencies: dependencies:
tslib: ^2.4.0 tslib: ^2.4.0
checksum: ff2074809638ed878e476ece370c6eae7e6257bf029a581bb7a290488d8f2a08c420a65988c7f03bfc6bb689218f0cd995d2f935bd182150b357fc2341142f4f checksum: 99ab25d55cf1ceeec12f83b60f48e744f8e1dfc8d52a2ed81b3b09bf15182e61ef55f25b69d51ec83044861bddaa4404e7c3285bf71dd518a7980867e41c2a10
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6724,11 +6724,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-darwin-arm64@npm:0.34.1": "@img/sharp-darwin-arm64@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-darwin-arm64@npm:0.34.1" resolution: "@img/sharp-darwin-arm64@npm:0.34.3"
dependencies: dependencies:
"@img/sharp-libvips-darwin-arm64": 1.1.0 "@img/sharp-libvips-darwin-arm64": 1.2.0
dependenciesMeta: dependenciesMeta:
"@img/sharp-libvips-darwin-arm64": "@img/sharp-libvips-darwin-arm64":
optional: true optional: true
@@ -6748,11 +6748,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-darwin-x64@npm:0.34.1": "@img/sharp-darwin-x64@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-darwin-x64@npm:0.34.1" resolution: "@img/sharp-darwin-x64@npm:0.34.3"
dependencies: dependencies:
"@img/sharp-libvips-darwin-x64": 1.1.0 "@img/sharp-libvips-darwin-x64": 1.2.0
dependenciesMeta: dependenciesMeta:
"@img/sharp-libvips-darwin-x64": "@img/sharp-libvips-darwin-x64":
optional: true optional: true
@@ -6767,9 +6767,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-libvips-darwin-arm64@npm:1.1.0": "@img/sharp-libvips-darwin-arm64@npm:1.2.0":
version: 1.1.0 version: 1.2.0
resolution: "@img/sharp-libvips-darwin-arm64@npm:1.1.0" resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.0"
conditions: os=darwin & cpu=arm64 conditions: os=darwin & cpu=arm64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6781,9 +6781,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-libvips-darwin-x64@npm:1.1.0": "@img/sharp-libvips-darwin-x64@npm:1.2.0":
version: 1.1.0 version: 1.2.0
resolution: "@img/sharp-libvips-darwin-x64@npm:1.1.0" resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.0"
conditions: os=darwin & cpu=x64 conditions: os=darwin & cpu=x64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6795,9 +6795,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-libvips-linux-arm64@npm:1.1.0": "@img/sharp-libvips-linux-arm64@npm:1.2.0":
version: 1.1.0 version: 1.2.0
resolution: "@img/sharp-libvips-linux-arm64@npm:1.1.0" resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.0"
conditions: os=linux & cpu=arm64 & libc=glibc conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6809,16 +6809,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-libvips-linux-arm@npm:1.1.0": "@img/sharp-libvips-linux-arm@npm:1.2.0":
version: 1.1.0 version: 1.2.0
resolution: "@img/sharp-libvips-linux-arm@npm:1.1.0" resolution: "@img/sharp-libvips-linux-arm@npm:1.2.0"
conditions: os=linux & cpu=arm & libc=glibc conditions: os=linux & cpu=arm & libc=glibc
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-libvips-linux-ppc64@npm:1.1.0": "@img/sharp-libvips-linux-ppc64@npm:1.2.0":
version: 1.1.0 version: 1.2.0
resolution: "@img/sharp-libvips-linux-ppc64@npm:1.1.0" resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.0"
conditions: os=linux & cpu=ppc64 & libc=glibc conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6830,9 +6830,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-libvips-linux-s390x@npm:1.1.0": "@img/sharp-libvips-linux-s390x@npm:1.2.0":
version: 1.1.0 version: 1.2.0
resolution: "@img/sharp-libvips-linux-s390x@npm:1.1.0" resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.0"
conditions: os=linux & cpu=s390x & libc=glibc conditions: os=linux & cpu=s390x & libc=glibc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6844,9 +6844,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-libvips-linux-x64@npm:1.1.0": "@img/sharp-libvips-linux-x64@npm:1.2.0":
version: 1.1.0 version: 1.2.0
resolution: "@img/sharp-libvips-linux-x64@npm:1.1.0" resolution: "@img/sharp-libvips-linux-x64@npm:1.2.0"
conditions: os=linux & cpu=x64 & libc=glibc conditions: os=linux & cpu=x64 & libc=glibc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6858,9 +6858,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-libvips-linuxmusl-arm64@npm:1.1.0": "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.0":
version: 1.1.0 version: 1.2.0
resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.1.0" resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.0"
conditions: os=linux & cpu=arm64 & libc=musl conditions: os=linux & cpu=arm64 & libc=musl
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6872,9 +6872,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-libvips-linuxmusl-x64@npm:1.1.0": "@img/sharp-libvips-linuxmusl-x64@npm:1.2.0":
version: 1.1.0 version: 1.2.0
resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.1.0" resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.0"
conditions: os=linux & cpu=x64 & libc=musl conditions: os=linux & cpu=x64 & libc=musl
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6891,11 +6891,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-linux-arm64@npm:0.34.1": "@img/sharp-linux-arm64@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-linux-arm64@npm:0.34.1" resolution: "@img/sharp-linux-arm64@npm:0.34.3"
dependencies: dependencies:
"@img/sharp-libvips-linux-arm64": 1.1.0 "@img/sharp-libvips-linux-arm64": 1.2.0
dependenciesMeta: dependenciesMeta:
"@img/sharp-libvips-linux-arm64": "@img/sharp-libvips-linux-arm64":
optional: true optional: true
@@ -6915,11 +6915,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-linux-arm@npm:0.34.1": "@img/sharp-linux-arm@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-linux-arm@npm:0.34.1" resolution: "@img/sharp-linux-arm@npm:0.34.3"
dependencies: dependencies:
"@img/sharp-libvips-linux-arm": 1.1.0 "@img/sharp-libvips-linux-arm": 1.2.0
dependenciesMeta: dependenciesMeta:
"@img/sharp-libvips-linux-arm": "@img/sharp-libvips-linux-arm":
optional: true optional: true
@@ -6927,6 +6927,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-linux-ppc64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-linux-ppc64@npm:0.34.3"
dependencies:
"@img/sharp-libvips-linux-ppc64": 1.2.0
dependenciesMeta:
"@img/sharp-libvips-linux-ppc64":
optional: true
conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node
linkType: hard
"@img/sharp-linux-s390x@npm:0.33.5": "@img/sharp-linux-s390x@npm:0.33.5":
version: 0.33.5 version: 0.33.5
resolution: "@img/sharp-linux-s390x@npm:0.33.5" resolution: "@img/sharp-linux-s390x@npm:0.33.5"
@@ -6939,11 +6951,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-linux-s390x@npm:0.34.1": "@img/sharp-linux-s390x@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-linux-s390x@npm:0.34.1" resolution: "@img/sharp-linux-s390x@npm:0.34.3"
dependencies: dependencies:
"@img/sharp-libvips-linux-s390x": 1.1.0 "@img/sharp-libvips-linux-s390x": 1.2.0
dependenciesMeta: dependenciesMeta:
"@img/sharp-libvips-linux-s390x": "@img/sharp-libvips-linux-s390x":
optional: true optional: true
@@ -6963,11 +6975,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-linux-x64@npm:0.34.1": "@img/sharp-linux-x64@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-linux-x64@npm:0.34.1" resolution: "@img/sharp-linux-x64@npm:0.34.3"
dependencies: dependencies:
"@img/sharp-libvips-linux-x64": 1.1.0 "@img/sharp-libvips-linux-x64": 1.2.0
dependenciesMeta: dependenciesMeta:
"@img/sharp-libvips-linux-x64": "@img/sharp-libvips-linux-x64":
optional: true optional: true
@@ -6987,11 +6999,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-linuxmusl-arm64@npm:0.34.1": "@img/sharp-linuxmusl-arm64@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.1" resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.3"
dependencies: dependencies:
"@img/sharp-libvips-linuxmusl-arm64": 1.1.0 "@img/sharp-libvips-linuxmusl-arm64": 1.2.0
dependenciesMeta: dependenciesMeta:
"@img/sharp-libvips-linuxmusl-arm64": "@img/sharp-libvips-linuxmusl-arm64":
optional: true optional: true
@@ -7011,11 +7023,11 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-linuxmusl-x64@npm:0.34.1": "@img/sharp-linuxmusl-x64@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-linuxmusl-x64@npm:0.34.1" resolution: "@img/sharp-linuxmusl-x64@npm:0.34.3"
dependencies: dependencies:
"@img/sharp-libvips-linuxmusl-x64": 1.1.0 "@img/sharp-libvips-linuxmusl-x64": 1.2.0
dependenciesMeta: dependenciesMeta:
"@img/sharp-libvips-linuxmusl-x64": "@img/sharp-libvips-linuxmusl-x64":
optional: true optional: true
@@ -7032,15 +7044,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-wasm32@npm:0.34.1": "@img/sharp-wasm32@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-wasm32@npm:0.34.1" resolution: "@img/sharp-wasm32@npm:0.34.3"
dependencies: dependencies:
"@emnapi/runtime": ^1.4.0 "@emnapi/runtime": ^1.4.4
conditions: cpu=wasm32 conditions: cpu=wasm32
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-win32-arm64@npm:0.34.3":
version: 0.34.3
resolution: "@img/sharp-win32-arm64@npm:0.34.3"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@img/sharp-win32-ia32@npm:0.33.5": "@img/sharp-win32-ia32@npm:0.33.5":
version: 0.33.5 version: 0.33.5
resolution: "@img/sharp-win32-ia32@npm:0.33.5" resolution: "@img/sharp-win32-ia32@npm:0.33.5"
@@ -7048,9 +7067,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-win32-ia32@npm:0.34.1": "@img/sharp-win32-ia32@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-win32-ia32@npm:0.34.1" resolution: "@img/sharp-win32-ia32@npm:0.34.3"
conditions: os=win32 & cpu=ia32 conditions: os=win32 & cpu=ia32
languageName: node languageName: node
linkType: hard linkType: hard
@@ -7062,9 +7081,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@img/sharp-win32-x64@npm:0.34.1": "@img/sharp-win32-x64@npm:0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "@img/sharp-win32-x64@npm:0.34.1" resolution: "@img/sharp-win32-x64@npm:0.34.3"
conditions: os=win32 & cpu=x64 conditions: os=win32 & cpu=x64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -8940,10 +8959,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/env@npm:15.3.3": "@next/env@npm:15.4.6":
version: 15.3.3 version: 15.4.6
resolution: "@next/env@npm:15.3.3" resolution: "@next/env@npm:15.4.6"
checksum: 52513b04ff1951ad094b37a89dcd37e9275c2d6c635dbe7fbdc44bd39b5511eb2c7c537a80ebbf92d197e5f1293ff16c175cd5dccc9c54bbfaac7271c1fe1bb9 checksum: bf1ea15f2a86c6bc968d92d4fb778d106e6e94c31893f51699f0fc51fd17416ae9e4086176164e82430c84597d509b77c2c5738e8cd98bc7f73ebc6d83b2926f
languageName: node languageName: node
linkType: hard linkType: hard
@@ -8995,9 +9014,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-darwin-arm64@npm:15.3.3": "@next/swc-darwin-arm64@npm:15.4.6":
version: 15.3.3 version: 15.4.6
resolution: "@next/swc-darwin-arm64@npm:15.3.3" resolution: "@next/swc-darwin-arm64@npm:15.4.6"
conditions: os=darwin & cpu=arm64 conditions: os=darwin & cpu=arm64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9023,9 +9042,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-darwin-x64@npm:15.3.3": "@next/swc-darwin-x64@npm:15.4.6":
version: 15.3.3 version: 15.4.6
resolution: "@next/swc-darwin-x64@npm:15.3.3" resolution: "@next/swc-darwin-x64@npm:15.4.6"
conditions: os=darwin & cpu=x64 conditions: os=darwin & cpu=x64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9051,9 +9070,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-linux-arm64-gnu@npm:15.3.3": "@next/swc-linux-arm64-gnu@npm:15.4.6":
version: 15.3.3 version: 15.4.6
resolution: "@next/swc-linux-arm64-gnu@npm:15.3.3" resolution: "@next/swc-linux-arm64-gnu@npm:15.4.6"
conditions: os=linux & cpu=arm64 & libc=glibc conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9079,9 +9098,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-linux-arm64-musl@npm:15.3.3": "@next/swc-linux-arm64-musl@npm:15.4.6":
version: 15.3.3 version: 15.4.6
resolution: "@next/swc-linux-arm64-musl@npm:15.3.3" resolution: "@next/swc-linux-arm64-musl@npm:15.4.6"
conditions: os=linux & cpu=arm64 & libc=musl conditions: os=linux & cpu=arm64 & libc=musl
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9107,9 +9126,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-linux-x64-gnu@npm:15.3.3": "@next/swc-linux-x64-gnu@npm:15.4.6":
version: 15.3.3 version: 15.4.6
resolution: "@next/swc-linux-x64-gnu@npm:15.3.3" resolution: "@next/swc-linux-x64-gnu@npm:15.4.6"
conditions: os=linux & cpu=x64 & libc=glibc conditions: os=linux & cpu=x64 & libc=glibc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9135,9 +9154,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-linux-x64-musl@npm:15.3.3": "@next/swc-linux-x64-musl@npm:15.4.6":
version: 15.3.3 version: 15.4.6
resolution: "@next/swc-linux-x64-musl@npm:15.3.3" resolution: "@next/swc-linux-x64-musl@npm:15.4.6"
conditions: os=linux & cpu=x64 & libc=musl conditions: os=linux & cpu=x64 & libc=musl
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9163,9 +9182,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-win32-arm64-msvc@npm:15.3.3": "@next/swc-win32-arm64-msvc@npm:15.4.6":
version: 15.3.3 version: 15.4.6
resolution: "@next/swc-win32-arm64-msvc@npm:15.3.3" resolution: "@next/swc-win32-arm64-msvc@npm:15.4.6"
conditions: os=win32 & cpu=arm64 conditions: os=win32 & cpu=arm64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9205,9 +9224,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@next/swc-win32-x64-msvc@npm:15.3.3": "@next/swc-win32-x64-msvc@npm:15.4.6":
version: 15.3.3 version: 15.4.6
resolution: "@next/swc-win32-x64-msvc@npm:15.3.3" resolution: "@next/swc-win32-x64-msvc@npm:15.4.6"
conditions: os=win32 & cpu=x64 conditions: os=win32 & cpu=x64
languageName: node languageName: node
linkType: hard linkType: hard
@@ -24262,6 +24281,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"detect-libc@npm:^2.0.4":
version: 2.0.4
resolution: "detect-libc@npm:2.0.4"
checksum: 3d186b7d4e16965e10e21db596c78a4e131f9eee69c0081d13b85e6a61d7448d3ba23fe7997648022bdfa3b0eb4cc3c289a44c8188df949445a20852689abef6
languageName: node
linkType: hard
"detect-newline@npm:^3.0.0": "detect-newline@npm:^3.0.0":
version: 3.1.0 version: 3.1.0
resolution: "detect-newline@npm:3.1.0" resolution: "detect-newline@npm:3.1.0"
@@ -35825,29 +35851,27 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"next@npm:^15.3.0": "next@npm:^15.4.5":
version: 15.3.3 version: 15.4.6
resolution: "next@npm:15.3.3" resolution: "next@npm:15.4.6"
dependencies: dependencies:
"@next/env": 15.3.3 "@next/env": 15.4.6
"@next/swc-darwin-arm64": 15.3.3 "@next/swc-darwin-arm64": 15.4.6
"@next/swc-darwin-x64": 15.3.3 "@next/swc-darwin-x64": 15.4.6
"@next/swc-linux-arm64-gnu": 15.3.3 "@next/swc-linux-arm64-gnu": 15.4.6
"@next/swc-linux-arm64-musl": 15.3.3 "@next/swc-linux-arm64-musl": 15.4.6
"@next/swc-linux-x64-gnu": 15.3.3 "@next/swc-linux-x64-gnu": 15.4.6
"@next/swc-linux-x64-musl": 15.3.3 "@next/swc-linux-x64-musl": 15.4.6
"@next/swc-win32-arm64-msvc": 15.3.3 "@next/swc-win32-arm64-msvc": 15.4.6
"@next/swc-win32-x64-msvc": 15.3.3 "@next/swc-win32-x64-msvc": 15.4.6
"@swc/counter": 0.1.3
"@swc/helpers": 0.5.15 "@swc/helpers": 0.5.15
busboy: 1.6.0
caniuse-lite: ^1.0.30001579 caniuse-lite: ^1.0.30001579
postcss: 8.4.31 postcss: 8.4.31
sharp: ^0.34.1 sharp: ^0.34.3
styled-jsx: 5.1.6 styled-jsx: 5.1.6
peerDependencies: peerDependencies:
"@opentelemetry/api": ^1.1.0 "@opentelemetry/api": ^1.1.0
"@playwright/test": ^1.41.2 "@playwright/test": ^1.51.1
babel-plugin-react-compiler: "*" babel-plugin-react-compiler: "*"
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
@@ -35882,7 +35906,7 @@ __metadata:
optional: true optional: true
bin: bin:
next: dist/bin/next next: dist/bin/next
checksum: 0e071a67e50b2770d27268b19dbe5a510297cf31fe3035ae169caa7e93f97bcf58c3f533e22fe300c8ad92b87e4f7d807619f7aa3b97a290de7c2b82b1451c4c checksum: 996e28076e30ae1e077219e46d2555f73391ca8132016c83b6096283f638bd6c7be58e2f320d0ef45ac0c18067138d603f900df50517d20ca804f102d2150d8a
languageName: node languageName: node
linkType: hard linkType: hard
@@ -41705,7 +41729,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.1": "semver@npm:^7.6.0, semver@npm:^7.6.3":
version: 7.7.1 version: 7.7.1
resolution: "semver@npm:7.7.1" resolution: "semver@npm:7.7.1"
bin: bin:
@@ -41714,6 +41738,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"semver@npm:^7.7.2":
version: 7.7.2
resolution: "semver@npm:7.7.2"
bin:
semver: bin/semver.js
checksum: dd94ba8f1cbc903d8eeb4dd8bf19f46b3deb14262b6717d0de3c804b594058ae785ef2e4b46c5c3b58733c99c83339068203002f9e37cfe44f7e2cc5e3d2f621
languageName: node
linkType: hard
"semver@npm:~2.3.1": "semver@npm:~2.3.1":
version: 2.3.2 version: 2.3.2
resolution: "semver@npm:2.3.2" resolution: "semver@npm:2.3.2"
@@ -41989,33 +42022,35 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"sharp@npm:^0.34.1": "sharp@npm:^0.34.3":
version: 0.34.1 version: 0.34.3
resolution: "sharp@npm:0.34.1" resolution: "sharp@npm:0.34.3"
dependencies: dependencies:
"@img/sharp-darwin-arm64": 0.34.1 "@img/sharp-darwin-arm64": 0.34.3
"@img/sharp-darwin-x64": 0.34.1 "@img/sharp-darwin-x64": 0.34.3
"@img/sharp-libvips-darwin-arm64": 1.1.0 "@img/sharp-libvips-darwin-arm64": 1.2.0
"@img/sharp-libvips-darwin-x64": 1.1.0 "@img/sharp-libvips-darwin-x64": 1.2.0
"@img/sharp-libvips-linux-arm": 1.1.0 "@img/sharp-libvips-linux-arm": 1.2.0
"@img/sharp-libvips-linux-arm64": 1.1.0 "@img/sharp-libvips-linux-arm64": 1.2.0
"@img/sharp-libvips-linux-ppc64": 1.1.0 "@img/sharp-libvips-linux-ppc64": 1.2.0
"@img/sharp-libvips-linux-s390x": 1.1.0 "@img/sharp-libvips-linux-s390x": 1.2.0
"@img/sharp-libvips-linux-x64": 1.1.0 "@img/sharp-libvips-linux-x64": 1.2.0
"@img/sharp-libvips-linuxmusl-arm64": 1.1.0 "@img/sharp-libvips-linuxmusl-arm64": 1.2.0
"@img/sharp-libvips-linuxmusl-x64": 1.1.0 "@img/sharp-libvips-linuxmusl-x64": 1.2.0
"@img/sharp-linux-arm": 0.34.1 "@img/sharp-linux-arm": 0.34.3
"@img/sharp-linux-arm64": 0.34.1 "@img/sharp-linux-arm64": 0.34.3
"@img/sharp-linux-s390x": 0.34.1 "@img/sharp-linux-ppc64": 0.34.3
"@img/sharp-linux-x64": 0.34.1 "@img/sharp-linux-s390x": 0.34.3
"@img/sharp-linuxmusl-arm64": 0.34.1 "@img/sharp-linux-x64": 0.34.3
"@img/sharp-linuxmusl-x64": 0.34.1 "@img/sharp-linuxmusl-arm64": 0.34.3
"@img/sharp-wasm32": 0.34.1 "@img/sharp-linuxmusl-x64": 0.34.3
"@img/sharp-win32-ia32": 0.34.1 "@img/sharp-wasm32": 0.34.3
"@img/sharp-win32-x64": 0.34.1 "@img/sharp-win32-arm64": 0.34.3
"@img/sharp-win32-ia32": 0.34.3
"@img/sharp-win32-x64": 0.34.3
color: ^4.2.3 color: ^4.2.3
detect-libc: ^2.0.3 detect-libc: ^2.0.4
semver: ^7.7.1 semver: ^7.7.2
dependenciesMeta: dependenciesMeta:
"@img/sharp-darwin-arm64": "@img/sharp-darwin-arm64":
optional: true optional: true
@@ -42043,6 +42078,8 @@ __metadata:
optional: true optional: true
"@img/sharp-linux-arm64": "@img/sharp-linux-arm64":
optional: true optional: true
"@img/sharp-linux-ppc64":
optional: true
"@img/sharp-linux-s390x": "@img/sharp-linux-s390x":
optional: true optional: true
"@img/sharp-linux-x64": "@img/sharp-linux-x64":
@@ -42053,11 +42090,13 @@ __metadata:
optional: true optional: true
"@img/sharp-wasm32": "@img/sharp-wasm32":
optional: true optional: true
"@img/sharp-win32-arm64":
optional: true
"@img/sharp-win32-ia32": "@img/sharp-win32-ia32":
optional: true optional: true
"@img/sharp-win32-x64": "@img/sharp-win32-x64":
optional: true optional: true
checksum: ce2798a2d1a4e4fb9bae5642177e4681050897de8c2d6e1783c8c15d8a40cc0e22bf4ce91d1bf355afd133d16d7032fcfdfa2ed3305eab34b7f8de8f61175519 checksum: f1d70751ab15080957f1a5db5583d00b002e644878e3e9a6d00bdbb31255f6e9df218ef82d96d3588b83023681f35bcf03e265a44e214b11306ef3fb439ac04d
languageName: node languageName: node
linkType: hard linkType: hard