Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3fbb672b | ||
|
|
2d5a86774e | ||
|
|
0865104a77 | ||
|
|
1914b37ee4 | ||
|
|
58ef0abb99 | ||
|
|
13ba680a73 | ||
|
|
f40c6da93b | ||
|
|
fc2e81e89a | ||
|
|
293dd1a140 | ||
|
|
b7fda31678 | ||
|
|
94a04f0dc2 | ||
|
|
2338f85dcf | ||
|
|
fbaccc8e5f | ||
|
|
750db38d38 | ||
|
|
8829e1ca3f | ||
|
|
ab703a4346 | ||
|
|
8d5a7b4e10 | ||
|
|
6ba3049314 | ||
|
|
abe92cd693 | ||
|
|
6c6cf0433a | ||
|
|
211b958c58 | ||
|
|
01a0d43943 | ||
|
|
3133eb5aeb | ||
|
|
76b68bebd7 | ||
|
|
c20e723e34 | ||
|
|
7b7478c0a9 | ||
|
+3 |
ebeb008f9b |
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
|
||||
|
||||
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
|
||||
|
||||
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
booker atom: allow toggling org and team info when booking round robin
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Atoms E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'packages/platform/atoms/**'
|
||||
- 'packages/platform/examples/base/**'
|
||||
- 'apps/api/v2/**'
|
||||
- '.github/workflows/atoms-e2e.yml'
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8096
|
||||
ATOMS_E2E_OAUTH_CLIENT_ID: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID }}
|
||||
ATOMS_E2E_OAUTH_CLIENT_SECRET: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET }}
|
||||
ATOMS_E2E_API_URL: ${{ secrets.ATOMS_E2E_API_URL }}
|
||||
ATOMS_E2E_ORG_ID: ${{ secrets.ATOMS_E2E_ORG_ID }}
|
||||
ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
|
||||
jobs:
|
||||
atoms-e2e:
|
||||
timeout-minutes: 15
|
||||
name: Atoms E2E Tests
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/yarn-playwright-install
|
||||
- name: Run Atoms E2E Tests
|
||||
working-directory: packages/platform/examples/base
|
||||
run: yarn test:e2e
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: atoms-e2e-test-results
|
||||
path: test-results
|
||||
retention-days: 7
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: atoms-e2e-playwright-report
|
||||
path: playwright-report
|
||||
retention-days: 7
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
|
||||
@@ -76,11 +76,13 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro
|
||||
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials", () => ({
|
||||
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||
qualifiedRRHosts: [],
|
||||
allFallbackRRHosts: [],
|
||||
fixedHosts: [],
|
||||
vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
|
||||
getQualifiedHostsService: vi.fn().mockReturnValue({
|
||||
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
|
||||
qualifiedRRHosts: [],
|
||||
allFallbackRRHosts: [],
|
||||
fixedHosts: [],
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.294",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.300",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
+2
-3
@@ -67,10 +67,9 @@ export class EventTypesPrivateLinksController {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get all private links for an event type" })
|
||||
async getPrivateLinks(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||
): Promise<GetPrivateLinksOutput> {
|
||||
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId, userId);
|
||||
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
|
||||
import { PrivateLinksRepository } from "./private-links.repository";
|
||||
import { PrivateLinksInputService } from "./services/private-links-input.service";
|
||||
import { PrivateLinksOutputService } from "./services/private-links-output.service";
|
||||
import { PrivateLinksService } from "./services/private-links.service";
|
||||
import { PrivateLinksRepository } from "./private-links.repository";
|
||||
|
||||
@Module({
|
||||
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
|
||||
@@ -23,5 +23,3 @@ import { PrivateLinksRepository } from "./private-links.repository";
|
||||
],
|
||||
})
|
||||
export class EventTypesPrivateLinksModule {}
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ export class PrivateLinksRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async create(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
|
||||
async create(
|
||||
eventTypeId: number,
|
||||
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
|
||||
) {
|
||||
return this.dbWrite.prisma.hashedLink.create({
|
||||
data: {
|
||||
eventTypeId,
|
||||
@@ -36,7 +39,10 @@ export class PrivateLinksRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async update(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
|
||||
async update(
|
||||
eventTypeId: number,
|
||||
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
|
||||
) {
|
||||
return this.dbWrite.prisma.hashedLink.updateMany({
|
||||
where: { eventTypeId, link: link.link },
|
||||
data: {
|
||||
@@ -50,5 +56,3 @@ export class PrivateLinksRepository {
|
||||
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
-3
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import {
|
||||
PrivateLinkOutput,
|
||||
TimeBasedPrivateLinkOutput,
|
||||
UsageBasedPrivateLinkOutput,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
export type PrivateLinkData = {
|
||||
id: string;
|
||||
eventTypeId: number;
|
||||
@@ -10,7 +16,6 @@ export type PrivateLinkData = {
|
||||
maxUsageCount?: number | null;
|
||||
usageCount?: number;
|
||||
};
|
||||
import { PrivateLinkOutput, TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class PrivateLinksOutputService {
|
||||
@@ -39,5 +44,3 @@ export class PrivateLinksOutputService {
|
||||
return data.map((item) => this.transformToOutput(item));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
|
||||
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
|
||||
|
||||
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
|
||||
import { PrivateLinksInputService } from "@/ee/event-types-private-links/services/private-links-input.service";
|
||||
import {
|
||||
PrivateLinksOutputService,
|
||||
type PrivateLinkData,
|
||||
} from "@/ee/event-types-private-links/services/private-links-output.service";
|
||||
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
|
||||
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class PrivateLinksService {
|
||||
@@ -48,7 +47,7 @@ export class PrivateLinksService {
|
||||
}
|
||||
}
|
||||
|
||||
async getPrivateLinks(eventTypeId: number, userId: number): Promise<PrivateLinkOutput[]> {
|
||||
async getPrivateLinks(eventTypeId: number): Promise<PrivateLinkOutput[]> {
|
||||
try {
|
||||
const links = await this.repo.listByEventTypeId(eventTypeId);
|
||||
const mapped: PrivateLinkData[] = links.map((l) => ({
|
||||
@@ -124,5 +123,3 @@ export class PrivateLinksService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
-2
@@ -6,7 +6,6 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
|
||||
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
|
||||
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
GetEventTypesQuery_2024_06_14,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
|
||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||
import { CalendarsModule } from "@/ee/calendars/calendars.module";
|
||||
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { GcalModule } from "@/ee/gcal/gcal.module";
|
||||
@@ -14,7 +15,6 @@ import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.m
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
@@ -34,6 +36,8 @@ import { Module } from "@nestjs/common";
|
||||
AvailableSlotsService,
|
||||
UserAvailabilityService,
|
||||
BusyTimesService,
|
||||
FilterHostsService,
|
||||
QualifiedHostsService,
|
||||
],
|
||||
exports: [AvailableSlotsService],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"
|
||||
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@@ -29,7 +30,12 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
eventTypeRepository: PrismaEventTypeRepository,
|
||||
userRepository: PrismaUserRepository,
|
||||
redisService: RedisService,
|
||||
featuresRepository: PrismaFeaturesRepository
|
||||
featuresRepository: PrismaFeaturesRepository,
|
||||
qualifiedHostsService: QualifiedHostsService,
|
||||
checkBookingLimitsService: CheckBookingLimitsService,
|
||||
cacheService: CacheService,
|
||||
userAvailabilityService: UserAvailabilityService,
|
||||
busyTimesService: BusyTimesService
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
@@ -41,15 +47,12 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
userRepo: userRepository,
|
||||
redisClient: redisService,
|
||||
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
|
||||
cacheService: new CacheService(featuresRepository),
|
||||
userAvailabilityService: new UserAvailabilityService(
|
||||
oooRepoDependency,
|
||||
bookingRepository,
|
||||
eventTypeRepository,
|
||||
redisService
|
||||
),
|
||||
busyTimesService: new BusyTimesService(bookingRepository),
|
||||
checkBookingLimitsService,
|
||||
cacheService,
|
||||
userAvailabilityService,
|
||||
busyTimesService,
|
||||
qualifiedHostsService,
|
||||
featuresRepo: featuresRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { FilterHostsService as BaseFilterHostsService } from "@calcom/platform-libraries/slots";
|
||||
|
||||
@Injectable()
|
||||
export class FilterHostsService extends BaseFilterHostsService {
|
||||
constructor(bookingRepository: PrismaBookingRepository) {
|
||||
super({
|
||||
bookingRepo: bookingRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { QualifiedHostsService as BaseQualifiedHostsService } from "@calcom/platform-libraries/slots";
|
||||
|
||||
@Injectable()
|
||||
export class QualifiedHostsService extends BaseQualifiedHostsService {
|
||||
constructor(bookingRepository: PrismaBookingRepository, filterHostsService: FilterHostsService) {
|
||||
super({
|
||||
bookingRepo: bookingRepository,
|
||||
filterHostsService,
|
||||
});
|
||||
}
|
||||
}
|
||||
+52
-2
@@ -2,10 +2,21 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
||||
import { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input";
|
||||
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
|
||||
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
BadRequestException,
|
||||
Patch,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -30,6 +41,7 @@ export class CalUnifiedCalendarsController {
|
||||
type: String,
|
||||
})
|
||||
@Get("/:calendar/event/:eventUid")
|
||||
@Get("/:calendar/events/:eventUid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -54,4 +66,42 @@ export class CalUnifiedCalendarsController {
|
||||
data: transformedEvent,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiParam({
|
||||
name: "calendar",
|
||||
enum: [GOOGLE_CALENDAR],
|
||||
type: String,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "eventUid",
|
||||
description:
|
||||
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
type: String,
|
||||
})
|
||||
@Patch("/:calendar/events/:eventUid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Update meeting details in calendar",
|
||||
description: "Updates event information in the specified calendar provider",
|
||||
})
|
||||
async updateCalendarEvent(
|
||||
@Param("calendar") calendar: string,
|
||||
@Param("eventUid") eventUid: string,
|
||||
@Body() updateData: UpdateUnifiedCalendarEventInput
|
||||
): Promise<GetUnifiedCalendarEventOutput> {
|
||||
if (calendar !== GOOGLE_CALENDAR) {
|
||||
throw new BadRequestException("Event updates are currently only available for Google Calendar");
|
||||
}
|
||||
|
||||
const updatedEvent = await this.googleCalendarService.updateEventDetails(eventUid, updateData);
|
||||
|
||||
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(updatedEvent);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: transformedEvent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsISO8601, IsOptional, IsString, ValidateNested, IsEnum, IsArray } from "class-validator";
|
||||
|
||||
import { CalendarEventStatus, CalendarEventResponseStatus } from "../outputs/get-unified-calendar-event.output";
|
||||
|
||||
export class UpdateCalendarEventAttendee {
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Email address of the attendee",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Display name of the attendee",
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@IsEnum(CalendarEventResponseStatus)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventResponseStatus,
|
||||
enumName: "CalendarEventResponseStatus",
|
||||
nullable: true,
|
||||
description: "Response status of the attendee",
|
||||
})
|
||||
responseStatus?: CalendarEventResponseStatus | null;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the current user",
|
||||
})
|
||||
self?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee's attendance is optional",
|
||||
})
|
||||
optional?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the host",
|
||||
})
|
||||
host?: boolean;
|
||||
|
||||
}
|
||||
|
||||
export class UpdateDateTimeWithZone {
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ type: "string", format: "date-time" })
|
||||
time?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export class UpdateUnifiedCalendarEventInput {
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateDateTimeWithZone)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time" },
|
||||
timeZone: { type: "string" },
|
||||
},
|
||||
description: "Start date and time of the calendar event with timezone information",
|
||||
})
|
||||
start?: UpdateDateTimeWithZone;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateDateTimeWithZone)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time" },
|
||||
timeZone: { type: "string" },
|
||||
},
|
||||
description: "End date and time of the calendar event with timezone information",
|
||||
})
|
||||
end?: UpdateDateTimeWithZone;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Title of the calendar event",
|
||||
})
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: "Detailed description of the calendar event",
|
||||
})
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpdateCalendarEventAttendee)
|
||||
@ApiPropertyOptional({
|
||||
type: [UpdateCalendarEventAttendee],
|
||||
nullable: true,
|
||||
description:
|
||||
"List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
|
||||
})
|
||||
attendees?: UpdateCalendarEventAttendee[];
|
||||
|
||||
@IsEnum(CalendarEventStatus)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventStatus,
|
||||
enumName: "CalendarEventStatus",
|
||||
nullable: true,
|
||||
description: "Status of the event (accepted, pending, declined, cancelled)",
|
||||
example: CalendarEventStatus.ACCEPTED,
|
||||
})
|
||||
status?: CalendarEventStatus | null;
|
||||
}
|
||||
+34
@@ -225,6 +225,22 @@ export class CalendarEventHost {
|
||||
responseStatus!: CalendarEventResponseStatus | null;
|
||||
}
|
||||
|
||||
export class calendarEventOwner {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "Email address of the event host",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Display name of the event host",
|
||||
})
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class CalendarEventAttendee {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
@@ -264,6 +280,14 @@ export class CalendarEventAttendee {
|
||||
description: "Indicates if this attendee's attendance is optional",
|
||||
})
|
||||
optional?: boolean;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the host",
|
||||
})
|
||||
host?: boolean;
|
||||
}
|
||||
|
||||
export class DateTimeWithZone {
|
||||
@@ -384,6 +408,16 @@ export class UnifiedCalendarEventOutput {
|
||||
})
|
||||
hosts?: CalendarEventHost[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => calendarEventOwner)
|
||||
@ApiPropertyOptional({
|
||||
type: calendarEventOwner,
|
||||
nullable: true,
|
||||
description: "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
|
||||
})
|
||||
calendarEventOwner?: calendarEventOwner;
|
||||
|
||||
@IsEnum(CALENDARS)
|
||||
@ApiProperty({
|
||||
enum: CALENDARS,
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { GoogleCalendarEventResponse } from "../get-calendar-event-details-output-pipe";
|
||||
|
||||
export const createGoogleCalendarEventFixture = (
|
||||
overrides: Partial<GoogleCalendarEventResponse> = {}
|
||||
): GoogleCalendarEventResponse => {
|
||||
const baseEvent: GoogleCalendarEventResponse = {
|
||||
kind: "calendar#event",
|
||||
etag: "test-etag",
|
||||
id: "test-event-id",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event",
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-01-01T00:00:00Z",
|
||||
summary: "Test Meeting",
|
||||
description: "Test description",
|
||||
creator: {
|
||||
email: "creator@example.com",
|
||||
displayName: "Creator Name",
|
||||
},
|
||||
organizer: {
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer Name",
|
||||
},
|
||||
start: {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
dateTime: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
iCalUID: "test-ical-uid",
|
||||
sequence: 0,
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee@example.com",
|
||||
displayName: "Attendee Name",
|
||||
responseStatus: "accepted",
|
||||
organizer: false,
|
||||
},
|
||||
{
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer Name",
|
||||
responseStatus: "accepted",
|
||||
organizer: true,
|
||||
},
|
||||
],
|
||||
conferenceData: {
|
||||
conferenceId: "abc-def-ghi",
|
||||
entryPoints: [
|
||||
{
|
||||
entryPointType: "video",
|
||||
uri: "https://meet.google.com/abc-def-ghi",
|
||||
label: "meet.google.com/abc-def-ghi",
|
||||
},
|
||||
],
|
||||
conferenceSolution: {
|
||||
key: {
|
||||
type: "hangoutsMeet",
|
||||
},
|
||||
name: "Google Meet",
|
||||
iconUri:
|
||||
"https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png",
|
||||
},
|
||||
},
|
||||
hangoutLink: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return { ...baseEvent, ...overrides };
|
||||
};
|
||||
|
||||
export const googleEventWithConferenceData = createGoogleCalendarEventFixture({
|
||||
conferenceData: {
|
||||
conferenceId: "test-conference-id",
|
||||
entryPoints: [
|
||||
{
|
||||
entryPointType: "video",
|
||||
uri: "https://meet.google.com/abc-def-ghi",
|
||||
label: "Google Meet",
|
||||
pin: "123456",
|
||||
regionCode: "US",
|
||||
},
|
||||
{
|
||||
entryPointType: "phone",
|
||||
uri: "tel:+1-555-123-4567",
|
||||
label: "Phone",
|
||||
pin: "789012",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const googleEventWithLocationOnly = createGoogleCalendarEventFixture({
|
||||
location: "123 Main St, City, State",
|
||||
conferenceData: undefined,
|
||||
hangoutLink: undefined,
|
||||
});
|
||||
|
||||
export const googleEventWithHangoutLink = createGoogleCalendarEventFixture({
|
||||
hangoutLink: "https://hangouts.google.com/call/abc123",
|
||||
conferenceData: undefined,
|
||||
location: undefined,
|
||||
});
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
CalendarEventStatus,
|
||||
CalendarEventResponseStatus,
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||
|
||||
import {
|
||||
createGoogleCalendarEventFixture,
|
||||
googleEventWithConferenceData,
|
||||
googleEventWithLocationOnly,
|
||||
googleEventWithHangoutLink,
|
||||
} from "./__fixtures__/google-calendar-event.fixture";
|
||||
import {
|
||||
GoogleCalendarEventOutputPipe,
|
||||
GoogleCalendarEventResponse,
|
||||
} from "./get-calendar-event-details-output-pipe";
|
||||
|
||||
describe("GoogleCalendarEventOutputPipe", () => {
|
||||
let pipe: GoogleCalendarEventOutputPipe;
|
||||
let sharedGoogleEvent: GoogleCalendarEventResponse;
|
||||
|
||||
beforeAll(() => {
|
||||
sharedGoogleEvent = createGoogleCalendarEventFixture();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pipe = new GoogleCalendarEventOutputPipe();
|
||||
});
|
||||
|
||||
describe("transform", () => {
|
||||
it("should transform Google Calendar event to unified format", () => {
|
||||
const result = pipe.transform(sharedGoogleEvent);
|
||||
|
||||
expect(result.id).toBe("test-event-id");
|
||||
expect(result.title).toBe("Test Meeting");
|
||||
expect(result.description).toBe("Test description");
|
||||
expect(result.source).toBe("google");
|
||||
expect(result.status).toBe(CalendarEventStatus.ACCEPTED);
|
||||
expect(result.start.time).toBe("2024-01-15T10:00:00Z");
|
||||
expect(result.start.timeZone).toBe("America/New_York");
|
||||
expect(result.end.time).toBe("2024-01-15T11:00:00Z");
|
||||
expect(result.end.timeZone).toBe("America/New_York");
|
||||
expect(result.attendees).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformDateTimeWithZone", () => {
|
||||
it("should transform Google date time to unified format", () => {
|
||||
const googleDateTime = {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
const result = pipe["transformDateTimeWithZone"](googleDateTime);
|
||||
|
||||
expect(result.time).toBe("2024-01-15T10:00:00Z");
|
||||
expect(result.timeZone).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformAttendeeResponseStatus", () => {
|
||||
it("should transform accepted status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("accepted");
|
||||
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should transform tentative status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("tentative");
|
||||
expect(result).toBe(CalendarEventResponseStatus.PENDING);
|
||||
});
|
||||
|
||||
it("should transform declined status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("declined");
|
||||
expect(result).toBe(CalendarEventResponseStatus.DECLINED);
|
||||
});
|
||||
|
||||
it("should transform needsaction status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("needsaction");
|
||||
expect(result).toBe(CalendarEventResponseStatus.NEEDS_ACTION);
|
||||
});
|
||||
|
||||
it("should handle case insensitive status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("ACCEPTED");
|
||||
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should handle unknown status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("unknown");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"](undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformEventStatus", () => {
|
||||
it("should transform confirmed status", () => {
|
||||
const result = pipe["transformEventStatus"]("confirmed");
|
||||
expect(result).toBe(CalendarEventStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should transform tentative status", () => {
|
||||
const result = pipe["transformEventStatus"]("tentative");
|
||||
expect(result).toBe(CalendarEventStatus.PENDING);
|
||||
});
|
||||
|
||||
it("should transform cancelled status", () => {
|
||||
const result = pipe["transformEventStatus"]("cancelled");
|
||||
expect(result).toBe(CalendarEventStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it("should handle case insensitive status", () => {
|
||||
const result = pipe["transformEventStatus"]("CONFIRMED");
|
||||
expect(result).toBe(CalendarEventStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should handle unknown status", () => {
|
||||
const result = pipe["transformEventStatus"]("unknown");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformEventStatus"](undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformOrganizer", () => {
|
||||
it("should return calendarEventOwner organizer exist", () => {
|
||||
const eventWithoutOrganizerAttendees = createGoogleCalendarEventFixture({
|
||||
organizer: { email: "organizer@example.com" },
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee@example.com",
|
||||
displayName: "Regular Attendee",
|
||||
responseStatus: "accepted",
|
||||
organizer: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = pipe.transform(eventWithoutOrganizerAttendees);
|
||||
|
||||
expect(result?.calendarEventOwner?.email).toBe("organizer@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformLocations", () => {
|
||||
it("should transform conference data entry points", () => {
|
||||
const result = pipe["transformLocations"](googleEventWithConferenceData);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
type: "video",
|
||||
url: "https://meet.google.com/abc-def-ghi",
|
||||
label: "Google Meet",
|
||||
pin: "123456",
|
||||
regionCode: "US",
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
type: "phone",
|
||||
url: "tel:+1-555-123-4567",
|
||||
label: "Phone",
|
||||
pin: "789012",
|
||||
regionCode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to location field", () => {
|
||||
const result = pipe["transformLocations"](googleEventWithLocationOnly);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "video",
|
||||
url: "123 Main St, City, State",
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to hangout link", () => {
|
||||
const result = pipe["transformLocations"](googleEventWithHangoutLink);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "video",
|
||||
url: "https://hangouts.google.com/call/abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array when no location data", () => {
|
||||
const eventWithoutLocation = createGoogleCalendarEventFixture({
|
||||
location: undefined,
|
||||
conferenceData: undefined,
|
||||
hangoutLink: undefined,
|
||||
});
|
||||
|
||||
const result = pipe["transformLocations"](eventWithoutLocation);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
-6
@@ -3,7 +3,7 @@ import {
|
||||
CalendarEventStatus,
|
||||
DateTimeWithZone,
|
||||
UnifiedCalendarEventOutput,
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||
import { PipeTransform, Injectable } from "@nestjs/common";
|
||||
|
||||
export interface GoogleCalendarEventResponse {
|
||||
@@ -94,14 +94,12 @@ export class GoogleCalendarEventOutputPipe
|
||||
calendarEvent.locations = this.transformLocations(googleEvent);
|
||||
|
||||
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
|
||||
calendarEvent.attendees = googleEvent.attendees
|
||||
.filter((attendee) => !attendee.organizer)
|
||||
.map((attendee) => {
|
||||
calendarEvent.attendees = googleEvent.attendees.map((attendee) => {
|
||||
return {
|
||||
email: attendee.email,
|
||||
name: attendee.displayName,
|
||||
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
|
||||
organizer: attendee.organizer,
|
||||
host: attendee.organizer,
|
||||
self: attendee.self,
|
||||
optional: attendee.optional,
|
||||
};
|
||||
@@ -110,7 +108,8 @@ export class GoogleCalendarEventOutputPipe
|
||||
|
||||
calendarEvent.status = this.transformEventStatus(googleEvent.status);
|
||||
|
||||
calendarEvent.hosts = this.transformHosts(googleEvent);
|
||||
calendarEvent.calendarEventOwner = googleEvent.organizer
|
||||
calendarEvent.hosts = this.transformHosts(googleEvent)
|
||||
|
||||
calendarEvent.source = "google";
|
||||
|
||||
@@ -196,6 +195,8 @@ export class GoogleCalendarEventOutputPipe
|
||||
regionCode: entryPoint.regionCode,
|
||||
};
|
||||
});
|
||||
} else if (googleEvent.location) {
|
||||
return [{ type: "video", url: googleEvent.location }];
|
||||
} else if (googleEvent.hangoutLink) {
|
||||
return [{ type: "video", url: googleEvent.hangoutLink }];
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
UpdateUnifiedCalendarEventInput,
|
||||
UpdateDateTimeWithZone,
|
||||
} from "../inputs/update-unified-calendar-event.input";
|
||||
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
|
||||
|
||||
// Common interfaces for Google Calendar types
|
||||
interface GoogleCalendarDateTime {
|
||||
dateTime: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
interface GoogleCalendarAttendee {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
responseStatus: string;
|
||||
organizer?: boolean;
|
||||
}
|
||||
|
||||
interface GoogleCalendarEventInputTransform {
|
||||
transform(
|
||||
updateData: UpdateUnifiedCalendarEventInput,
|
||||
fetchedEvent?: GoogleCalendarEventResponse | null
|
||||
): any;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarEventInputPipe implements GoogleCalendarEventInputTransform {
|
||||
transform(
|
||||
updateData: UpdateUnifiedCalendarEventInput,
|
||||
): any {
|
||||
const updatePayload: any = {};
|
||||
|
||||
if (updateData.title !== undefined) {
|
||||
updatePayload.summary = updateData.title;
|
||||
}
|
||||
|
||||
if (updateData.description !== undefined) {
|
||||
updatePayload.description = updateData.description;
|
||||
}
|
||||
|
||||
if (updateData.start) {
|
||||
updatePayload.start = this.transformDateTimeWithZone(updateData.start);
|
||||
}
|
||||
|
||||
if (updateData.end) {
|
||||
updatePayload.end = this.transformDateTimeWithZone(updateData.end);
|
||||
}
|
||||
|
||||
if (updateData.attendees !== undefined) {
|
||||
updatePayload.attendees = this.transformAttendees(
|
||||
updateData.attendees,
|
||||
);
|
||||
}
|
||||
|
||||
if (updateData.status !== undefined) {
|
||||
updatePayload.status = this.transformEventStatus(updateData.status);
|
||||
}
|
||||
|
||||
return updatePayload;
|
||||
}
|
||||
|
||||
private transformDateTimeWithZone(dateTime: UpdateDateTimeWithZone): GoogleCalendarDateTime {
|
||||
return {
|
||||
dateTime: dateTime.time || "",
|
||||
timeZone: dateTime.timeZone || "",
|
||||
};
|
||||
}
|
||||
|
||||
private transformAttendees(
|
||||
inputAttendees: NonNullable<UpdateUnifiedCalendarEventInput["attendees"]>,
|
||||
): GoogleCalendarAttendee[] {
|
||||
return inputAttendees.map((attendee) => {
|
||||
return {
|
||||
email: attendee.email,
|
||||
displayName: attendee.name,
|
||||
responseStatus: this.transformResponseStatus(attendee.responseStatus),
|
||||
organizer: attendee.host,
|
||||
self: attendee.self,
|
||||
optional: attendee.optional,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private transformResponseStatus(responseStatus?: CalendarEventResponseStatus | null): string {
|
||||
if (!responseStatus) return "needsAction";
|
||||
|
||||
switch (responseStatus) {
|
||||
case CalendarEventResponseStatus.ACCEPTED:
|
||||
return "accepted";
|
||||
case CalendarEventResponseStatus.PENDING:
|
||||
return "tentative";
|
||||
case CalendarEventResponseStatus.DECLINED:
|
||||
return "declined";
|
||||
case CalendarEventResponseStatus.NEEDS_ACTION:
|
||||
return "needsAction";
|
||||
default:
|
||||
return "needsAction";
|
||||
}
|
||||
}
|
||||
|
||||
private transformEventStatus(status?: CalendarEventStatus | null): string {
|
||||
if (!status) return "confirmed";
|
||||
|
||||
switch (status) {
|
||||
case CalendarEventStatus.ACCEPTED:
|
||||
return "confirmed";
|
||||
case CalendarEventStatus.PENDING:
|
||||
return "tentative";
|
||||
case CalendarEventStatus.CANCELLED:
|
||||
return "cancelled";
|
||||
case CalendarEventStatus.DECLINED:
|
||||
return "cancelled";
|
||||
default:
|
||||
return "confirmed";
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
UpdateUnifiedCalendarEventInput,
|
||||
UpdateDateTimeWithZone,
|
||||
} from "../inputs/update-unified-calendar-event.input";
|
||||
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
|
||||
import { GoogleCalendarEventResponse } from "./get-calendar-event-details-output-pipe";
|
||||
import { GoogleCalendarEventInputPipe } from "./google-calendar-event-input-pipe";
|
||||
|
||||
describe("GoogleCalendarEventInputPipe", () => {
|
||||
let pipe: GoogleCalendarEventInputPipe;
|
||||
let sharedGoogleEvent: GoogleCalendarEventResponse;
|
||||
|
||||
beforeAll(() => {
|
||||
sharedGoogleEvent = {
|
||||
kind: "calendar#event",
|
||||
etag: "test-etag",
|
||||
id: "test-event-id",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event",
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-01-01T00:00:00Z",
|
||||
summary: "Test Meeting",
|
||||
description: "Test description",
|
||||
start: {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
dateTime: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
iCalUID: "test-ical-uid",
|
||||
sequence: 0,
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee1@example.com",
|
||||
displayName: "Attendee One",
|
||||
responseStatus: "accepted",
|
||||
organizer: false,
|
||||
},
|
||||
{
|
||||
email: "attendee2@example.com",
|
||||
displayName: "Attendee Two",
|
||||
responseStatus: "needsAction",
|
||||
organizer: false,
|
||||
},
|
||||
{
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer",
|
||||
responseStatus: "accepted",
|
||||
organizer: true,
|
||||
},
|
||||
],
|
||||
creator: {
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer",
|
||||
},
|
||||
organizer: {
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pipe = new GoogleCalendarEventInputPipe();
|
||||
});
|
||||
|
||||
describe("transform", () => {
|
||||
it("should transform basic event fields", () => {
|
||||
const input: UpdateUnifiedCalendarEventInput = {
|
||||
title: "Updated Meeting",
|
||||
description: "Updated description",
|
||||
start: {
|
||||
time: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
time: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
status: CalendarEventStatus.ACCEPTED,
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
summary: "Updated Meeting",
|
||||
description: "Updated description",
|
||||
start: {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
dateTime: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
status: "confirmed",
|
||||
};
|
||||
|
||||
const result = pipe.transform(input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle partial updates", () => {
|
||||
const input: UpdateUnifiedCalendarEventInput = {
|
||||
title: "Only Title Update",
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
summary: "Only Title Update",
|
||||
};
|
||||
|
||||
const result = pipe.transform(input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle null description", () => {
|
||||
const input: UpdateUnifiedCalendarEventInput = {
|
||||
description: null,
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
description: null,
|
||||
};
|
||||
|
||||
const result = pipe.transform(input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformDateTimeWithZone", () => {
|
||||
it("should transform date time with timezone", () => {
|
||||
const input: UpdateDateTimeWithZone = {
|
||||
time: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
const result = pipe["transformDateTimeWithZone"](input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle empty time and timezone", () => {
|
||||
const input: UpdateDateTimeWithZone = {};
|
||||
|
||||
const expectedOutput = {
|
||||
dateTime: "",
|
||||
timeZone: "",
|
||||
};
|
||||
|
||||
const result = pipe["transformDateTimeWithZone"](input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformResponseStatus", () => {
|
||||
it("should transform ACCEPTED status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.ACCEPTED);
|
||||
expect(result).toBe("accepted");
|
||||
});
|
||||
|
||||
it("should transform PENDING status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.PENDING);
|
||||
expect(result).toBe("tentative");
|
||||
});
|
||||
|
||||
it("should transform DECLINED status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.DECLINED);
|
||||
expect(result).toBe("declined");
|
||||
});
|
||||
|
||||
it("should transform NEEDS_ACTION status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.NEEDS_ACTION);
|
||||
expect(result).toBe("needsAction");
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformResponseStatus"](null);
|
||||
expect(result).toBe("needsAction");
|
||||
});
|
||||
|
||||
it("should handle undefined status", () => {
|
||||
const result = pipe["transformResponseStatus"](undefined);
|
||||
expect(result).toBe("needsAction");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformEventStatus", () => {
|
||||
it("should transform ACCEPTED status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.ACCEPTED);
|
||||
expect(result).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("should transform PENDING status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.PENDING);
|
||||
expect(result).toBe("tentative");
|
||||
});
|
||||
|
||||
it("should transform CANCELLED status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.CANCELLED);
|
||||
expect(result).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("should transform DECLINED status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.DECLINED);
|
||||
expect(result).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformEventStatus"](null);
|
||||
expect(result).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("should handle undefined status", () => {
|
||||
const result = pipe["transformEventStatus"](undefined);
|
||||
expect(result).toBe("confirmed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,8 @@ import { JWT } from "googleapis-common";
|
||||
|
||||
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
import { UnifiedCalendarEventOutput } from "../outputs/get-unified-calendar-event";
|
||||
import { UpdateUnifiedCalendarEventInput } from "../inputs/update-unified-calendar-event.input";
|
||||
import { GoogleCalendarEventInputPipe } from "../pipes/google-calendar-event-input-pipe";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService {
|
||||
@@ -53,6 +54,44 @@ export class GoogleCalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateEventDetails(
|
||||
eventUid: string,
|
||||
updateData: UpdateUnifiedCalendarEventInput
|
||||
): Promise<GoogleCalendarEventResponse> {
|
||||
const bookingReference =
|
||||
await this.bookingReferencesRepository.getBookingReferencesIncludeSensitiveCredentials(eventUid);
|
||||
|
||||
if (!bookingReference) {
|
||||
throw new NotFoundException("Booking reference not found");
|
||||
}
|
||||
|
||||
const ownerUserEmail = bookingReference?.booking?.user?.email;
|
||||
|
||||
const calendar = await this.getAuthorizedCalendarInstance(
|
||||
ownerUserEmail,
|
||||
bookingReference.credential?.key,
|
||||
bookingReference.delegationCredential
|
||||
);
|
||||
|
||||
|
||||
const updatePayload = new GoogleCalendarEventInputPipe().transform(updateData);
|
||||
|
||||
try {
|
||||
const event = await calendar.events.patch({
|
||||
calendarId: bookingReference?.externalCalendarId ?? "primary",
|
||||
eventId: bookingReference?.uid,
|
||||
requestBody: updatePayload,
|
||||
});
|
||||
|
||||
if (!event.data) {
|
||||
throw new NotFoundException("Failed to update meeting");
|
||||
}
|
||||
return event.data as GoogleCalendarEventResponse;
|
||||
} catch (error) {
|
||||
throw new NotFoundException("Failed to update meeting details");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an authorized Google Calendar instance
|
||||
* Tries delegation credentials first, falls back to direct OAuth
|
||||
|
||||
@@ -40,5 +40,3 @@ export class EventTypeOwnershipGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8563,6 +8563,69 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/events/{eventUid}": {
|
||||
"patch": {
|
||||
"operationId": "CalUnifiedCalendarsController_updateCalendarEvent",
|
||||
"summary": "Update meeting details in calendar",
|
||||
"description": "Updates event information in the specified calendar provider",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "calendar",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"google"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateUnifiedCalendarEventInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GetUnifiedCalendarEventOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Cal Unified Calendars"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/ics-feed/save": {
|
||||
"post": {
|
||||
"operationId": "CalendarsController_createIcsFeed",
|
||||
@@ -28782,7 +28845,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Host's response to the invitation",
|
||||
"description": "Response status of the attendee",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"pending",
|
||||
@@ -28815,6 +28878,11 @@
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee's attendance is optional"
|
||||
},
|
||||
"host": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee is the host"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -28853,6 +28921,23 @@
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"calendarEventOwner": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email address of the event host"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"CalendarSource": {
|
||||
"type": "string",
|
||||
"description": "Calendar integration source (e.g., Google Calendar, Office 365, Apple Calendar). Currently only Google Calendar is supported.",
|
||||
@@ -28949,6 +29034,15 @@
|
||||
"$ref": "#/components/schemas/CalendarEventHost"
|
||||
}
|
||||
},
|
||||
"calendarEventOwner": {
|
||||
"nullable": true,
|
||||
"description": "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/calendarEventOwner"
|
||||
}
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"example": "google",
|
||||
"$ref": "#/components/schemas/CalendarSource"
|
||||
@@ -28982,6 +29076,91 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"UpdateCalendarEventAttendee": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email address of the attendee"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name of the attendee"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
},
|
||||
"self": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee is the current user"
|
||||
},
|
||||
"optional": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee's attendance is optional"
|
||||
},
|
||||
"host": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee is the host"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateUnifiedCalendarEventInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Start date and time of the calendar event with timezone information"
|
||||
},
|
||||
"end": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "End date and time of the calendar event with timezone information"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Title of the calendar event"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Detailed description of the calendar event"
|
||||
},
|
||||
"attendees": {
|
||||
"nullable": true,
|
||||
"description": "List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UpdateCalendarEventAttendee"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RequestEmailVerificationInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -16,7 +16,7 @@ import slugify from "@calcom/lib/slugify";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyCtx, buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
import CachedClientView, { type TeamBookingPageProps } from "~/team/type-view-cached";
|
||||
|
||||
@@ -124,19 +124,17 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
|
||||
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
// Handle org redirects for non-org contexts
|
||||
if (!isOrgContext) {
|
||||
const redirectResult = await getTemporaryOrgRedirect({
|
||||
slugs: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: legacyCtx.query,
|
||||
});
|
||||
if (redirectResult) return redirect(redirectResult.redirect.destination);
|
||||
}
|
||||
// Handle org redirects
|
||||
const redirectResult = await handleOrgRedirect({
|
||||
slugs: [teamSlug],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
context: legacyCtx,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
if (redirectResult) return redirect(redirectResult.redirect.destination);
|
||||
|
||||
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
@@ -7,6 +8,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -32,15 +34,20 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
}
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const user = await userRepo.findUserTeams({
|
||||
id: session.user.id,
|
||||
});
|
||||
const meCaller = await createRouterCaller(meRouter);
|
||||
|
||||
if (!user) {
|
||||
const [userTeams, user] = await Promise.all([
|
||||
userRepo.findUserTeams({
|
||||
id: session.user.id,
|
||||
}),
|
||||
meCaller.get(),
|
||||
]);
|
||||
|
||||
if (!userTeams || !user) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
return <Page hasPendingInvites={!!user.teams.find((team) => team.accepted === false)} />;
|
||||
return <Page user={user} hasPendingInvites={!!userTeams.teams.find((team) => team.accepted === false)} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { List } from "@calcom/ui/components/list";
|
||||
@@ -12,10 +16,18 @@ import { StepConnectionLoader } from "../components/StepConnectionLoader";
|
||||
interface ConnectedAppStepProps {
|
||||
nextStep: () => void;
|
||||
isPageLoading: boolean;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}
|
||||
|
||||
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
const { nextStep, isPageLoading } = props;
|
||||
const ConnectedVideoStepInner = ({
|
||||
t,
|
||||
setAnyInstalledVideoApps,
|
||||
user,
|
||||
}: {
|
||||
t: TFunction;
|
||||
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}) => {
|
||||
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: false,
|
||||
@@ -30,47 +42,59 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
sortByMostPopular: true,
|
||||
sortByInstalledFirst: true,
|
||||
});
|
||||
const { data } = useMeQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
const metadata = userMetadata.parse(data?.metadata);
|
||||
|
||||
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||
(item) => item.userCredentialIds.length > 0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAnyInstalledVideoApps(Boolean(hasAnyInstalledVideoApps));
|
||||
}, [hasAnyInstalledVideoApps, setAnyInstalledVideoApps]);
|
||||
|
||||
if (isPending) {
|
||||
return <StepConnectionLoader />;
|
||||
}
|
||||
|
||||
const result = userMetadata.safeParse(user.metadata);
|
||||
if (!result.success) {
|
||||
return <StepConnectionLoader />;
|
||||
}
|
||||
const { data: metadata } = result;
|
||||
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
|
||||
return (
|
||||
<>
|
||||
{!isPending && (
|
||||
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
|
||||
{queryConnectedVideoApps?.items &&
|
||||
queryConnectedVideoApps?.items.map((item) => {
|
||||
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
|
||||
return (
|
||||
<li key={item.name}>
|
||||
{item.name && item.logo && (
|
||||
<AppConnectionItem
|
||||
type={item.type}
|
||||
title={item.name}
|
||||
isDefault={item.slug === defaultConferencingApp}
|
||||
description={item.description}
|
||||
dependencyData={item.dependencyData}
|
||||
logo={item.logo}
|
||||
slug={item.slug}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
defaultInstall={
|
||||
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
|
||||
{queryConnectedVideoApps?.items &&
|
||||
queryConnectedVideoApps?.items.map((item) => {
|
||||
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
|
||||
return (
|
||||
<li key={item.name}>
|
||||
{item.name && item.logo && (
|
||||
<AppConnectionItem
|
||||
type={item.type}
|
||||
title={item.name}
|
||||
isDefault={item.slug === defaultConferencingApp}
|
||||
description={item.description}
|
||||
dependencyData={item.dependencyData}
|
||||
logo={item.logo}
|
||||
slug={item.slug}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
defaultInstall={!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
{isPending && <StepConnectionLoader />}
|
||||
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
const { nextStep, isPageLoading, user } = props;
|
||||
const { t } = useLocale();
|
||||
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} t={t} user={user} />
|
||||
<Button
|
||||
EndIcon="arrow-right"
|
||||
data-testid="save-video-button"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { UserAvatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
@@ -21,8 +22,11 @@ type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
|
||||
const UserProfile = () => {
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
interface UserProfileProps {
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}
|
||||
|
||||
const UserProfile = ({ user }: UserProfileProps) => {
|
||||
const { t } = useLocale();
|
||||
const avatarRef = useRef<HTMLInputElement>(null);
|
||||
const { setValue, handleSubmit, getValues } = useForm<FormData>({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Input } from "@calcom/ui/components/form";
|
||||
|
||||
@@ -21,11 +22,11 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
interface IUserSettingsProps {
|
||||
nextStep: () => void;
|
||||
hideUsername?: boolean;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}
|
||||
|
||||
const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { nextStep } = props;
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
const { nextStep, user } = props;
|
||||
const { t } = useLocale();
|
||||
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
@@ -15,7 +15,7 @@ import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/enums";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { getRedirectWithOriginAndSearchString } from "@lib/handleOrgRedirect";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
@@ -64,17 +64,26 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
return notFound;
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: [username],
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
// Get just the origin and searchString from the redirect to ensure that we don't redirect to a URL that exposes the real path to book the user for any other events \
|
||||
// This is important for a private booking link
|
||||
// e.g. http://app.cal.com/d/sgdthj8mu4nsLNTYi3fW2p/demo -> should redirect to -> http://acme.cal.com/d/sgdthj8mu4nsLNTYi3fW2p/demo and not to http://acme.cal.com/john/demo(which exposes the real path to book the user for any other events)
|
||||
const redirectWithOriginAndSearchString = await getRedirectWithOriginAndSearchString({
|
||||
slugs: [username],
|
||||
redirectType: RedirectType.User,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
if (redirectWithOriginAndSearchString) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
// App Router doesn't have access to the current path directly, so we build it manually
|
||||
destination: `${redirectWithOriginAndSearchString.origin ?? ""}/d/${link}/${slug}${
|
||||
redirectWithOriginAndSearchString.searchString
|
||||
}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
name = profileUsername || username;
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "./getTemporaryOrgRedirect";
|
||||
|
||||
const mockData = {
|
||||
redirects: [] as {
|
||||
toUrl: string;
|
||||
from: string;
|
||||
redirectType: RedirectType;
|
||||
}[],
|
||||
};
|
||||
|
||||
function mockARedirectInDB({
|
||||
toUrl,
|
||||
slug,
|
||||
redirectType,
|
||||
}: {
|
||||
toUrl: string;
|
||||
slug: string;
|
||||
redirectType: RedirectType;
|
||||
}) {
|
||||
mockData.redirects.push({
|
||||
toUrl,
|
||||
from: slug,
|
||||
redirectType,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
prismaMock.tempOrgRedirect.findMany.mockImplementation(({ where }) => {
|
||||
return new Promise((resolve) => {
|
||||
const tempOrgRedirects: typeof mockData.redirects = [];
|
||||
where.from.in.forEach((whereSlug: string) => {
|
||||
const matchingRedirect = mockData.redirects.find((redirect) => {
|
||||
return where.type === redirect.redirectType && whereSlug === redirect.from && where.fromOrgId === 0;
|
||||
});
|
||||
if (matchingRedirect) {
|
||||
tempOrgRedirects.push(matchingRedirect);
|
||||
}
|
||||
});
|
||||
resolve(tempOrgRedirects);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockData.redirects = [];
|
||||
});
|
||||
|
||||
describe("getTemporaryOrgRedirect", () => {
|
||||
it("should generate event-type URL without existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate event-type URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate User URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Profile URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL without query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking Profile Url", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking Profile Url - same order", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
|
||||
const redirect1 = await getTemporaryOrgRedirect({
|
||||
slugs: ["second", "first"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect1).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/second-in-org1+first-in-org1?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking Profile Url with query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking EventType Url", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Dynamic Group Booking EventType Url with query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "first",
|
||||
toUrl: "https://calcom.cal.com/first-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
mockARedirectInDB({
|
||||
slug: "second",
|
||||
toUrl: "https://calcom.cal.com/second-in-org1",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: ["first", "second"],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?abc=1&orgRedirection=true",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
|
||||
export const getTemporaryOrgRedirect = async ({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
}: {
|
||||
slugs: string[] | string;
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
}) => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
slugs = slugs instanceof Array ? slugs : [slugs];
|
||||
log.debug(
|
||||
`Looking for redirect for`,
|
||||
safeStringify({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
})
|
||||
);
|
||||
|
||||
const redirects = await prisma.tempOrgRedirect.findMany({
|
||||
where: {
|
||||
type: redirectType,
|
||||
from: {
|
||||
in: slugs,
|
||||
},
|
||||
fromOrgId: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const currentQueryString = stringify(currentQuery);
|
||||
if (!redirects.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group
|
||||
const newOrigin = new URL(redirects[0].toUrl).origin;
|
||||
const query = currentQueryString ? `?${currentQueryString}&orgRedirection=true` : "?orgRedirection=true";
|
||||
// Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things
|
||||
const newSlugs = slugs.map((slug) => {
|
||||
const redirect = redirects.find((redirect) => redirect.from === slug);
|
||||
if (!redirect) {
|
||||
return slug;
|
||||
}
|
||||
const newSlug = new URL(redirect.toUrl).pathname.slice(1);
|
||||
return newSlug;
|
||||
});
|
||||
|
||||
const newSlug = newSlugs.join("+");
|
||||
const newPath = newSlug ? `/${newSlug}` : "";
|
||||
|
||||
const newDestination = `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`;
|
||||
log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`);
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: newDestination,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
@@ -0,0 +1,704 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { handleOrgRedirect, getRedirectWithOriginAndSearchString } from "./handleOrgRedirect";
|
||||
|
||||
// Mock prisma for all tests
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
tempOrgRedirect: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: prismaMock,
|
||||
}));
|
||||
|
||||
const createTestContext = (overrides?: {
|
||||
host?: string;
|
||||
query?: ParsedUrlQuery;
|
||||
}): GetServerSidePropsContext =>
|
||||
({
|
||||
req: {
|
||||
headers: {
|
||||
host: overrides?.host || "cal.local:3000",
|
||||
},
|
||||
},
|
||||
query: overrides?.query || {},
|
||||
} as unknown as GetServerSidePropsContext);
|
||||
|
||||
const createTestRedirectParams = (overrides?: {
|
||||
slugs?: string[];
|
||||
redirectType?: RedirectType;
|
||||
eventTypeSlug?: string | null;
|
||||
context?: GetServerSidePropsContext;
|
||||
currentOrgDomain?: string | null;
|
||||
}) => ({
|
||||
slugs: overrides?.slugs || ["pro"],
|
||||
redirectType: overrides?.redirectType || RedirectType.User,
|
||||
eventTypeSlug: overrides?.eventTypeSlug ?? null,
|
||||
context: overrides?.context || createTestContext(),
|
||||
currentOrgDomain: overrides?.currentOrgDomain ?? null,
|
||||
});
|
||||
|
||||
interface RedirectScenario {
|
||||
redirects: Array<{
|
||||
from: string;
|
||||
toUrl: string;
|
||||
type?: RedirectType;
|
||||
}>;
|
||||
}
|
||||
|
||||
const createRedirectScenario = (scenario: RedirectScenario) => {
|
||||
// Create a map for quick lookup based on input parameters
|
||||
const redirectMap = new Map<string, (typeof scenario.redirects)[0]>();
|
||||
|
||||
scenario.redirects.forEach((redirect) => {
|
||||
const key = `${redirect.type || RedirectType.User}-${redirect.from}`;
|
||||
redirectMap.set(key, redirect);
|
||||
});
|
||||
|
||||
// Return a mock implementation that returns data based on the actual query
|
||||
prismaMock.tempOrgRedirect.findMany.mockImplementation(async (params) => {
|
||||
const { type, from } = params?.where || {};
|
||||
const slugsToFind = from?.in || [];
|
||||
|
||||
// Return redirects that match the requested type and slugs
|
||||
const matchingRedirects = slugsToFind
|
||||
.map((slug: string) => {
|
||||
const key = `${type}-${slug}`;
|
||||
const redirect = redirectMap.get(key);
|
||||
if (redirect) {
|
||||
return {
|
||||
from: redirect.from,
|
||||
toUrl: redirect.toUrl,
|
||||
type: redirect.type || type,
|
||||
fromOrgId: 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return matchingRedirects;
|
||||
});
|
||||
};
|
||||
|
||||
const expectRedirectTo = (result: any, expectedDestination: string) => {
|
||||
expect(result).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: expectedDestination,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const expectNoRedirect = (result: any) => {
|
||||
expect(result).toBeNull();
|
||||
};
|
||||
|
||||
const expectPrismaCalledWith = (expectedParams: { type: RedirectType; slugs: string[] }) => {
|
||||
expect(prismaMock.tempOrgRedirect.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
type: expectedParams.type,
|
||||
from: {
|
||||
in: expectedParams.slugs,
|
||||
},
|
||||
fromOrgId: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const expectPrismaNotCalled = () => {
|
||||
expect(prismaMock.tempOrgRedirect.findMany).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
// Verify that the redirect was actually built from the data returned by Prisma
|
||||
const expectRedirectUsesData = (
|
||||
result: any,
|
||||
expectedSlug: string,
|
||||
expectedOrigin = "https://acme.cal.local:3000"
|
||||
) => {
|
||||
if (!result || !result.redirect) {
|
||||
throw new Error("Expected a redirect result but got null/undefined");
|
||||
}
|
||||
const url = new URL(result.redirect.destination);
|
||||
expect(url.origin).toBe(expectedOrigin);
|
||||
expect(url.pathname).toContain(expectedSlug);
|
||||
};
|
||||
|
||||
describe("handleOrgRedirect", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to no redirects - tests will override with specific scenarios
|
||||
createRedirectScenario({ redirects: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("when not in organization context", () => {
|
||||
it("should redirect to organization URL when redirect exists", async () => {
|
||||
// Setup scenario with unique test data to verify correct usage
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
// Add a decoy redirect to ensure the code picks the right one
|
||||
{
|
||||
from: "other-user",
|
||||
toUrl: "https://acme.cal.local:3000/wrong-redirect",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["pro"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "pro-example");
|
||||
});
|
||||
|
||||
it("should return null when no redirect exists", async () => {
|
||||
// Explicitly set up no redirects scenario
|
||||
createRedirectScenario({ redirects: [] });
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["pro"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
|
||||
expectNoRedirect(result);
|
||||
});
|
||||
|
||||
it("should handle Team redirect type", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "sales-team",
|
||||
toUrl: "https://acme.cal.local:3000/sales",
|
||||
type: RedirectType.Team,
|
||||
},
|
||||
// Add a User redirect with same slug to verify type filtering
|
||||
{
|
||||
from: "sales-team",
|
||||
toUrl: "https://acme.cal.local:3000/wrong-user-redirect",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["sales-team"],
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.Team, slugs: ["sales-team"] });
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/sales?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "sales");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when SINGLE_ORG_SLUG mode is enabled", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue("acme");
|
||||
});
|
||||
|
||||
it("should redirect using relative path when accessing from base domain", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({ host: "cal.local:3000" }),
|
||||
currentOrgDomain: "acme",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
|
||||
// Should use relative path to stay on same domain
|
||||
expectRedirectTo(result, "/pro-example?orgRedirection=true");
|
||||
});
|
||||
|
||||
it("should redirect john87 to john using relative path in single org mode", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "john87",
|
||||
toUrl: "https://acme.cal.local:3000/john",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["john87"],
|
||||
context: createTestContext({
|
||||
host: "my-instance.com",
|
||||
}),
|
||||
currentOrgDomain: "acme",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["john87"] });
|
||||
// Should redirect to relative path /john to stay on my-instance.com
|
||||
expectRedirectTo(result, "/john?orgRedirection=true");
|
||||
});
|
||||
|
||||
describe("when orgRedirection parameter is present", () => {
|
||||
it("should not redirect to prevent loops", async () => {
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({ query: { orgRedirection: "true" } }),
|
||||
currentOrgDomain: "acme",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaNotCalled();
|
||||
expectNoRedirect(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when event type slug is provided", () => {
|
||||
it("should include event type slug in redirect destination", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
eventTypeSlug: "30min",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example/30min?orgRedirection=true");
|
||||
// Verify the event type slug was properly appended
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/pro-example/30min");
|
||||
});
|
||||
|
||||
it("should handle empty event type slug", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
eventTypeSlug: null,
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "pro-example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when handling multiple slugs (group bookings)", () => {
|
||||
it("should preserve order of slugs in redirect", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "user1",
|
||||
toUrl: "https://acme.cal.local:3000/org-user1",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
{
|
||||
from: "user2",
|
||||
toUrl: "https://acme.cal.local:3000/org-user2",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
{
|
||||
from: "user3",
|
||||
toUrl: "https://acme.cal.local:3000/org-user3",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["user1", "user2", "user3"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(
|
||||
result,
|
||||
"https://acme.cal.local:3000/org-user1+org-user2+org-user3?orgRedirection=true"
|
||||
);
|
||||
// Verify order is preserved from input slugs
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/org-user1+org-user2+org-user3");
|
||||
});
|
||||
|
||||
it("should handle partial redirects in group bookings", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "user1",
|
||||
toUrl: "https://acme.cal.local:3000/org-user1",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
// user2 has no redirect - it should stay as-is
|
||||
{
|
||||
from: "user3",
|
||||
toUrl: "https://acme.cal.local:3000/org-user3",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["user1", "user2", "user3"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/org-user1+user2+org-user3?orgRedirection=true");
|
||||
// Verify that user2 remains unchanged while others are redirected
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/org-user1+user2+org-user3");
|
||||
});
|
||||
|
||||
it("should handle different origins in redirects", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "user1",
|
||||
toUrl: "https://org1.cal.com/john",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
{
|
||||
from: "user2",
|
||||
toUrl: "https://org1.cal.com/jane",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["user1", "user2"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
// Should use the first redirect's origin
|
||||
expectRedirectTo(result, "https://org1.cal.com/john+jane?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "john+jane", "https://org1.cal.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when handling query parameters", () => {
|
||||
it("should add orgRedirection=true to empty query", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({ query: {} }),
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "pro-example");
|
||||
});
|
||||
|
||||
it("should preserve existing query parameters", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({
|
||||
query: { abc: "1", xyz: "test" },
|
||||
}),
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?abc=1&xyz=test&orgRedirection=true");
|
||||
// Verify query parameters are preserved
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.searchParams.get("abc")).toBe("1");
|
||||
expect(url.searchParams.get("xyz")).toBe("test");
|
||||
});
|
||||
|
||||
it("should not duplicate orgRedirection parameter", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({
|
||||
query: { orgRedirection: "true", abc: "1" },
|
||||
}),
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?abc=1&orgRedirection=true");
|
||||
|
||||
// Verify only one orgRedirection parameter exists
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
const orgRedirectionParams = url.searchParams.getAll("orgRedirection");
|
||||
expect(orgRedirectionParams).toHaveLength(1);
|
||||
expect(orgRedirectionParams[0]).toBe("true");
|
||||
});
|
||||
|
||||
it("should handle array query parameters", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
context: createTestContext({
|
||||
query: { tags: ["tag1", "tag2"], filter: "active" },
|
||||
}),
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.searchParams.getAll("tags")).toEqual(["tag1", "tag2"]);
|
||||
expect(url.searchParams.get("filter")).toBe("active");
|
||||
expect(url.searchParams.get("orgRedirection")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases and error scenarios", () => {
|
||||
it("should handle empty slugs array", async () => {
|
||||
createRedirectScenario({ redirects: [] });
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: [],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaCalledWith({ type: RedirectType.User, slugs: [] });
|
||||
expectNoRedirect(result);
|
||||
});
|
||||
|
||||
it("should handle redirects with trailing slashes", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000/pro-example/",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams();
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example/?orgRedirection=true");
|
||||
// Verify trailing slash is preserved
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/pro-example/");
|
||||
});
|
||||
|
||||
it("should handle redirects to root path", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "pro",
|
||||
toUrl: "https://acme.cal.local:3000",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams();
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000?orgRedirection=true");
|
||||
// Verify root path redirect
|
||||
const url = new URL(result?.redirect.destination || "");
|
||||
expect(url.pathname).toBe("/");
|
||||
});
|
||||
|
||||
it("should handle special characters in slugs", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "user-name.test",
|
||||
toUrl: "https://acme.cal.local:3000/user_name_test",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = createTestRedirectParams({
|
||||
slugs: ["user-name.test"],
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectRedirectTo(result, "https://acme.cal.local:3000/user_name_test?orgRedirection=true");
|
||||
expectRedirectUsesData(result, "user_name_test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when SINGLE_ORG_SLUG is not set", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("should not check for redirects when in org context", async () => {
|
||||
const params = createTestRedirectParams({
|
||||
currentOrgDomain: "acme",
|
||||
});
|
||||
const result = await handleOrgRedirect(params);
|
||||
|
||||
expectPrismaNotCalled();
|
||||
expectNoRedirect(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRedirectWithOriginAndSearchString", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
prismaMock.tempOrgRedirect.findMany.mockReset();
|
||||
});
|
||||
|
||||
it("should return origin and search string for absolute URL redirects", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "john",
|
||||
toUrl: "https://acme.cal.local:3000/john-org",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = {
|
||||
slugs: ["john"],
|
||||
redirectType: RedirectType.User,
|
||||
context: createTestContext(),
|
||||
currentOrgDomain: null,
|
||||
};
|
||||
const result = await getRedirectWithOriginAndSearchString(params);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.origin).toBe("https://acme.cal.local:3000");
|
||||
expect(result?.searchString).toBe("?orgRedirection=true");
|
||||
});
|
||||
|
||||
it("should return null origin for relative path redirects", async () => {
|
||||
// Enable SINGLE_ORG_SLUG for this test to get relative paths
|
||||
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue("acme");
|
||||
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "john",
|
||||
toUrl: "https://acme.cal.local:3000/john-org",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = {
|
||||
slugs: ["john"],
|
||||
redirectType: RedirectType.User,
|
||||
context: createTestContext({ host: "my-instance.com" }),
|
||||
currentOrgDomain: "acme",
|
||||
};
|
||||
const result = await getRedirectWithOriginAndSearchString(params);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.origin).toBeNull(); // Relative path has no origin
|
||||
expect(result?.searchString).toBe("?orgRedirection=true");
|
||||
});
|
||||
|
||||
it("should preserve existing query parameters in search string", async () => {
|
||||
createRedirectScenario({
|
||||
redirects: [
|
||||
{
|
||||
from: "john",
|
||||
toUrl: "https://acme.cal.local:3000/john-org",
|
||||
type: RedirectType.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params = {
|
||||
slugs: ["john"],
|
||||
redirectType: RedirectType.User,
|
||||
context: createTestContext({ query: { foo: "bar", baz: "qux" } }),
|
||||
currentOrgDomain: null,
|
||||
};
|
||||
const result = await getRedirectWithOriginAndSearchString(params);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.origin).toBe("https://acme.cal.local:3000");
|
||||
expect(result?.searchString).toBe("?foo=bar&baz=qux&orgRedirection=true");
|
||||
});
|
||||
|
||||
it("should return null when no redirect exists", async () => {
|
||||
createRedirectScenario({ redirects: [] });
|
||||
|
||||
const params = {
|
||||
slugs: ["john"],
|
||||
redirectType: RedirectType.User,
|
||||
context: createTestContext(),
|
||||
currentOrgDomain: null,
|
||||
};
|
||||
const result = await getRedirectWithOriginAndSearchString(params);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { SINGLE_ORG_SLUG } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["lib", "handleOrgRedirect"] });
|
||||
type NextJsRedirect = {
|
||||
redirect: {
|
||||
permanent: false;
|
||||
/**
|
||||
* It could be a full URL or a relative path
|
||||
*/
|
||||
destination: string;
|
||||
};
|
||||
};
|
||||
|
||||
function getSearchString(relativeOrAbsoluteUrl: string) {
|
||||
const url = new URL(relativeOrAbsoluteUrl, "http://localhost");
|
||||
return url.search;
|
||||
}
|
||||
|
||||
const getTemporaryOrgRedirect = async ({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
useRelativePath = false,
|
||||
}: {
|
||||
slugs: string[];
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
useRelativePath?: boolean;
|
||||
}): Promise<NextJsRedirect | null> => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
log.debug(
|
||||
`Looking for redirect for`,
|
||||
safeStringify({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
})
|
||||
);
|
||||
|
||||
const redirects = await prisma.tempOrgRedirect.findMany({
|
||||
where: {
|
||||
type: redirectType,
|
||||
from: {
|
||||
in: slugs,
|
||||
},
|
||||
fromOrgId: 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (!redirects.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group
|
||||
const newOrigin = new URL(redirects[0].toUrl).origin;
|
||||
|
||||
// Filter out any existing orgRedirection parameter to avoid duplicates
|
||||
// querystring.stringify transforms undefined values to empty strings, so we need to filter those out as well initially
|
||||
const filteredQuery = Object.entries(currentQuery).reduce((acc, [key, value]) => {
|
||||
if (key !== "orgRedirection" && value !== undefined) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as ParsedUrlQuery);
|
||||
|
||||
const currentQueryString = stringify(filteredQuery);
|
||||
const query = currentQueryString ? `?${currentQueryString}&orgRedirection=true` : "?orgRedirection=true";
|
||||
// Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things
|
||||
const newSlugs = slugs.map((slug) => {
|
||||
const redirect = redirects.find((redirect) => redirect.from === slug);
|
||||
if (!redirect) {
|
||||
return slug;
|
||||
}
|
||||
const newSlug = new URL(redirect.toUrl).pathname.slice(1);
|
||||
return newSlug;
|
||||
});
|
||||
|
||||
const newSlug = newSlugs.join("+");
|
||||
const newPath = newSlug ? `/${newSlug}` : "";
|
||||
|
||||
// When in single org mode and not on actual org subdomain, use relative path to stay on same domain
|
||||
const newDestination = useRelativePath
|
||||
? `${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`
|
||||
: `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`;
|
||||
log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`);
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: newDestination,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
interface HandleOrgRedirectParams {
|
||||
slugs: string[];
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
context: {
|
||||
query: ParsedUrlQuery;
|
||||
};
|
||||
currentOrgDomain: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles organization redirects for both regular org context and SINGLE_ORG_SLUG mode
|
||||
* The redirect is required for all existing user links and team links to keep working when a user/team is moved to an organization
|
||||
* Example:
|
||||
* - User "john87" is added to organization "acme" and his username in the organization is "john". So, cal.com/john87 is redirected to cal.com/john
|
||||
* - Team "acme-sales" is added to organization "acme" and its slug in the organization is "sales". So, cal.com/acme-sales is redirected to cal.com/sales
|
||||
*
|
||||
* Returns a redirect object if a redirect is needed, null otherwise
|
||||
*/
|
||||
export async function handleOrgRedirect({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
context,
|
||||
currentOrgDomain,
|
||||
}: HandleOrgRedirectParams) {
|
||||
const isOrgContext = !!currentOrgDomain;
|
||||
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
|
||||
|
||||
// If we're not in an org context, the request could clearly be eligible for a redirect
|
||||
if (!isOrgContext) {
|
||||
const nextJsRedirect = await getTemporaryOrgRedirect({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (nextJsRedirect) {
|
||||
return nextJsRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
const isSingleOrgMode = SINGLE_ORG_SLUG && isOrgContext;
|
||||
|
||||
// When SINGLE_ORG_SLUG is set(which is possible in Self-Hosted instances), isOrgContext could be true even when the current domain is not an org subdomain
|
||||
// Example: my-instance.com could technically mean acme.my-instance.com where acme is the org slug
|
||||
// In such a case, the existing links which were on my-instance.com e.g. my-instance.com/john87 should be redirected to my-instance.com/john where john is the new username in Organization
|
||||
// This is why we need to follow redirect even when in Org Context
|
||||
if (!isSingleOrgMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If already redirected from a non-org link, we shouldn't redirect again. Protects against infinite redirects
|
||||
if (isARedirectFromNonOrgLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We're in single org mode but not on an actual org subdomain
|
||||
// Check if there's a redirect for this username
|
||||
// Use relative path since we want to stay on the same domain (my-instance.com)
|
||||
const nextJsRedirect = await getTemporaryOrgRedirect({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery: context.query,
|
||||
useRelativePath: true,
|
||||
});
|
||||
|
||||
return nextJsRedirect;
|
||||
}
|
||||
|
||||
export async function getRedirectWithOriginAndSearchString({
|
||||
slugs,
|
||||
redirectType,
|
||||
context,
|
||||
currentOrgDomain,
|
||||
}: Omit<HandleOrgRedirectParams, "eventTypeSlug">) {
|
||||
const nextJsRedirect = await handleOrgRedirect({
|
||||
slugs,
|
||||
redirectType,
|
||||
eventTypeSlug: null,
|
||||
context,
|
||||
currentOrgDomain,
|
||||
});
|
||||
if (!nextJsRedirect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newDestination = nextJsRedirect.redirect.destination;
|
||||
// If there is an origin use it, otherwise mark no new Origin and it remains the same domain
|
||||
const newOrigin =
|
||||
newDestination.startsWith("http://") || newDestination.startsWith("https://")
|
||||
? new URL(newDestination).origin
|
||||
: null;
|
||||
|
||||
return {
|
||||
origin: newOrigin,
|
||||
searchString: getSearchString(newDestination),
|
||||
};
|
||||
}
|
||||
@@ -23,15 +23,18 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
if (team) {
|
||||
return GSSTeamPage({
|
||||
...ctx,
|
||||
query: { slug: ctx.query.user, orgRedirection: ctx.query.orgRedirection },
|
||||
query: {
|
||||
slug: ctx.query.user,
|
||||
...(ctx.query.orgRedirection !== undefined && { orgRedirection: ctx.query.orgRedirection }),
|
||||
},
|
||||
});
|
||||
}
|
||||
return GSSUserPage({
|
||||
...ctx,
|
||||
query: {
|
||||
user: ctx.query.user,
|
||||
redirect: ctx.query.redirect,
|
||||
orgRedirection: ctx.query.orgRedirection,
|
||||
...(ctx.query.redirect !== undefined && { redirect: ctx.query.redirect }),
|
||||
...(ctx.query.orgRedirection !== undefined && { orgRedirection: ctx.query.orgRedirection }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { User } from "@calcom/prisma/client";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/client";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
@@ -33,19 +33,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
|
||||
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: [teamSlug],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Team, OrganizationSettings } from "@calcom/prisma/client";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
|
||||
|
||||
@@ -67,7 +67,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
context.req,
|
||||
context.params?.orgSlug ?? context.query?.orgSlug
|
||||
);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||
@@ -89,12 +88,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
isOrgView: isValidOrgDomain && isOrgProfile,
|
||||
});
|
||||
|
||||
if (!isOrgContext && slug) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: slug,
|
||||
if (slug) {
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: [slug],
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
||||
@@ -10,8 +10,7 @@ import { z } from "zod";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { StepCard } from "@calcom/ui/components/card";
|
||||
@@ -85,13 +84,14 @@ const stepRouteSchema = z.object({
|
||||
|
||||
type PageProps = {
|
||||
hasPendingInvites: boolean;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
};
|
||||
|
||||
const OnboardingPage = (props: PageProps) => {
|
||||
const pathname = usePathname();
|
||||
const params = useParamsWithFallback();
|
||||
|
||||
const user = props.user;
|
||||
const router = useRouter();
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
const { t } = useLocale();
|
||||
const [isNextStepLoading, startTransition] = useTransition();
|
||||
|
||||
@@ -162,20 +162,20 @@ const OnboardingPage = (props: PageProps) => {
|
||||
<StepCard>
|
||||
<Suspense fallback={<Icon name="loader" />}>
|
||||
{currentStep === "user-settings" && (
|
||||
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} />
|
||||
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} user={user} />
|
||||
)}
|
||||
{currentStep === "connected-calendar" && (
|
||||
<ConnectedCalendars nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
|
||||
)}
|
||||
|
||||
{currentStep === "connected-video" && (
|
||||
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
|
||||
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} user={user} />
|
||||
)}
|
||||
|
||||
{currentStep === "setup-availability" && (
|
||||
<SetupAvailability nextStep={goToNextStep} defaultScheduleId={user.defaultScheduleId} />
|
||||
)}
|
||||
{currentStep === "user-profile" && <UserProfile />}
|
||||
{currentStep === "user-profile" && <UserProfile user={user} />}
|
||||
</Suspense>
|
||||
</StepCard>
|
||||
|
||||
|
||||
@@ -179,6 +179,8 @@ const nextConfig = {
|
||||
"superagent-proxy", // Dependencies of @tryvital/vital-node
|
||||
"superagent", // Dependencies of akismet
|
||||
"formidable", // Dependencies of akismet
|
||||
"@boxyhq/saml-jackson",
|
||||
"jose", // Dependency of @boxyhq/saml-jackson
|
||||
],
|
||||
experimental: {
|
||||
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.5.15",
|
||||
"version": "5.5.17",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -57,3 +57,22 @@ export const applyTheme = function () {
|
||||
console.error("Error applying theme:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// ToDesktop class application function - will be stringified and injected. So, it must not use anything from the closure
|
||||
export const applyToDesktopClass = function () {
|
||||
try {
|
||||
const onReady = () => {
|
||||
if (typeof window !== "undefined" && window.todesktop && document.documentElement) {
|
||||
document.documentElement.classList.add("todesktop");
|
||||
} else if (document.documentElement) {
|
||||
return;
|
||||
} else {
|
||||
requestAnimationFrame(onReady);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(onReady);
|
||||
} catch (e) {
|
||||
console.error("Error applying todesktop class:", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { applyTheme } from "./_applyThemeForDocument";
|
||||
import { applyTheme, applyToDesktopClass } from "./_applyThemeForDocument";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
|
||||
|
||||
@@ -50,6 +50,7 @@ class MyDocument extends Document<Props> {
|
||||
__html: `
|
||||
window.calNewLocale = "${newLocale}";
|
||||
(${applyTheme.toString()})();
|
||||
(${applyToDesktopClass.toString()})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
|
||||
import type { TGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { fetcher } from "@calcom/lib/retellAIFetcher";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -15,7 +15,7 @@ import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
import { getUsersInOrgContext } from "@server/lib/[user]/getServerSideProps";
|
||||
|
||||
@@ -122,17 +122,17 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const allowRescheduleForCancelledBooking = context.query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
if (!org) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
context,
|
||||
currentOrgDomain: org,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
@@ -220,18 +220,16 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const allowRescheduleForCancelledBooking = context.query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
const [user] = await getUsersInOrgContext([username], isValidOrgDomain ? currentOrgDomain : null);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[[pages/[user]]]"] });
|
||||
type UserPageProps = {
|
||||
@@ -73,25 +73,20 @@ type UserPageProps = {
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isOrgContext = isValidOrgDomain && !!currentOrgDomain;
|
||||
|
||||
const dataFetchStart = Date.now();
|
||||
|
||||
if (!isOrgContext) {
|
||||
// If there is no org context, see if some redirect is setup due to org migration
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernameList,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
const redirect = await handleOrgRedirect({
|
||||
slugs: usernameList,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
context,
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
|
||||
const usersInOrgContext = await getUsersInOrgContext(
|
||||
|
||||
@@ -3,7 +3,7 @@ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
||||
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/BookingLimits";
|
||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { describe, vi, test } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { SchedulingType, type BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { expect, expectedSlotsForSchedule } from "./getSchedule/expects";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
|
||||
import { expect, expectedSlotsForSchedule } from "./expects";
|
||||
import { setupAndTeardown } from "./setupAndTeardown";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { expectNoAttemptToGetAvailability } from "../../utils/bookingScenario/ex
|
||||
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { expect, expectedSlotsForSchedule } from "./expects";
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ScenarioData } from "../../utils/bookingScenario/bookingScenario";
|
||||
|
||||
import { describe, expect, vi, test } from "vitest";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { PeriodType } from "@calcom/prisma/enums";
|
||||
|
||||
import { expectedSlotsForSchedule } from "./expects";
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { describe, test, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
|
||||
import { expect } from "./expects";
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { IncomingMessage } from "http";
|
||||
import { describe, test, beforeEach, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { getScheduleSchema, GetScheduleOptions } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
|
||||
|
||||
+1009
-3570
File diff suppressed because it is too large
Load Diff
@@ -125,12 +125,45 @@ Along with the props, Availability settings atom accepts custom styles via the *
|
||||
| formClassName | Form which contains the days and toggles |
|
||||
| timezoneSelectClassName | Adds styling to the timezone select component |
|
||||
| subtitlesClassName | Styles the subtitle |
|
||||
| scheduleContainer | Styles the entire schedule component |
|
||||
| scheduleDay | Adds styling to just the day of a particular schedule |
|
||||
| dayRanges | Adds styling to day ranges |
|
||||
| timeRanges | Time ranges in the availability settings can be customized |
|
||||
| labelAndSwitchContainer | Adds styling to label and switches |
|
||||
| overridesModalClassNames | Adds styling to the date overrides modal |
|
||||
| scheduleClassNames | An object for granular styling of schedule components (see detailed table below) |
|
||||
| dateOverrideClassNames | An object for granular styling of date override components (see detailed table below) |
|
||||
|
||||
### scheduleClassNames Object Structure
|
||||
|
||||
The `scheduleClassNames` prop accepts an object with the following structure for granular styling of schedule components:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `schedule` | Styles the entire schedule component |
|
||||
| `scheduleContainer` | Styles the schedule container |
|
||||
| `scheduleDay` | Adds styling to just the day of a particular schedule |
|
||||
| `dayRanges` | Adds styling to day ranges |
|
||||
| `timeRangeField` | Styles the time range input fields |
|
||||
| `labelAndSwitchContainer` | Adds styling to label and switches |
|
||||
| `timePicker` | An object for granular styling of time picker dropdown components (see nested table below) |
|
||||
|
||||
#### timePicker Object Structure (nested within scheduleClassNames)
|
||||
|
||||
The `timePicker` prop accepts an object with the following structure for granular styling of time picker dropdown components. This applies to the time selection dropdowns (e.g., [ 9:00 ]) that users can click to open a dropdown with available times or click to manually enter a time:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `container` | Styles the main time picker container |
|
||||
| `valueContainer` | Styles the container that holds the selected value |
|
||||
| `value` | Styles the displayed selected time value |
|
||||
| `input` | Styles the input field for manual time entry |
|
||||
| `dropdown` | Styles the dropdown menu with time options |
|
||||
|
||||
### dateOverrideClassNames Object Structure
|
||||
|
||||
The `dateOverrideClassNames` prop accepts an object with the following structure for granular styling of date override components:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `container` | Styles the main container |
|
||||
| `title` | Styles the title|
|
||||
| `description` | Styles the description |
|
||||
| `button` | Styles the button to add date override |
|
||||
|
||||
<Info>Please ensure all custom classnames are valid [Tailwind CSS](https://tailwindcss.com/) classnames.</Info>
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
|
||||
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
|
||||
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
|
||||
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
@@ -47,6 +47,37 @@ Along with the props, calendar settings atom accepts custom styles via the **cla
|
||||
| calendarSettingsCustomClassnames | Adds styling to the entire calendar settings atom |
|
||||
| destinationCalendarSettingsCustomClassnames | Adds styling only to the destination calendar container |
|
||||
| selectedCalendarSettingsCustomClassnames | Adds styling only to the selected calendar container |
|
||||
| selectedCalendarSettingsClassNames | An object for granular styling of selected calendars component (see detailed table below) |
|
||||
| destinationCalendarSettingsClassNames | An object for granular styling of destination calendar component (see detailed table below) |
|
||||
|
||||
### selectedCalendarSettingsClassNames Object Structure
|
||||
|
||||
The `selectedCalendarSettingsClassNames` prop accepts an object with the following nested structure for granular styling of the selected calendars component:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `container` | Styles the main container of the selected calendars section |
|
||||
| `header.container` | Styles the header container |
|
||||
| `header.title` | Styles the header title |
|
||||
| `header.description` | Styles the header description |
|
||||
| `selectedCalendarsListClassNames.container` | Styles the container that holds all selected calendar items |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.container` | Styles each individual calendar item container |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.header.container` | Styles the header section of each calendar item |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.header.title` | Styles the title of each calendar item |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.header.description` | Styles the description of each calendar item |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.body.container` | Styles the body section of each calendar item |
|
||||
| `selectedCalendarsListClassNames.selectedCalendar.body.description` | Styles the body description of each calendar item |
|
||||
| `noSelectedCalendarsMessage` | Styles the message shown when no calendars are connected |
|
||||
|
||||
### destinationCalendarSettingsClassNames Object Structure
|
||||
|
||||
The `destinationCalendarSettingsClassNames` prop accepts an object with the following nested structure for granular styling of the destination calendar component:
|
||||
|
||||
| Property Path | Description |
|
||||
|:--------------|:------------|
|
||||
| `container` | Styles the main container of the destination calendar section |
|
||||
| `header.title` | Styles the header title text |
|
||||
| `header.description` | Styles the header description text |
|
||||
|
||||
<p></p>
|
||||
Additionally, if you wish to select either the Destination Calendar or the Selected Calendar, we also provide atoms specifically designed for this purpose.
|
||||
|
||||
Binary file not shown.
@@ -26,11 +26,14 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.bookerUrl).toBe("https://cal.com/user/test-slug");
|
||||
expect(event.title).toBe("Test Event");
|
||||
expect(event.startTime).toBe(mockStartTime);
|
||||
expect(event.endTime).toBe(mockEndTime);
|
||||
expect(event.additionalNotes).toBe("Some notes");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.bookerUrl).toBe("https://cal.com/user/test-slug");
|
||||
expect(event.title).toBe("Test Event");
|
||||
expect(event.startTime).toBe(mockStartTime);
|
||||
expect(event.endTime).toBe(mockEndTime);
|
||||
expect(event.additionalNotes).toBe("Some notes");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with event type details", () => {
|
||||
@@ -50,11 +53,14 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.type).toBe("test-slug");
|
||||
expect(event.description).toBe("Test description");
|
||||
expect(event.eventTypeId).toBe(123);
|
||||
expect(event.hideCalendarNotes).toBe(true);
|
||||
expect(event.hideCalendarEventDetails).toBe(false);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.type).toBe("test-slug");
|
||||
expect(event.description).toBe("Test description");
|
||||
expect(event.eventTypeId).toBe(123);
|
||||
expect(event.hideCalendarNotes).toBe(true);
|
||||
expect(event.hideCalendarEventDetails).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with organizer details", () => {
|
||||
@@ -82,17 +88,20 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.organizer).toEqual({
|
||||
id: 456,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
username: "johndoe",
|
||||
timeZone: "America/New_York",
|
||||
language: {
|
||||
translate: mockTranslate,
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.organizer).toEqual({
|
||||
id: 456,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
username: "johndoe",
|
||||
timeZone: "America/New_York",
|
||||
language: {
|
||||
translate: mockTranslate,
|
||||
locale: "en",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle nameless organizer", () => {
|
||||
@@ -119,7 +128,10 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.organizer.name).toBe("Nameless");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.organizer.name).toBe("Nameless");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with attendees", () => {
|
||||
@@ -158,7 +170,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withAttendees(attendees)
|
||||
.build();
|
||||
|
||||
expect(event.attendees).toEqual(attendees);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.attendees).toEqual(attendees);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with metadata and responses", () => {
|
||||
@@ -192,10 +207,13 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.additionalNotes).toBe("Some notes");
|
||||
expect(event.customInputs).toEqual(customInputs);
|
||||
expect(event.responses).toEqual(responses);
|
||||
expect(event.userFieldsResponses).toEqual(userFieldsResponses);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.additionalNotes).toBe("Some notes");
|
||||
expect(event.customInputs).toEqual(customInputs);
|
||||
expect(event.responses).toEqual(responses);
|
||||
expect(event.userFieldsResponses).toEqual(userFieldsResponses);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with location", () => {
|
||||
@@ -216,8 +234,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.location).toBe("Conference Room A");
|
||||
expect(event.conferenceCredentialId).toBe(789);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.location).toBe("Conference Room A");
|
||||
expect(event.conferenceCredentialId).toBe(789);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with destination calendar", () => {
|
||||
@@ -229,6 +250,8 @@ describe("CalendarEventBuilder", () => {
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
credentialId: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
};
|
||||
@@ -247,7 +270,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withDestinationCalendar([destinationCalendar])
|
||||
.build();
|
||||
|
||||
expect(event.destinationCalendar).toEqual([destinationCalendar]);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.destinationCalendar).toEqual([destinationCalendar]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with identifiers", () => {
|
||||
@@ -268,8 +294,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.iCalUID).toBe("ical-123");
|
||||
expect(event.iCalSequence).toBe(2);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.iCalUID).toBe("ical-123");
|
||||
expect(event.iCalSequence).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with confirmation settings", () => {
|
||||
@@ -290,8 +319,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.oneTimePassword).toBeUndefined();
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.oneTimePassword).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should set oneTimePassword to null when isConfirmedByDefault is true", () => {
|
||||
@@ -312,8 +344,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.oneTimePassword).toBeNull();
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.oneTimePassword).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with platform variables", () => {
|
||||
@@ -336,10 +371,13 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.platformClientId).toBe("client-123");
|
||||
expect(event.platformRescheduleUrl).toBe("https://platform.com/reschedule");
|
||||
expect(event.platformCancelUrl).toBe("https://platform.com/cancel");
|
||||
expect(event.platformBookingUrl).toBe("https://platform.com/booking");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.platformClientId).toBe("client-123");
|
||||
expect(event.platformRescheduleUrl).toBe("https://platform.com/reschedule");
|
||||
expect(event.platformCancelUrl).toBe("https://platform.com/cancel");
|
||||
expect(event.platformBookingUrl).toBe("https://platform.com/booking");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with apps status", () => {
|
||||
@@ -376,7 +414,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withAppsStatus(appsStatus)
|
||||
.build();
|
||||
|
||||
expect(event.appsStatus).toEqual(appsStatus);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.appsStatus).toEqual(appsStatus);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with video call data", () => {
|
||||
@@ -401,7 +442,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withVideoCallData(videoCallData)
|
||||
.build();
|
||||
|
||||
expect(event.videoCallData).toEqual(videoCallData);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.videoCallData).toEqual(videoCallData);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with team information", () => {
|
||||
@@ -435,7 +479,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withTeam(team)
|
||||
.build();
|
||||
|
||||
expect(event.team).toEqual(team);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.team).toEqual(team);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with recurring event information", () => {
|
||||
@@ -459,7 +506,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withRecurring(recurringEvent)
|
||||
.build();
|
||||
|
||||
expect(event.recurringEvent).toEqual(recurringEvent);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.recurringEvent).toEqual(recurringEvent);
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with attendee seat ID", () => {
|
||||
@@ -477,7 +527,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withAttendeeSeatId("seat-123")
|
||||
.build();
|
||||
|
||||
expect(event.attendeeSeatId).toBe("seat-123");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.attendeeSeatId).toBe("seat-123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with UID", () => {
|
||||
@@ -495,7 +548,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withUid("booking-uid-123")
|
||||
.build();
|
||||
|
||||
expect(event.uid).toBe("booking-uid-123");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.uid).toBe("booking-uid-123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with one-time password", () => {
|
||||
@@ -513,7 +569,10 @@ describe("CalendarEventBuilder", () => {
|
||||
.withOneTimePassword("otp123")
|
||||
.build();
|
||||
|
||||
expect(event.oneTimePassword).toBe("otp123");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.oneTimePassword).toBe("otp123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should create an event with recurring event ID", () => {
|
||||
@@ -531,9 +590,12 @@ describe("CalendarEventBuilder", () => {
|
||||
.withRecurringEventId("recurring-123")
|
||||
.build();
|
||||
|
||||
expect(event.existingRecurringEvent).toEqual({
|
||||
recurringEventId: "recurring-123",
|
||||
});
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.existingRecurringEvent).toEqual({
|
||||
recurringEventId: "recurring-123",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should create a complete calendar event with all properties", () => {
|
||||
@@ -597,6 +659,8 @@ describe("CalendarEventBuilder", () => {
|
||||
userId: 1,
|
||||
eventTypeId: 123,
|
||||
credentialId: 1,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
},
|
||||
@@ -644,26 +708,29 @@ describe("CalendarEventBuilder", () => {
|
||||
.build();
|
||||
|
||||
// Test that all properties are set correctly
|
||||
expect(event.title).toBe("Complete Test Event");
|
||||
expect(event.type).toBe("complete-test");
|
||||
expect(event.organizer.name).toBe("John Doe");
|
||||
expect(event.attendees).toHaveLength(1);
|
||||
expect(event.location).toBe("Conference Room A");
|
||||
expect(event.iCalUID).toBe("ical-123");
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.platformClientId).toBe("client-123");
|
||||
expect(event.appsStatus).toHaveLength(1);
|
||||
expect(event.videoCallData?.url).toBe("https://meet.example.com/123");
|
||||
expect(event.team?.name).toBe("Engineering Team");
|
||||
expect(event.recurringEvent?.count).toBe(5);
|
||||
expect(event.attendeeSeatId).toBe("seat-123");
|
||||
expect(event.uid).toBe("booking-uid-123");
|
||||
expect(event.oneTimePassword).toBe("otp123");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.title).toBe("Complete Test Event");
|
||||
expect(event.type).toBe("complete-test");
|
||||
expect(event.organizer.name).toBe("John Doe");
|
||||
expect(event.attendees).toHaveLength(1);
|
||||
expect(event.location).toBe("Conference Room A");
|
||||
expect(event.iCalUID).toBe("ical-123");
|
||||
expect(event.requiresConfirmation).toBe(true);
|
||||
expect(event.platformClientId).toBe("client-123");
|
||||
expect(event.appsStatus).toHaveLength(1);
|
||||
expect(event.videoCallData?.url).toBe("https://meet.example.com/123");
|
||||
expect(event.team?.name).toBe("Engineering Team");
|
||||
expect(event.recurringEvent?.count).toBe(5);
|
||||
expect(event.attendeeSeatId).toBe("seat-123");
|
||||
expect(event.uid).toBe("booking-uid-123");
|
||||
expect(event.oneTimePassword).toBe("otp123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw an error when building without required fields", () => {
|
||||
it("should return null when building without required fields", () => {
|
||||
const builder = new CalendarEventBuilder();
|
||||
expect(() => builder.build()).toThrow("Missing required fields for calendar event");
|
||||
expect(builder.build()).toBeNull();
|
||||
});
|
||||
|
||||
it("should create an event from an existing event", () => {
|
||||
@@ -684,8 +751,11 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.title).toBe("Updated Event");
|
||||
expect(event.type).toBe("existing-type");
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.title).toBe("Updated Event");
|
||||
expect(event.type).toBe("existing-type");
|
||||
}
|
||||
});
|
||||
|
||||
it("should propagate disableCancelling and disableRescheduling", () => {
|
||||
@@ -706,7 +776,10 @@ describe("CalendarEventBuilder", () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
expect(event.disableCancelling).toBe(true);
|
||||
expect(event.disableRescheduling).toBe(true);
|
||||
expect(event).not.toBeNull();
|
||||
if (event) {
|
||||
expect(event.disableCancelling).toBe(true);
|
||||
expect(event.disableRescheduling).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,7 +266,7 @@ export class CalendarEventBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): CalendarEvent {
|
||||
build(): CalendarEvent | null {
|
||||
// Validate required fields
|
||||
if (
|
||||
!this.event.startTime ||
|
||||
@@ -275,7 +275,7 @@ export class CalendarEventBuilder {
|
||||
!this.event.bookerUrl ||
|
||||
!this.event.title
|
||||
) {
|
||||
throw new Error("Missing required fields for calendar event");
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.event as CalendarEvent;
|
||||
|
||||
@@ -81,6 +81,7 @@ const BookerComponent = ({
|
||||
timeZones,
|
||||
eventMetaChildren,
|
||||
roundRobinHideOrgAndTeam,
|
||||
showNoAvailabilityDialog,
|
||||
}: BookerProps & WrappedBookerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
|
||||
@@ -417,6 +418,7 @@ const BookerComponent = ({
|
||||
slots={schedule?.data?.slots}
|
||||
isLoading={schedule.isPending}
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -445,6 +447,7 @@ const BookerComponent = ({
|
||||
slots={schedule?.data?.slots}
|
||||
isLoading={schedule.isPending}
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
</BookerSection>
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const DatePicker = ({
|
||||
isLoading,
|
||||
classNames,
|
||||
scrollToTimeSlots,
|
||||
showNoAvailabilityDialog,
|
||||
}: {
|
||||
event: {
|
||||
data?: {
|
||||
@@ -72,6 +73,7 @@ export const DatePicker = ({
|
||||
isLoading?: boolean;
|
||||
classNames?: DatePickerClassNames;
|
||||
scrollToTimeSlots?: () => void;
|
||||
showNoAvailabilityDialog?: boolean;
|
||||
}) => {
|
||||
const { i18n } = useLocale();
|
||||
const [month, selectedDate, layout] = useBookerStore(
|
||||
@@ -148,6 +150,7 @@ export const DatePicker = ({
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
periodData={periodData}
|
||||
isCompact={isCompact}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface BookerProps {
|
||||
hashedLink?: string | null;
|
||||
isInstantMeeting?: boolean;
|
||||
teamMemberEmail?: string | null;
|
||||
showNoAvailabilityDialog?: boolean;
|
||||
crmOwnerRecordType?: string | null;
|
||||
crmAppSlug?: string | null;
|
||||
crmRecordId?: string | null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SystemField } from "@calcom/features/bookings/lib/SystemField";
|
||||
import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber";
|
||||
import { getBookingWithResponses } from "@calcom/lib/getBooking";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
@@ -42,7 +43,10 @@ export const getCalEventResponses = ({
|
||||
// To set placeholder email for the booking
|
||||
if (!!!backwardCompatibleResponses.email) {
|
||||
if (typeof backwardCompatibleResponses["attendeePhoneNumber"] !== "string")
|
||||
throw new Error("Both Phone and Email are missing");
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Both Phone and Email are missing",
|
||||
});
|
||||
|
||||
backwardCompatibleResponses.email = contructEmailFromPhoneNumber(
|
||||
backwardCompatibleResponses["attendeePhoneNumber"]
|
||||
|
||||
@@ -52,8 +52,8 @@ import {
|
||||
enrichHostsWithDelegationCredentials,
|
||||
getFirstDelegationConferencingCredentialAppLocation,
|
||||
} from "@calcom/lib/delegationCredential/server";
|
||||
import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
||||
import { getCacheService } from "@calcom/lib/di/containers/cache";
|
||||
import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/BookingLimits";
|
||||
import { getCacheService } from "@calcom/lib/di/containers/Cache";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
|
||||
@@ -83,7 +83,13 @@ import {
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
|
||||
import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
import type {
|
||||
AdditionalInformation,
|
||||
AppsStatus,
|
||||
CalendarEvent,
|
||||
CalEventResponses,
|
||||
Person,
|
||||
} from "@calcom/types/Calendar";
|
||||
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
|
||||
|
||||
@@ -314,17 +320,35 @@ export const buildEventForTeamEventType = async ({
|
||||
|
||||
const teamMembers = await Promise.all(teamMemberPromises);
|
||||
|
||||
evt = CalendarEventBuilder.fromEvent(evt)
|
||||
.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars])
|
||||
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
|
||||
?.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars])
|
||||
.build();
|
||||
|
||||
return CalendarEventBuilder.fromEvent(evt)
|
||||
.withTeam({
|
||||
if (!updatedEvt) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Failed to build event with destination calendar due to missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
evt = updatedEvt;
|
||||
|
||||
const teamEvt = CalendarEventBuilder.fromEvent(evt)
|
||||
?.withTeam({
|
||||
members: teamMembers,
|
||||
name: team?.name || "Nameless",
|
||||
id: team?.id ?? 0,
|
||||
})
|
||||
.build();
|
||||
|
||||
if (!teamEvt) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Failed to build team event due to missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
return teamEvt;
|
||||
};
|
||||
|
||||
function buildTroubleshooterData({
|
||||
@@ -1162,7 +1186,7 @@ async function handler(
|
||||
|
||||
const eventName = getEventName(eventNameObject);
|
||||
|
||||
let evt: CalendarEvent = new CalendarEventBuilder()
|
||||
const builtEvt = new CalendarEventBuilder()
|
||||
.withBasicDetails({
|
||||
bookerUrl,
|
||||
title: eventName,
|
||||
@@ -1220,20 +1244,44 @@ async function handler(
|
||||
})
|
||||
.build();
|
||||
|
||||
if (!builtEvt) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Failed to build calendar event due to missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
let evt: CalendarEvent = builtEvt;
|
||||
|
||||
if (input.bookingData.thirdPartyRecurringEventId) {
|
||||
evt = CalendarEventBuilder.fromEvent(evt)
|
||||
.withRecurringEventId(input.bookingData.thirdPartyRecurringEventId)
|
||||
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
|
||||
?.withRecurringEventId(input.bookingData.thirdPartyRecurringEventId)
|
||||
.build();
|
||||
|
||||
if (!updatedEvt) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Failed to build event with recurring event ID due to missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
evt = updatedEvt;
|
||||
}
|
||||
|
||||
if (isTeamEventType) {
|
||||
evt = await buildEventForTeamEventType({
|
||||
const teamEvt = await buildEventForTeamEventType({
|
||||
existingEvent: evt,
|
||||
schedulingType: eventType.schedulingType,
|
||||
users,
|
||||
team: eventType.team,
|
||||
organizerUser,
|
||||
});
|
||||
|
||||
if (!teamEvt) {
|
||||
throw new HttpError({ statusCode: 400, message: "Failed to build team event" });
|
||||
}
|
||||
|
||||
evt = teamEvt;
|
||||
}
|
||||
|
||||
// data needed for triggering webhooks
|
||||
@@ -1351,13 +1399,22 @@ async function handler(
|
||||
// Rescheduling logic for the original seated event was handled in handleSeats
|
||||
// We want to use new booking logic for the new time slot
|
||||
originalRescheduledBooking = null;
|
||||
evt = CalendarEventBuilder.fromEvent(evt)
|
||||
.withIdentifiers({
|
||||
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
|
||||
?.withIdentifiers({
|
||||
iCalUID: getICalUID({
|
||||
attendeeId: bookingSeat?.attendeeId,
|
||||
}),
|
||||
})
|
||||
.build();
|
||||
|
||||
if (!updatedEvt) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Failed to build event with new identifiers due to missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
evt = updatedEvt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1478,14 +1535,32 @@ async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
evt = CalendarEventBuilder.fromEvent(evt)
|
||||
.withUid(booking.uid ?? null)
|
||||
const updatedEvtWithUid = CalendarEventBuilder.fromEvent(evt)
|
||||
?.withUid(booking.uid ?? null)
|
||||
.build();
|
||||
|
||||
evt = CalendarEventBuilder.fromEvent(evt)
|
||||
.withOneTimePassword(booking.oneTimePassword ?? null)
|
||||
if (!updatedEvtWithUid) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Failed to build event with UID due to missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
evt = updatedEvtWithUid;
|
||||
|
||||
const updatedEvtWithPassword = CalendarEventBuilder.fromEvent(evt)
|
||||
?.withOneTimePassword(booking.oneTimePassword ?? null)
|
||||
.build();
|
||||
|
||||
if (!updatedEvtWithPassword) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Failed to build event with one-time password due to missing required fields",
|
||||
});
|
||||
}
|
||||
|
||||
evt = updatedEvtWithPassword;
|
||||
|
||||
if (booking && booking.id && eventType.seatsPerTimeSlot) {
|
||||
const currentAttendee = booking.attendees.find(
|
||||
(attendee) =>
|
||||
@@ -1580,6 +1655,47 @@ async function handler(
|
||||
evt.iCalUID = undefined;
|
||||
}
|
||||
|
||||
if (changedOrganizer && originalRescheduledBooking?.user) {
|
||||
const originalHostCredentials = await getAllCredentialsIncludeServiceAccountKey(
|
||||
originalRescheduledBooking.user,
|
||||
eventType
|
||||
);
|
||||
const refreshedOriginalHostCredentials = await refreshCredentials(originalHostCredentials);
|
||||
|
||||
// Create EventManager with original host's credentials for deletion operations
|
||||
const originalHostEventManager = new EventManager(
|
||||
{ ...originalRescheduledBooking.user, credentials: refreshedOriginalHostCredentials },
|
||||
apps
|
||||
);
|
||||
log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
|
||||
// Create deletion event with original host's organizer info and original booking properties
|
||||
const deletionEvent = {
|
||||
...evt,
|
||||
organizer: {
|
||||
id: originalRescheduledBooking.user.id,
|
||||
name: originalRescheduledBooking.user.name || "",
|
||||
email: originalRescheduledBooking.user.email,
|
||||
username: originalRescheduledBooking.user.username || undefined,
|
||||
timeZone: originalRescheduledBooking.user.timeZone,
|
||||
language: { translate: tOrganizer, locale: originalRescheduledBooking.user.locale ?? "en" },
|
||||
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
|
||||
},
|
||||
destinationCalendar: previousHostDestinationCalendar,
|
||||
// Override with original booking properties used by deletion operations
|
||||
startTime: originalRescheduledBooking.startTime.toISOString(),
|
||||
endTime: originalRescheduledBooking.endTime.toISOString(),
|
||||
uid: originalRescheduledBooking.uid,
|
||||
location: originalRescheduledBooking.location,
|
||||
responses: originalRescheduledBooking.responses
|
||||
? (originalRescheduledBooking.responses as CalEventResponses)
|
||||
: evt.responses,
|
||||
};
|
||||
|
||||
await originalHostEventManager.deleteEventsAndMeetings({
|
||||
event: deletionEvent,
|
||||
bookingReferences: originalRescheduledBooking.references,
|
||||
});
|
||||
}
|
||||
const updateManager = await eventManager.reschedule(
|
||||
evt,
|
||||
originalRescheduledBooking.uid,
|
||||
@@ -1737,6 +1853,8 @@ async function handler(
|
||||
|
||||
originalBookingMemberEmails.push({
|
||||
...originalRescheduledBooking.user,
|
||||
username: originalRescheduledBooking.user.username ?? undefined,
|
||||
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
|
||||
name: originalRescheduledBooking.user.name || "",
|
||||
language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" },
|
||||
});
|
||||
@@ -1941,7 +2059,7 @@ async function handler(
|
||||
})
|
||||
);
|
||||
|
||||
if (!isDryRun) {
|
||||
if (!isDryRun && !(eventType.seatsPerTimeSlot && rescheduleUid)) {
|
||||
await sendScheduledEmailsAndSMS(
|
||||
{
|
||||
...evt,
|
||||
|
||||
@@ -4,8 +4,8 @@ import dayjs from "@calcom/dayjs";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
|
||||
import { buildDateRanges } from "@calcom/lib/date-ranges";
|
||||
import { getBusyTimesService } from "@calcom/lib/di/containers/busy-times";
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
|
||||
import { getBusyTimesService } from "@calcom/lib/di/containers/BusyTimes";
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
|
||||
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Prisma } from "@prisma/client";
|
||||
import type { Logger } from "tslog";
|
||||
|
||||
import { checkIfUsersAreBlocked } from "@calcom/features/watchlist/operations/check-if-users-are-blocked.controller";
|
||||
import { findQualifiedHostsWithDelegationCredentials } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
|
||||
import { enrichUsersWithDelegationCredentials } from "@calcom/lib/delegationCredential/server";
|
||||
import { getQualifiedHostsService } from "@calcom/lib/di/containers/QualifiedHosts";
|
||||
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
|
||||
@@ -142,8 +142,9 @@ const _loadAndValidateUsers = async ({
|
||||
? false
|
||||
: user.isFixed || eventType.schedulingType !== SchedulingType.ROUND_ROBIN,
|
||||
}));
|
||||
const qualifiedHostsService = getQualifiedHostsService();
|
||||
const { qualifiedRRHosts, allFallbackRRHosts, fixedHosts } =
|
||||
await findQualifiedHostsWithDelegationCredentials({
|
||||
await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
routedTeamMemberIds: routedTeamMemberIds || [],
|
||||
rescheduleUid,
|
||||
@@ -159,7 +160,7 @@ const _loadAndValidateUsers = async ({
|
||||
},
|
||||
{} as {
|
||||
[key: number]: Awaited<
|
||||
ReturnType<typeof findQualifiedHostsWithDelegationCredentials>
|
||||
ReturnType<ReturnType<typeof getQualifiedHostsService>["findQualifiedHostsWithDelegationCredentials"]>
|
||||
>["qualifiedRRHosts"][number];
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2827,5 +2827,133 @@ describe("handleNewBooking", () => {
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"should use correct credentials when round robin reschedule changes host - original host credentials for deletion, new host for creation",
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const originalHost = getOrganizer({
|
||||
name: "Original Host",
|
||||
email: "originalhost@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstMorningShift],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const newHost = getOrganizer({
|
||||
name: "New Host",
|
||||
email: "newhost@example.com",
|
||||
id: 102,
|
||||
schedules: [TestData.schedules.IstEveningShift],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const uidOfBookingToBeRescheduled = "credential-test-booking-uid";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 15,
|
||||
length: 15,
|
||||
users: [{ id: 101 }, { id: 102 }],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
},
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
eventTypeId: 1,
|
||||
userId: 101,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||
credentialId: null,
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
credentialId: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer: originalHost,
|
||||
usersApartFromOrganizer: [newHost],
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
const videoMock = mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
});
|
||||
|
||||
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: { uid: "NEW_EVENT_ID" },
|
||||
update: { uid: "UPDATED_EVENT_ID" },
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
rescheduleUid: uidOfBookingToBeRescheduled,
|
||||
start: `${plus1DateString}T14:00:00.000Z`,
|
||||
end: `${plus1DateString}T14:15:00.000Z`,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking({
|
||||
bookingData: mockBookingData,
|
||||
});
|
||||
|
||||
expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, {
|
||||
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
|
||||
calEvent: {
|
||||
organizer: expect.objectContaining({
|
||||
email: originalHost.email,
|
||||
}),
|
||||
startTime: `${plus1DateString}T05:00:00.000Z`,
|
||||
endTime: `${plus1DateString}T05:15:00.000Z`,
|
||||
uid: uidOfBookingToBeRescheduled,
|
||||
},
|
||||
uid: "MOCK_ID",
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
expect(createdBooking.userId).toBe(newHost.id);
|
||||
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T14:00:00.000Z`);
|
||||
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T14:15:00.000Z`);
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -51,6 +51,8 @@ const attendeeRescheduleSeatedBooking = async (
|
||||
// We don't want to trigger rescheduling logic of the original booking
|
||||
originalRescheduledBooking = null;
|
||||
|
||||
await sendRescheduledSeatEmailAndSMS(evt, seatAttendee as Person, eventType.metadata);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ export type DatePickerProps = {
|
||||
periodData?: PeriodData;
|
||||
// Whether this is a compact sidebar view or main monthly view
|
||||
isCompact?: boolean;
|
||||
// Whether to show the no availability dialog
|
||||
showNoAvailabilityDialog?: boolean;
|
||||
};
|
||||
|
||||
const Day = ({
|
||||
@@ -164,6 +166,7 @@ const Days = ({
|
||||
isBookingInPast,
|
||||
periodData,
|
||||
isCompact,
|
||||
showNoAvailabilityDialog = true,
|
||||
...props
|
||||
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
|
||||
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
|
||||
@@ -345,14 +348,18 @@ const Days = ({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!props.isLoading && !isBookingInPast && includedDates && includedDates?.length === 0 && (
|
||||
<NoAvailabilityDialog
|
||||
month={month}
|
||||
nextMonthButton={nextMonthButton}
|
||||
browsingDate={browsingDate}
|
||||
periodData={periodData}
|
||||
/>
|
||||
)}
|
||||
{!props.isLoading &&
|
||||
!isBookingInPast &&
|
||||
includedDates &&
|
||||
includedDates?.length === 0 &&
|
||||
showNoAvailabilityDialog && (
|
||||
<NoAvailabilityDialog
|
||||
month={month}
|
||||
nextMonthButton={nextMonthButton}
|
||||
browsingDate={browsingDate}
|
||||
periodData={periodData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -374,6 +381,7 @@ const DatePicker = ({
|
||||
periodType: "UNLIMITED",
|
||||
},
|
||||
isCompact,
|
||||
showNoAvailabilityDialog,
|
||||
...passThroughProps
|
||||
}: DatePickerProps &
|
||||
Partial<React.ComponentProps<typeof Days>> & {
|
||||
@@ -484,6 +492,7 @@ const DatePicker = ({
|
||||
isBookingInPast={isBookingInPast}
|
||||
periodData={periodData}
|
||||
isCompact={isCompact}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const TeamEventTypeForm = ({
|
||||
|
||||
return (
|
||||
<Form form={form} handleSubmit={handleSubmit}>
|
||||
<div className="mt-3 space-y-6 pb-11">
|
||||
<div className="mt-1 space-y-6">
|
||||
<TextField
|
||||
type="hidden"
|
||||
labelProps={{ style: { display: "none" } }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, Suspense } from "react";
|
||||
import { useState, Suspense, useMemo } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
SelectedCalendarSettingsScope,
|
||||
SelectedCalendarsSettingsWebWrapperSkeleton,
|
||||
} from "@calcom/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
|
||||
import { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone";
|
||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
|
||||
import { TimezoneSelect as WebTimezoneSelect } from "@calcom/features/components/timezone-select";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
@@ -565,6 +566,10 @@ export const EventAdvancedTab = ({
|
||||
userEmail = removePlatformClientIdFromEmail(userEmail, platformContext.clientId);
|
||||
}
|
||||
|
||||
const TimezoneSelect = useMemo(() => {
|
||||
return isPlatform ? PlatformTimzoneSelect : WebTimezoneSelect;
|
||||
}, [isPlatform]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<calendarComponents.CalendarSettings
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
routingRepositoryBaseInputSchema,
|
||||
bookingRepositoryBaseInputSchema,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { getInsightsBookingService } from "@calcom/lib/di/containers/insights-booking";
|
||||
import { getInsightsRoutingService } from "@calcom/lib/di/containers/insights-routing";
|
||||
import { getInsightsBookingService } from "@calcom/lib/di/containers/InsightsBooking";
|
||||
import { getInsightsRoutingService } from "@calcom/lib/di/containers/InsightsRouting";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
|
||||
@@ -36,6 +36,14 @@ export type ScheduleLabelsType = {
|
||||
deleteTime: string;
|
||||
};
|
||||
|
||||
export type SelectInnerClassNames = {
|
||||
control?: string;
|
||||
singleValue?: string;
|
||||
valueContainer?: string;
|
||||
input?: string;
|
||||
menu?: string;
|
||||
};
|
||||
|
||||
export type FieldPathByValue<TFieldValues extends FieldValues, TValue> = {
|
||||
[Key in FieldPath<TFieldValues>]: FieldPathValue<TFieldValues, Key> extends TValue ? Key : never;
|
||||
}[FieldPath<TFieldValues>];
|
||||
@@ -105,6 +113,7 @@ export const ScheduleDay = <TFieldValues extends FieldValues>({
|
||||
classNames={{
|
||||
dayRanges: classNames?.dayRanges,
|
||||
timeRangeField: classNames?.timeRangeField,
|
||||
timePicker: classNames?.timePicker,
|
||||
}}
|
||||
/>
|
||||
{!disabled && <div className="block">{CopyButton}</div>}
|
||||
@@ -237,7 +246,7 @@ export const DayRanges = <TFieldValues extends FieldValues>({
|
||||
disabled?: boolean;
|
||||
labels?: ScheduleLabelsType;
|
||||
userTimeFormat: number | null;
|
||||
classNames?: Pick<scheduleClassNames, "dayRanges" | "timeRangeField">;
|
||||
classNames?: Pick<scheduleClassNames, "dayRanges" | "timeRangeField" | "timePicker">;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { getValues } = useFormContext();
|
||||
@@ -260,6 +269,7 @@ export const DayRanges = <TFieldValues extends FieldValues>({
|
||||
<TimeRangeField
|
||||
className={classNames?.timeRangeField}
|
||||
userTimeFormat={userTimeFormat}
|
||||
timePickerClassNames={classNames?.timePicker}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
@@ -335,11 +345,27 @@ const TimeRangeField = ({
|
||||
onChange,
|
||||
disabled,
|
||||
userTimeFormat,
|
||||
timePickerClassNames,
|
||||
}: {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
userTimeFormat: number | null;
|
||||
timePickerClassNames?: {
|
||||
container?: string;
|
||||
value?: string;
|
||||
valueContainer?: string;
|
||||
input?: string;
|
||||
dropdown?: string;
|
||||
};
|
||||
} & ControllerRenderProps) => {
|
||||
const innerClassNames: SelectInnerClassNames = {
|
||||
control: timePickerClassNames?.container,
|
||||
singleValue: timePickerClassNames?.value,
|
||||
valueContainer: timePickerClassNames?.valueContainer,
|
||||
input: timePickerClassNames?.input,
|
||||
menu: timePickerClassNames?.dropdown,
|
||||
};
|
||||
|
||||
// this is a controlled component anyway given it uses LazySelect, so keep it RHF agnostic.
|
||||
return (
|
||||
<div className={cn("flex flex-row gap-2 sm:gap-3", className)}>
|
||||
@@ -349,6 +375,7 @@ const TimeRangeField = ({
|
||||
isDisabled={disabled}
|
||||
value={value.start}
|
||||
menuPlacement="bottom"
|
||||
innerClassNames={innerClassNames}
|
||||
onChange={(option) => {
|
||||
const newStart = new Date(option?.value as number);
|
||||
if (newStart >= new Date(value.end)) {
|
||||
@@ -367,6 +394,7 @@ const TimeRangeField = ({
|
||||
isDisabled={disabled}
|
||||
value={value.end}
|
||||
min={value.start}
|
||||
innerClassNames={innerClassNames}
|
||||
menuPlacement="bottom"
|
||||
onChange={(option) => {
|
||||
onChange({ ...value, end: new Date(option?.value as number) });
|
||||
@@ -388,6 +416,7 @@ const LazySelect = ({
|
||||
min?: ConfigType;
|
||||
max?: ConfigType;
|
||||
userTimeFormat: number | null;
|
||||
innerClassNames?: SelectInnerClassNames;
|
||||
}) => {
|
||||
// Lazy-loaded options, otherwise adding a field has a noticeable redraw delay.
|
||||
const { options, filter } = useOptions(userTimeFormat);
|
||||
|
||||
@@ -327,7 +327,9 @@ export default class EventManager {
|
||||
}
|
||||
|
||||
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
|
||||
const isMSTeamsWithOutlookCalendar = evt.location === MSTeamsLocationType && mainHostDestinationCalendar?.integration === "office365_calendar";
|
||||
const isMSTeamsWithOutlookCalendar =
|
||||
evt.location === MSTeamsLocationType &&
|
||||
mainHostDestinationCalendar?.integration === "office365_calendar";
|
||||
|
||||
const results: Array<EventResult<Exclude<Event, AdditionalInformation>>> = [];
|
||||
|
||||
@@ -635,7 +637,7 @@ export default class EventManager {
|
||||
const shouldUpdateBookingReferences =
|
||||
!!changedOrganizer || isLocationChanged || !!isBookingRequestedReschedule || isDailyVideoRoomExpired;
|
||||
|
||||
if (evt.requiresConfirmation) {
|
||||
if (evt.requiresConfirmation && !changedOrganizer) {
|
||||
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking");
|
||||
// As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking.
|
||||
await this.deleteEventsAndMeetings({
|
||||
@@ -644,14 +646,7 @@ export default class EventManager {
|
||||
});
|
||||
} else {
|
||||
if (changedOrganizer) {
|
||||
log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
|
||||
await this.deleteEventsAndMeetings({
|
||||
event: { ...event, destinationCalendar: previousHostDestinationCalendar },
|
||||
bookingReferences: booking.references,
|
||||
});
|
||||
|
||||
log.debug("RescheduleOrganizerChanged: Creating Event and Meeting for for new booking");
|
||||
|
||||
const createdEvent = await this.create(originalEvt);
|
||||
results.push(...createdEvent.results);
|
||||
updatedBookingReferences.push(...createdEvent.referencesToCreate);
|
||||
@@ -728,7 +723,7 @@ export default class EventManager {
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteEventsAndMeetings({
|
||||
public async deleteEventsAndMeetings({
|
||||
event,
|
||||
bookingReferences,
|
||||
isBookingInRecurringSeries,
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import type { Mock } from "vitest";
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
|
||||
import { filterHostsBySameRoundRobinHost } from "./filterHostsBySameRoundRobinHost";
|
||||
import { FilterHostsService } from "./filterHostsBySameRoundRobinHost";
|
||||
|
||||
// Mocking setup
|
||||
const prismaMock = {
|
||||
booking: {
|
||||
findFirst: vi.fn(), // Mock the findFirst method
|
||||
},
|
||||
};
|
||||
const mockBookingRepo = {
|
||||
findOriginalRescheduledBookingUserId: vi.fn(),
|
||||
} as unknown as BookingRepository;
|
||||
|
||||
// Use `vi.spyOn` to make `prisma.booking.groupBy` call the mock instead
|
||||
vi.spyOn(prisma.booking, "findFirst").mockImplementation(prismaMock.booking.findFirst);
|
||||
|
||||
afterEach(() => {
|
||||
// Clear call history before each test to avoid cross-test interference
|
||||
prismaMock.booking.findFirst.mockClear();
|
||||
const filterHostsService = new FilterHostsService({
|
||||
bookingRepo: mockBookingRepo,
|
||||
});
|
||||
|
||||
describe("filterHostsBySameRoundRobinHost", () => {
|
||||
afterEach(() => {
|
||||
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockClear();
|
||||
});
|
||||
|
||||
describe("FilterHostsService", () => {
|
||||
it("skips filter if rescheduleWithSameRoundRobinHost set to false", async () => {
|
||||
const hosts = [
|
||||
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
|
||||
];
|
||||
expect(
|
||||
filterHostsBySameRoundRobinHost({
|
||||
filterHostsService.filterHostsBySameRoundRobinHost({
|
||||
hosts,
|
||||
rescheduleUid: "some-uid",
|
||||
rescheduleWithSameRoundRobinHost: false,
|
||||
@@ -35,10 +33,10 @@ describe("filterHostsBySameRoundRobinHost", () => {
|
||||
});
|
||||
it("skips filter if rerouting", async () => {
|
||||
const hosts = [
|
||||
{ isFixed: true as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
|
||||
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
|
||||
];
|
||||
expect(
|
||||
filterHostsBySameRoundRobinHost({
|
||||
filterHostsService.filterHostsBySameRoundRobinHost({
|
||||
hosts,
|
||||
rescheduleUid: "some-uid",
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
@@ -48,15 +46,14 @@ describe("filterHostsBySameRoundRobinHost", () => {
|
||||
});
|
||||
|
||||
it("correctly selects the same host if the filter applies and the host is in the RR users", async () => {
|
||||
prismaMock.booking.findFirst.mockResolvedValue({ userId: 1 });
|
||||
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockResolvedValue({ userId: 1 });
|
||||
|
||||
const hosts = [
|
||||
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
|
||||
{ isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } },
|
||||
];
|
||||
// Same host should be selected
|
||||
expect(
|
||||
filterHostsBySameRoundRobinHost({
|
||||
filterHostsService.filterHostsBySameRoundRobinHost({
|
||||
hosts,
|
||||
rescheduleUid: "some-uid",
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
@@ -68,7 +65,7 @@ describe("filterHostsBySameRoundRobinHost", () => {
|
||||
// Tests for bookings that have more than one host
|
||||
describe("Fixed hosts and round robin groups support", () => {
|
||||
it("should return organizer and attendee hosts", async () => {
|
||||
prismaMock.booking.findFirst.mockResolvedValue({
|
||||
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockResolvedValue({
|
||||
userId: 1,
|
||||
attendees: [
|
||||
{ email: "host2@acme.com" },
|
||||
@@ -84,7 +81,7 @@ describe("filterHostsBySameRoundRobinHost", () => {
|
||||
{ isFixed: false as const, createdAt: new Date(), user: { id: 4, email: "host4@acme.com" } },
|
||||
];
|
||||
|
||||
const result = await filterHostsBySameRoundRobinHost({
|
||||
const result = await filterHostsService.filterHostsBySameRoundRobinHost({
|
||||
hosts,
|
||||
rescheduleUid: "some-uid",
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
@@ -101,7 +98,7 @@ describe("filterHostsBySameRoundRobinHost", () => {
|
||||
});
|
||||
|
||||
it("should return only organizer host when no attendees match current hosts", async () => {
|
||||
prismaMock.booking.findFirst.mockResolvedValue({
|
||||
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockResolvedValue({
|
||||
userId: 1,
|
||||
attendees: [
|
||||
{ email: "attendee1@example.com" }, // Non-host attendee
|
||||
@@ -114,7 +111,7 @@ describe("filterHostsBySameRoundRobinHost", () => {
|
||||
{ isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "host2@acme.com" } },
|
||||
];
|
||||
|
||||
const result = await filterHostsBySameRoundRobinHost({
|
||||
const result = await filterHostsService.filterHostsBySameRoundRobinHost({
|
||||
hosts,
|
||||
rescheduleUid: "some-uid",
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
|
||||
@@ -1,57 +1,53 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
|
||||
import { isRerouting } from "./routing/utils";
|
||||
|
||||
export const filterHostsBySameRoundRobinHost = async <
|
||||
T extends {
|
||||
isFixed: false; // ensure no fixed hosts are passed.
|
||||
user: { id: number; email: string };
|
||||
}
|
||||
>({
|
||||
hosts,
|
||||
rescheduleUid,
|
||||
rescheduleWithSameRoundRobinHost,
|
||||
routedTeamMemberIds,
|
||||
}: {
|
||||
hosts: T[];
|
||||
rescheduleUid: string | null;
|
||||
rescheduleWithSameRoundRobinHost: boolean;
|
||||
routedTeamMemberIds: number[] | null;
|
||||
}) => {
|
||||
if (
|
||||
!rescheduleUid ||
|
||||
!rescheduleWithSameRoundRobinHost ||
|
||||
isRerouting({ rescheduleUid, routedTeamMemberIds })
|
||||
) {
|
||||
return hosts;
|
||||
}
|
||||
const originalRescheduledBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid: rescheduleUid,
|
||||
status: {
|
||||
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export interface IFilterHostsService {
|
||||
bookingRepo: BookingRepository;
|
||||
}
|
||||
|
||||
if (!originalRescheduledBooking) {
|
||||
return hosts;
|
||||
export class FilterHostsService {
|
||||
constructor(public readonly dependencies: IFilterHostsService) {}
|
||||
|
||||
async filterHostsBySameRoundRobinHost<
|
||||
T extends {
|
||||
isFixed: false; // ensure no fixed hosts are passed.
|
||||
user: { id: number; email: string };
|
||||
}
|
||||
>({
|
||||
hosts,
|
||||
rescheduleUid,
|
||||
rescheduleWithSameRoundRobinHost,
|
||||
routedTeamMemberIds,
|
||||
}: {
|
||||
hosts: T[];
|
||||
rescheduleUid: string | null;
|
||||
rescheduleWithSameRoundRobinHost: boolean;
|
||||
routedTeamMemberIds: number[] | null;
|
||||
}) {
|
||||
if (
|
||||
!rescheduleUid ||
|
||||
!rescheduleWithSameRoundRobinHost ||
|
||||
isRerouting({ rescheduleUid, routedTeamMemberIds })
|
||||
) {
|
||||
return hosts;
|
||||
}
|
||||
|
||||
const originalRescheduledBooking =
|
||||
await this.dependencies.bookingRepo.findOriginalRescheduledBookingUserId({
|
||||
rescheduleUid,
|
||||
});
|
||||
|
||||
if (!originalRescheduledBooking) {
|
||||
return hosts;
|
||||
}
|
||||
|
||||
const attendeeEmails = originalRescheduledBooking.attendees?.map((attendee) => attendee.email) || [];
|
||||
|
||||
return hosts.filter((host) => {
|
||||
const isOrganizer = host.user.id === originalRescheduledBooking.userId;
|
||||
const isAttendee = attendeeEmails.includes(host.user.email);
|
||||
return isOrganizer || isAttendee;
|
||||
});
|
||||
}
|
||||
|
||||
const attendeeEmails = originalRescheduledBooking.attendees?.map((attendee) => attendee.email) || [];
|
||||
|
||||
return hosts.filter((host) => {
|
||||
const isOrganizer = host.user.id === originalRescheduledBooking.userId;
|
||||
const isAttendee = attendeeEmails.includes(host.user.email);
|
||||
return isOrganizer || isAttendee;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { Mock } from "vitest";
|
||||
|
||||
import { RRResetInterval, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { getQualifiedHostsService } from "../di/containers/QualifiedHosts";
|
||||
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
|
||||
import { findQualifiedHostsWithDelegationCredentials } from "./findQualifiedHostsWithDelegationCredentials";
|
||||
import * as getRoutedUsers from "./getRoutedUsers";
|
||||
|
||||
// Mock the filterHostsByLeadThreshold function
|
||||
@@ -21,6 +21,7 @@ afterEach(() => {
|
||||
(filterHostsByLeadThreshold as Mock).mockClear();
|
||||
});
|
||||
|
||||
const qualifiedHostsService = getQualifiedHostsService();
|
||||
describe("findQualifiedHostsWithDelegationCredentials", async () => {
|
||||
it("should return qualified hosts based on mock of filterHostsByLeadThreshold", async () => {
|
||||
const hosts = [
|
||||
@@ -92,7 +93,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
|
||||
};
|
||||
|
||||
// Call the function under test
|
||||
const result = await findQualifiedHostsWithDelegationCredentials({
|
||||
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
routedTeamMemberIds: [],
|
||||
rescheduleUid: null,
|
||||
@@ -144,7 +145,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
|
||||
};
|
||||
|
||||
// Call the function under test
|
||||
const result = await findQualifiedHostsWithDelegationCredentials({
|
||||
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
routedTeamMemberIds: [],
|
||||
rescheduleUid: null,
|
||||
@@ -231,7 +232,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
|
||||
};
|
||||
|
||||
// Call the function under test
|
||||
const result = await findQualifiedHostsWithDelegationCredentials({
|
||||
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
routedTeamMemberIds: [],
|
||||
rescheduleUid: null,
|
||||
@@ -320,7 +321,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
|
||||
};
|
||||
|
||||
// Call the function under test
|
||||
const result = await findQualifiedHostsWithDelegationCredentials({
|
||||
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
routedTeamMemberIds: [1],
|
||||
rescheduleUid: null,
|
||||
@@ -398,7 +399,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
|
||||
};
|
||||
prismaMock.booking.findFirst.mockResolvedValue({ userId: 2 });
|
||||
|
||||
const result = await findQualifiedHostsWithDelegationCredentials({
|
||||
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
routedTeamMemberIds: [],
|
||||
rescheduleUid: "recheduleUid",
|
||||
@@ -478,7 +479,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
|
||||
.mockImplementation(async () => [hosts[0]]);
|
||||
|
||||
// Call the function under test
|
||||
const result = await findQualifiedHostsWithDelegationCredentials({
|
||||
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
routedTeamMemberIds: [0, 1, 2],
|
||||
rescheduleUid: null,
|
||||
@@ -572,7 +573,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
|
||||
]);
|
||||
|
||||
// Call the function under test
|
||||
const result = await findQualifiedHostsWithDelegationCredentials({
|
||||
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
routedTeamMemberIds: [2, 3],
|
||||
rescheduleUid: null,
|
||||
@@ -672,7 +673,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
|
||||
(filterHostsByLeadThreshold as Mock).mockResolvedValue(rrHostsAfterFairness);
|
||||
|
||||
// Call the function under test
|
||||
const result = await findQualifiedHostsWithDelegationCredentials({
|
||||
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
routedTeamMemberIds: [2, 3],
|
||||
rescheduleUid: null,
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { FilterHostsService } from "@calcom/lib/bookings/filterHostsBySameRoundRobinHost";
|
||||
import {
|
||||
findMatchingHostsWithEventSegment,
|
||||
getNormalizedHostsWithDelegationCredentials,
|
||||
} from "@calcom/lib/bookings/getRoutedUsers";
|
||||
import type { EventType } from "@calcom/lib/bookings/getRoutedUsers";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import type { SelectedCalendar } from "@calcom/prisma/client";
|
||||
import type { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import type { RoutingFormResponse } from "../server/getLuckyUser";
|
||||
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
|
||||
import { filterHostsBySameRoundRobinHost } from "./filterHostsBySameRoundRobinHost";
|
||||
|
||||
export interface IQualifiedHostsService {
|
||||
bookingRepo: BookingRepository;
|
||||
filterHostsService: FilterHostsService;
|
||||
}
|
||||
|
||||
type Host<T> = {
|
||||
isFixed: boolean;
|
||||
@@ -48,173 +54,174 @@ const isFixedHost = <T extends { isFixed: boolean }>(host: T): host is T & { isF
|
||||
return host.isFixed;
|
||||
};
|
||||
|
||||
const _findQualifiedHostsWithDelegationCredentials = async <
|
||||
T extends {
|
||||
email: string;
|
||||
id: number;
|
||||
credentials: CredentialPayload[];
|
||||
userLevelSelectedCalendars: SelectedCalendar[];
|
||||
} & Record<string, unknown>
|
||||
>({
|
||||
eventType,
|
||||
rescheduleUid,
|
||||
routedTeamMemberIds,
|
||||
contactOwnerEmail,
|
||||
routingFormResponse,
|
||||
}: {
|
||||
eventType: {
|
||||
id: number;
|
||||
maxLeadThreshold: number | null;
|
||||
hosts?: Host<T>[];
|
||||
users: T[];
|
||||
schedulingType: SchedulingType | null;
|
||||
isRRWeightsEnabled: boolean;
|
||||
rescheduleWithSameRoundRobinHost: boolean;
|
||||
includeNoShowInRRCalculation: boolean;
|
||||
} & EventType;
|
||||
rescheduleUid: string | null;
|
||||
routedTeamMemberIds: number[];
|
||||
contactOwnerEmail: string | null;
|
||||
routingFormResponse: RoutingFormResponse | null;
|
||||
}): Promise<{
|
||||
qualifiedRRHosts: {
|
||||
isFixed: boolean;
|
||||
createdAt: Date | null;
|
||||
priority?: number | null;
|
||||
weight?: number | null;
|
||||
groupId?: string | null;
|
||||
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
|
||||
}[];
|
||||
fixedHosts: {
|
||||
isFixed: boolean;
|
||||
createdAt: Date | null;
|
||||
priority?: number | null;
|
||||
weight?: number | null;
|
||||
groupId?: string | null;
|
||||
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
|
||||
}[];
|
||||
// all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner)
|
||||
allFallbackRRHosts?: {
|
||||
isFixed: boolean;
|
||||
createdAt: Date | null;
|
||||
priority?: number | null;
|
||||
weight?: number | null;
|
||||
groupId?: string | null;
|
||||
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
|
||||
}[];
|
||||
}> => {
|
||||
const { hosts: normalizedHosts, fallbackHosts: fallbackUsers } =
|
||||
await getNormalizedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
});
|
||||
// not a team event type, or some other reason - segment matching isn't necessary.
|
||||
if (!normalizedHosts) {
|
||||
const fixedHosts = fallbackUsers.filter(isFixedHost);
|
||||
const roundRobinHosts = fallbackUsers.filter(isRoundRobinHost);
|
||||
return { qualifiedRRHosts: roundRobinHosts, fixedHosts };
|
||||
}
|
||||
export class QualifiedHostsService {
|
||||
constructor(public readonly dependencies: IQualifiedHostsService) {}
|
||||
|
||||
const fixedHosts = normalizedHosts.filter(isFixedHost);
|
||||
const roundRobinHosts = normalizedHosts.filter(isRoundRobinHost);
|
||||
async _findQualifiedHostsWithDelegationCredentials<
|
||||
T extends {
|
||||
email: string;
|
||||
id: number;
|
||||
credentials: CredentialPayload[];
|
||||
userLevelSelectedCalendars: SelectedCalendar[];
|
||||
} & Record<string, unknown>
|
||||
>({
|
||||
eventType,
|
||||
rescheduleUid,
|
||||
routedTeamMemberIds,
|
||||
contactOwnerEmail,
|
||||
routingFormResponse,
|
||||
}: {
|
||||
eventType: {
|
||||
id: number;
|
||||
maxLeadThreshold?: number | null;
|
||||
hosts?: Host<T>[];
|
||||
users: T[];
|
||||
schedulingType: SchedulingType | null;
|
||||
isRRWeightsEnabled: boolean;
|
||||
rescheduleWithSameRoundRobinHost: boolean;
|
||||
includeNoShowInRRCalculation: boolean;
|
||||
} & EventType;
|
||||
rescheduleUid: string | null;
|
||||
routedTeamMemberIds: number[];
|
||||
contactOwnerEmail: string | null;
|
||||
routingFormResponse: RoutingFormResponse | null;
|
||||
}): Promise<{
|
||||
qualifiedRRHosts: {
|
||||
isFixed: boolean;
|
||||
createdAt: Date | null;
|
||||
priority?: number | null;
|
||||
weight?: number | null;
|
||||
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
|
||||
}[];
|
||||
fixedHosts: {
|
||||
isFixed: boolean;
|
||||
createdAt: Date | null;
|
||||
priority?: number | null;
|
||||
weight?: number | null;
|
||||
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
|
||||
}[];
|
||||
// all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner)
|
||||
allFallbackRRHosts?: {
|
||||
isFixed: boolean;
|
||||
createdAt: Date | null;
|
||||
priority?: number | null;
|
||||
weight?: number | null;
|
||||
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
|
||||
}[];
|
||||
}> {
|
||||
const { hosts: normalizedHosts, fallbackHosts: fallbackUsers } =
|
||||
await getNormalizedHostsWithDelegationCredentials({
|
||||
eventType,
|
||||
});
|
||||
// not a team event type, or some other reason - segment matching isn't necessary.
|
||||
if (!normalizedHosts) {
|
||||
const fixedHosts = fallbackUsers.filter(isFixedHost);
|
||||
const roundRobinHosts = fallbackUsers.filter(isRoundRobinHost);
|
||||
return { qualifiedRRHosts: roundRobinHosts, fixedHosts };
|
||||
}
|
||||
|
||||
// If it is rerouting, we should not force reschedule with same host.
|
||||
const hostsAfterRescheduleWithSameRoundRobinHost = applyFilterWithFallback(
|
||||
roundRobinHosts,
|
||||
await filterHostsBySameRoundRobinHost({
|
||||
hosts: roundRobinHosts,
|
||||
rescheduleUid,
|
||||
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
|
||||
routedTeamMemberIds,
|
||||
})
|
||||
);
|
||||
const fixedHosts = normalizedHosts.filter(isFixedHost);
|
||||
const roundRobinHosts = normalizedHosts.filter(isRoundRobinHost);
|
||||
|
||||
if (hostsAfterRescheduleWithSameRoundRobinHost.length === 1) {
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterRescheduleWithSameRoundRobinHost,
|
||||
fixedHosts,
|
||||
};
|
||||
}
|
||||
// If it is rerouting, we should not force reschedule with same host.
|
||||
const hostsAfterRescheduleWithSameRoundRobinHost = applyFilterWithFallback(
|
||||
roundRobinHosts,
|
||||
await this.dependencies.filterHostsService.filterHostsBySameRoundRobinHost({
|
||||
hosts: roundRobinHosts,
|
||||
rescheduleUid,
|
||||
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
|
||||
routedTeamMemberIds,
|
||||
})
|
||||
);
|
||||
|
||||
const hostsAfterSegmentMatching = applyFilterWithFallback(
|
||||
hostsAfterRescheduleWithSameRoundRobinHost,
|
||||
(await findMatchingHostsWithEventSegment({
|
||||
eventType,
|
||||
hosts: hostsAfterRescheduleWithSameRoundRobinHost,
|
||||
})) as typeof hostsAfterRescheduleWithSameRoundRobinHost
|
||||
);
|
||||
if (hostsAfterRescheduleWithSameRoundRobinHost.length === 1) {
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterRescheduleWithSameRoundRobinHost,
|
||||
fixedHosts,
|
||||
};
|
||||
}
|
||||
|
||||
if (hostsAfterSegmentMatching.length === 1) {
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterSegmentMatching,
|
||||
fixedHosts,
|
||||
};
|
||||
}
|
||||
const hostsAfterSegmentMatching = applyFilterWithFallback(
|
||||
hostsAfterRescheduleWithSameRoundRobinHost,
|
||||
(await findMatchingHostsWithEventSegment({
|
||||
eventType,
|
||||
hosts: hostsAfterRescheduleWithSameRoundRobinHost,
|
||||
})) as typeof hostsAfterRescheduleWithSameRoundRobinHost
|
||||
);
|
||||
|
||||
//if segment matching doesn't return any hosts we fall back to all round robin hosts
|
||||
const officalRRHosts = hostsAfterSegmentMatching.length
|
||||
? hostsAfterSegmentMatching
|
||||
: hostsAfterRescheduleWithSameRoundRobinHost;
|
||||
if (hostsAfterSegmentMatching.length === 1) {
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterSegmentMatching,
|
||||
fixedHosts,
|
||||
};
|
||||
}
|
||||
|
||||
const hostsAfterContactOwnerMatching = applyFilterWithFallback(
|
||||
officalRRHosts,
|
||||
officalRRHosts.filter((host) => host.user.email === contactOwnerEmail)
|
||||
);
|
||||
//if segment matching doesn't return any hosts we fall back to all round robin hosts
|
||||
const officalRRHosts = hostsAfterSegmentMatching.length
|
||||
? hostsAfterSegmentMatching
|
||||
: hostsAfterRescheduleWithSameRoundRobinHost;
|
||||
|
||||
const hostsAfterRoutedTeamMemberIdsMatching = applyFilterWithFallback(
|
||||
officalRRHosts,
|
||||
officalRRHosts.filter((host) => routedTeamMemberIds.includes(host.user.id))
|
||||
);
|
||||
const hostsAfterContactOwnerMatching = applyFilterWithFallback(
|
||||
officalRRHosts,
|
||||
officalRRHosts.filter((host) => host.user.email === contactOwnerEmail)
|
||||
);
|
||||
|
||||
const hostsAfterRoutedTeamMemberIdsMatching = applyFilterWithFallback(
|
||||
officalRRHosts,
|
||||
officalRRHosts.filter((host) => routedTeamMemberIds.includes(host.user.id))
|
||||
);
|
||||
|
||||
if (hostsAfterRoutedTeamMemberIdsMatching.length === 1) {
|
||||
if (hostsAfterContactOwnerMatching.length === 1) {
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterContactOwnerMatching,
|
||||
allFallbackRRHosts: getFallBackWithContactOwner(
|
||||
hostsAfterRoutedTeamMemberIdsMatching,
|
||||
hostsAfterContactOwnerMatching[0]
|
||||
),
|
||||
fixedHosts,
|
||||
};
|
||||
}
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterRoutedTeamMemberIdsMatching,
|
||||
fixedHosts,
|
||||
};
|
||||
}
|
||||
|
||||
const hostsAfterFairnessMatching = applyFilterWithFallback(
|
||||
hostsAfterRoutedTeamMemberIdsMatching,
|
||||
await filterHostsByLeadThreshold({
|
||||
eventType,
|
||||
hosts: hostsAfterRoutedTeamMemberIdsMatching,
|
||||
maxLeadThreshold: eventType.maxLeadThreshold ?? null,
|
||||
routingFormResponse,
|
||||
})
|
||||
);
|
||||
|
||||
if (hostsAfterRoutedTeamMemberIdsMatching.length === 1) {
|
||||
if (hostsAfterContactOwnerMatching.length === 1) {
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterContactOwnerMatching,
|
||||
allFallbackRRHosts: getFallBackWithContactOwner(
|
||||
hostsAfterRoutedTeamMemberIdsMatching,
|
||||
hostsAfterFairnessMatching,
|
||||
hostsAfterContactOwnerMatching[0]
|
||||
),
|
||||
fixedHosts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterRoutedTeamMemberIdsMatching,
|
||||
qualifiedRRHosts: hostsAfterFairnessMatching,
|
||||
// only if fairness filtering is active
|
||||
allFallbackRRHosts:
|
||||
hostsAfterFairnessMatching.length !== hostsAfterRoutedTeamMemberIdsMatching.length
|
||||
? hostsAfterRoutedTeamMemberIdsMatching
|
||||
: undefined,
|
||||
fixedHosts,
|
||||
};
|
||||
}
|
||||
|
||||
const hostsAfterFairnessMatching = applyFilterWithFallback(
|
||||
hostsAfterRoutedTeamMemberIdsMatching,
|
||||
await filterHostsByLeadThreshold({
|
||||
eventType,
|
||||
hosts: hostsAfterRoutedTeamMemberIdsMatching,
|
||||
maxLeadThreshold: eventType.maxLeadThreshold,
|
||||
routingFormResponse,
|
||||
})
|
||||
findQualifiedHostsWithDelegationCredentials = withReporting(
|
||||
this._findQualifiedHostsWithDelegationCredentials.bind(this),
|
||||
"findQualifiedHostsWithDelegationCredentials"
|
||||
);
|
||||
|
||||
if (hostsAfterContactOwnerMatching.length === 1) {
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterContactOwnerMatching,
|
||||
allFallbackRRHosts: getFallBackWithContactOwner(
|
||||
hostsAfterFairnessMatching,
|
||||
hostsAfterContactOwnerMatching[0]
|
||||
),
|
||||
fixedHosts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
qualifiedRRHosts: hostsAfterFairnessMatching,
|
||||
// only if fairness filtering is active
|
||||
allFallbackRRHosts:
|
||||
hostsAfterFairnessMatching.length !== hostsAfterRoutedTeamMemberIdsMatching.length
|
||||
? hostsAfterRoutedTeamMemberIdsMatching
|
||||
: undefined,
|
||||
fixedHosts,
|
||||
};
|
||||
};
|
||||
|
||||
export const findQualifiedHostsWithDelegationCredentials = withReporting(
|
||||
_findQualifiedHostsWithDelegationCredentials,
|
||||
"findQualifiedHostsWithDelegationCredentials"
|
||||
);
|
||||
}
|
||||
|
||||
+19
-14
@@ -5,20 +5,22 @@ import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
import type { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
|
||||
import { availableSlotsModule } from "../modules/available-slots";
|
||||
import { bookingRepositoryModule } from "../modules/booking";
|
||||
import { busyTimesModule } from "../modules/busy-times";
|
||||
import { cacheModule } from "../modules/cache";
|
||||
import { checkBookingLimitsModule } from "../modules/check-booking-limits";
|
||||
import { eventTypeRepositoryModule } from "../modules/eventType";
|
||||
import { featuresRepositoryModule } from "../modules/features";
|
||||
import { getUserAvailabilityModule } from "../modules/get-user-availability";
|
||||
import { oooRepositoryModule } from "../modules/ooo";
|
||||
import { routingFormResponseRepositoryModule } from "../modules/routingFormResponse";
|
||||
import { scheduleRepositoryModule } from "../modules/schedule";
|
||||
import { selectedSlotsRepositoryModule } from "../modules/selectedSlots";
|
||||
import { teamRepositoryModule } from "../modules/team";
|
||||
import { userRepositoryModule } from "../modules/user";
|
||||
import { availableSlotsModule } from "../modules/AvailableSlots";
|
||||
import { bookingRepositoryModule } from "../modules/Booking";
|
||||
import { busyTimesModule } from "../modules/BusyTimes";
|
||||
import { cacheModule } from "../modules/Cache";
|
||||
import { checkBookingLimitsModule } from "../modules/CheckBookingLimits";
|
||||
import { eventTypeRepositoryModule } from "../modules/EventType";
|
||||
import { featuresRepositoryModule } from "../modules/Features";
|
||||
import { filterHostsModule } from "../modules/FilterHosts";
|
||||
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
|
||||
import { oooRepositoryModule } from "../modules/Ooo";
|
||||
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
|
||||
import { routingFormResponseRepositoryModule } from "../modules/RoutingFormResponse";
|
||||
import { scheduleRepositoryModule } from "../modules/Schedule";
|
||||
import { selectedSlotsRepositoryModule } from "../modules/SelectedSlots";
|
||||
import { teamRepositoryModule } from "../modules/Team";
|
||||
import { userRepositoryModule } from "../modules/User";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
|
||||
@@ -37,6 +39,9 @@ container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimits
|
||||
container.load(DI_TOKENS.AVAILABLE_SLOTS_SERVICE_MODULE, availableSlotsModule);
|
||||
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule);
|
||||
container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
|
||||
container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
|
||||
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
|
||||
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
|
||||
|
||||
export function getAvailableSlotsService() {
|
||||
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
|
||||
+3
-3
@@ -5,9 +5,9 @@ import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import type { CheckBookingAndDurationLimitsService } from "../../../features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits";
|
||||
import type { CheckBookingLimitsService } from "../../intervalLimits/server/checkBookingLimits";
|
||||
import { bookingRepositoryModule } from "../modules/booking";
|
||||
import { checkBookingAndDurationLimitsModule } from "../modules/check-booking-and-duration-limits";
|
||||
import { checkBookingLimitsModule } from "../modules/check-booking-limits";
|
||||
import { bookingRepositoryModule } from "../modules/Booking";
|
||||
import { checkBookingAndDurationLimitsModule } from "../modules/CheckBookingAndDurationLimits";
|
||||
import { checkBookingLimitsModule } from "../modules/CheckBookingLimits";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
@@ -4,8 +4,8 @@ import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import type { BusyTimesService } from "../../getBusyTimes";
|
||||
import { bookingRepositoryModule } from "../modules/booking";
|
||||
import { busyTimesModule } from "../modules/busy-times";
|
||||
import { bookingRepositoryModule } from "../modules/Booking";
|
||||
import { busyTimesModule } from "../modules/BusyTimes";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
@@ -4,8 +4,8 @@ import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import type { CacheService } from "../../../features/calendar-cache/lib/getShouldServeCache";
|
||||
import { cacheModule } from "../modules/cache";
|
||||
import { featuresRepositoryModule } from "../modules/features";
|
||||
import { cacheModule } from "../modules/Cache";
|
||||
import { featuresRepositoryModule } from "../modules/Features";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createContainer } from "@evyweb/ioctopus";
|
||||
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import type { FilterHostsService } from "../../bookings/filterHostsBySameRoundRobinHost";
|
||||
import { bookingRepositoryModule } from "../modules/Booking";
|
||||
import { filterHostsModule } from "../modules/FilterHosts";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
|
||||
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
|
||||
|
||||
export function getFilterHostsService() {
|
||||
return container.get<FilterHostsService>(DI_TOKENS.FILTER_HOSTS_SERVICE);
|
||||
}
|
||||
+5
-5
@@ -5,11 +5,11 @@ import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import type { UserAvailabilityService } from "../../getUserAvailability";
|
||||
import { bookingRepositoryModule } from "../modules/booking";
|
||||
import { busyTimesModule } from "../modules/busy-times";
|
||||
import { eventTypeRepositoryModule } from "../modules/eventType";
|
||||
import { getUserAvailabilityModule } from "../modules/get-user-availability";
|
||||
import { oooRepositoryModule } from "../modules/ooo";
|
||||
import { bookingRepositoryModule } from "../modules/Booking";
|
||||
import { busyTimesModule } from "../modules/BusyTimes";
|
||||
import { eventTypeRepositoryModule } from "../modules/EventType";
|
||||
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
|
||||
import { oooRepositoryModule } from "../modules/Ooo";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
+1
-1
@@ -9,7 +9,7 @@ import type {
|
||||
import type { InsightsBookingService } from "@calcom/lib/server/service/InsightsBookingDIService";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import { insightsBookingModule } from "../modules/insights-booking";
|
||||
import { insightsBookingModule } from "../modules/InsightsBooking";
|
||||
|
||||
export function getInsightsBookingService({
|
||||
options,
|
||||
+1
-1
@@ -9,7 +9,7 @@ import type {
|
||||
import type { InsightsRoutingService } from "@calcom/lib/server/service/insightsRoutingDI";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import { insightsRoutingModule } from "../modules/insights-routing";
|
||||
import { insightsRoutingModule } from "../modules/InsightsRouting";
|
||||
|
||||
export function getInsightsRoutingService({
|
||||
options,
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContainer } from "@evyweb/ioctopus";
|
||||
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import type { QualifiedHostsService } from "../../bookings/findQualifiedHostsWithDelegationCredentials";
|
||||
import { bookingRepositoryModule } from "../modules/Booking";
|
||||
import { filterHostsModule } from "../modules/FilterHosts";
|
||||
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
|
||||
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
|
||||
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
|
||||
|
||||
export function getQualifiedHostsService() {
|
||||
return container.get<QualifiedHostsService>(DI_TOKENS.QUALIFIED_HOSTS_SERVICE);
|
||||
}
|
||||
@@ -20,4 +20,6 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
|
||||
checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE,
|
||||
userAvailabilityService: DI_TOKENS.GET_USER_AVAILABILITY_SERVICE,
|
||||
busyTimesService: DI_TOKENS.BUSY_TIMES_SERVICE,
|
||||
featuresRepo: DI_TOKENS.FEATURES_REPOSITORY,
|
||||
qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE,
|
||||
} satisfies Record<keyof IAvailableSlotsService, symbol>);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user