Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee1904bb2c | ||
|
|
aaa59f1455 | ||
|
|
e29b7e83b8 | ||
|
|
6af473a486 | ||
|
|
bf8f966811 | ||
|
|
c64b663514 | ||
|
|
29314cde75 | ||
|
|
3ea73e75d6 | ||
|
|
d18e23378b | ||
|
|
736f1f594c | ||
|
|
09b1643478 | ||
|
|
86f6e8bac3 | ||
|
|
9c65d02e84 | ||
|
|
be9ab02a7e | ||
|
|
cfefba6d4d | ||
|
|
815e13303b | ||
|
|
7c8dbb3ea3 | ||
|
|
cabd459682 | ||
|
|
33aedbae20 | ||
|
|
d3fbc73a39 | ||
|
|
cfda4757e4 | ||
|
|
895ed48bd6 | ||
|
|
eda328d196 | ||
|
|
1c501d1501 | ||
|
|
08a57f32c5 | ||
|
|
b5a26bc227 | ||
|
|
737b019199 | ||
|
|
b41031626f | ||
|
|
a1fc4bccb9 | ||
|
|
d764b6a32d | ||
|
|
87cb8b170a | ||
|
|
badd0de33c | ||
|
|
124fd8ebca | ||
|
|
5d0dc3f546 | ||
|
|
e4baf15b53 | ||
|
|
a1bd8b6259 | ||
|
|
7987c9f45d | ||
|
|
781d0d92df | ||
|
|
b71d8baccc | ||
|
|
a71d949a0c | ||
|
|
e55cee443b | ||
|
|
c3634b3aba | ||
|
|
cd576f5378 |
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@calcom/atoms": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
booker atom: allow toggling org and team info when booking round robin
|
||||||
+5
-1
@@ -1 +1,5 @@
|
|||||||
module.exports = require("./packages/config/eslint-preset");
|
// This configuration only applies to the package manager root.
|
||||||
|
/** @type {import("eslint").Linter.Config} */
|
||||||
|
module.exports = {
|
||||||
|
extends: ["./packages/config/eslint-preset.js"],
|
||||||
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ env:
|
|||||||
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
|
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
|
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -191,8 +191,9 @@ const availabilitySchema = z
|
|||||||
async function handler(req: NextApiRequest) {
|
async function handler(req: NextApiRequest) {
|
||||||
const { isSystemWideAdmin, userId: reqUserId } = req;
|
const { isSystemWideAdmin, userId: reqUserId } = req;
|
||||||
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
||||||
|
const userAvailabilityService = getUserAvailabilityService();
|
||||||
if (!teamId)
|
if (!teamId)
|
||||||
return getUserAvailability({
|
return userAvailabilityService.getUserAvailability({
|
||||||
username,
|
username,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
@@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
const availabilities = members.map(async (user) => {
|
const availabilities = members.map(async (user) => {
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
availability: await getUserAvailability({
|
availability: await userAvailabilityService.getUserAvailability({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
|
|||||||
@@ -42,15 +42,17 @@ $ yarn prisma generate
|
|||||||
|
|
||||||
Copy `.env.example` to `.env` and fill values.
|
Copy `.env.example` to `.env` and fill values.
|
||||||
|
|
||||||
## Add license Key to deployments table in DB
|
## Add license Key to Deployment table in DB
|
||||||
|
|
||||||
id, logo theme licenseKey agreedLicenseAt
|
id, logo, theme, licenseKey, agreedLicenseAt:-
|
||||||
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
|
1, null, null, '00000000-0000-0000-0000-000000000000', '2023-05-15 21:39:47.611'
|
||||||
|
|
||||||
|
Replace with your actual license key.
|
||||||
|
|
||||||
your CALCOM_LICENSE_KEY env var need to contain the same value
|
your CALCOM_LICENSE_KEY env var need to contain the same value
|
||||||
|
|
||||||
.env
|
.env
|
||||||
CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb
|
CALCOM_LICENSE_KEY=00000000-0000-0000-0000-000000000000
|
||||||
|
|
||||||
## Running the app
|
## Running the app
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.291",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
@@ -98,6 +98,8 @@
|
|||||||
"@types/luxon": "^3.3.7",
|
"@types/luxon": "^3.3.7",
|
||||||
"@types/passport-jwt": "^3.0.13",
|
"@types/passport-jwt": "^3.0.13",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6",
|
||||||
|
"@typescript-eslint/parser": "^6",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-date-mock": "^1.0.10",
|
"jest-date-mock": "^1.0.10",
|
||||||
"node-mocks-http": "^1.16.2",
|
"node-mocks-http": "^1.16.2",
|
||||||
|
|||||||
@@ -104,10 +104,11 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
const rescheduledToInfo = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
|
||||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
|
||||||
|
|
||||||
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
|
const rescheduledByEmail = databaseBooking.rescheduled
|
||||||
|
? rescheduledToInfo?.rescheduledBy
|
||||||
|
: databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
@@ -157,13 +158,12 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
|
|
||||||
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
|
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
|
||||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||||
return {
|
return {
|
||||||
uid: rescheduledTo?.uid,
|
uid: rescheduledTo?.uid,
|
||||||
rescheduledBy: rescheduledTo?.rescheduledBy
|
rescheduledBy: rescheduledTo?.rescheduledBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||||
if (databaseMetadata === null) return {};
|
if (databaseMetadata === null) return {};
|
||||||
|
|
||||||
@@ -279,9 +279,11 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
const rescheduledToInfo = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
const rescheduledByEmail = databaseBooking.rescheduled
|
||||||
|
? rescheduledToInfo?.rescheduledBy
|
||||||
|
: databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
|
|||||||
+4
-1
@@ -290,7 +290,10 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(responseBookingFields).toBeDefined();
|
expect(responseBookingFields).toBeDefined();
|
||||||
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
||||||
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
||||||
expect(responseBookingField).toEqual(bookingFields[0]);
|
const fields = responseBookingField
|
||||||
|
//@ts-ignore
|
||||||
|
delete fields.labelAsSafeHtml
|
||||||
|
expect(fields).toEqual(bookingFields[0]);
|
||||||
eventType.bookingFields = responseBookingFields;
|
eventType.bookingFields = responseBookingFields;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
|
|||||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||||
|
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||||
import { CacheService } from "@/lib/services/cache.service";
|
import { CacheService } from "@/lib/services/cache.service";
|
||||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
|
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -24,10 +27,13 @@ import { Module } from "@nestjs/common";
|
|||||||
PrismaEventTypeRepository,
|
PrismaEventTypeRepository,
|
||||||
PrismaRoutingFormResponseRepository,
|
PrismaRoutingFormResponseRepository,
|
||||||
PrismaTeamRepository,
|
PrismaTeamRepository,
|
||||||
|
RedisService,
|
||||||
PrismaFeaturesRepository,
|
PrismaFeaturesRepository,
|
||||||
CheckBookingLimitsService,
|
CheckBookingLimitsService,
|
||||||
CacheService,
|
CacheService,
|
||||||
AvailableSlotsService,
|
AvailableSlotsService,
|
||||||
|
UserAvailabilityService,
|
||||||
|
BusyTimesService,
|
||||||
],
|
],
|
||||||
exports: [AvailableSlotsService],
|
exports: [AvailableSlotsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.rep
|
|||||||
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
||||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
|
import { BusyTimesService } from "@/lib/services/busy-times.service";
|
||||||
import { CacheService } from "@/lib/services/cache.service";
|
import { CacheService } from "@/lib/services/cache.service";
|
||||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||||
|
|
||||||
|
import { UserAvailabilityService } from "./user-availability.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -24,6 +28,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
selectedSlotRepository: PrismaSelectedSlotRepository,
|
selectedSlotRepository: PrismaSelectedSlotRepository,
|
||||||
eventTypeRepository: PrismaEventTypeRepository,
|
eventTypeRepository: PrismaEventTypeRepository,
|
||||||
userRepository: PrismaUserRepository,
|
userRepository: PrismaUserRepository,
|
||||||
|
redisService: RedisService,
|
||||||
featuresRepository: PrismaFeaturesRepository
|
featuresRepository: PrismaFeaturesRepository
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
@@ -35,8 +40,16 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
selectedSlotRepo: selectedSlotRepository,
|
selectedSlotRepo: selectedSlotRepository,
|
||||||
eventTypeRepo: eventTypeRepository,
|
eventTypeRepo: eventTypeRepository,
|
||||||
userRepo: userRepository,
|
userRepo: userRepository,
|
||||||
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
|
redisClient: redisService,
|
||||||
|
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
|
||||||
cacheService: new CacheService(featuresRepository),
|
cacheService: new CacheService(featuresRepository),
|
||||||
|
userAvailabilityService: new UserAvailabilityService(
|
||||||
|
oooRepoDependency,
|
||||||
|
bookingRepository,
|
||||||
|
eventTypeRepository,
|
||||||
|
redisService
|
||||||
|
),
|
||||||
|
busyTimesService: new BusyTimesService(bookingRepository),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { BusyTimesService as BaseBusyTimesService } from "@calcom/platform-libraries/slots";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BusyTimesService extends BaseBusyTimesService {
|
||||||
|
constructor(bookingRepository: PrismaBookingRepository) {
|
||||||
|
super({
|
||||||
|
bookingRepo: bookingRepository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserAvailabilityService extends BaseUserAvailabilityService {
|
||||||
|
constructor(
|
||||||
|
oooRepoDependency: PrismaOOORepository,
|
||||||
|
bookingRepository: PrismaBookingRepository,
|
||||||
|
eventTypeRepository: PrismaEventTypeRepository,
|
||||||
|
redisService: RedisService
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
oooRepo: oooRepoDependency,
|
||||||
|
bookingRepo: bookingRepository,
|
||||||
|
eventTypeRepo: eventTypeRepository,
|
||||||
|
redisClient: redisService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
fromReschedule?: string | null;
|
fromReschedule?: string | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
if (this.configService.get("e2e")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const { uid, startTime, fromReschedule } = booking;
|
const { uid, startTime, fromReschedule } = booking;
|
||||||
|
|
||||||
const delay = startTime.getTime() - Date.now();
|
const delay = startTime.getTime() - Date.now();
|
||||||
@@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
* Removing an attendee from a booking does not cancel the usage increment job.
|
* Removing an attendee from a booking does not cancel the usage increment job.
|
||||||
*/
|
*/
|
||||||
async cancelUsageByBookingUid(bookingUid: string) {
|
async cancelUsageByBookingUid(bookingUid: string) {
|
||||||
|
if (this.configService.get("e2e")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
||||||
if (job) {
|
if (job) {
|
||||||
await job.remove();
|
await job.remove();
|
||||||
@@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
await this.billingQueue.close();
|
await this.billingQueue.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
|
|||||||
@@ -7,12 +7,32 @@ import { Redis } from "ioredis";
|
|||||||
export class RedisService implements OnModuleDestroy {
|
export class RedisService implements OnModuleDestroy {
|
||||||
public redis: Redis;
|
public redis: Redis;
|
||||||
private readonly logger = new Logger("RedisService");
|
private readonly logger = new Logger("RedisService");
|
||||||
|
private isReady = false; // Track connection status
|
||||||
|
|
||||||
constructor(readonly configService: ConfigService<AppConfig>) {
|
constructor(readonly configService: ConfigService<AppConfig>) {
|
||||||
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
|
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
|
||||||
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
|
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
|
||||||
|
|
||||||
this.redis = new Redis(dbUrl);
|
this.redis = new Redis(dbUrl);
|
||||||
|
|
||||||
|
this.redis.on("error", (err) => {
|
||||||
|
this.logger.error(`IoRedis connection error: ${err.message}`);
|
||||||
|
this.isReady = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on("connect", () => {
|
||||||
|
this.logger.log("IoRedis connected!");
|
||||||
|
this.isReady = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on("reconnecting", (delay: string) => {
|
||||||
|
this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on("end", () => {
|
||||||
|
this.logger.warn("IoRedis connection ended.");
|
||||||
|
this.isReady = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
@@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy {
|
|||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async get<TData>(key: string): Promise<TData | null> {
|
||||||
|
let data = null;
|
||||||
|
if (!this.isReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = await this.redis.get(key);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as TData;
|
||||||
|
} catch (e) {
|
||||||
|
return data as TData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<number> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return this.redis.del(key);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||||
|
if (opts?.ttl) {
|
||||||
|
await this.redis.set(key, stringifiedValue, "PX", opts.ttl);
|
||||||
|
} else {
|
||||||
|
await this.redis.set(key, stringifiedValue);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
|
|
||||||
|
async expire(key: string, seconds: number): Promise<0 | 1> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return this.redis.expire(key, seconds) as Promise<0 | 1>;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const results = await this.redis.lrange(key, start, end);
|
||||||
|
return results.map((item) => JSON.parse(item) as TResult);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stringifiedElements = elements.map((element) =>
|
||||||
|
typeof element === "object" ? JSON.stringify(element) : String(element)
|
||||||
|
);
|
||||||
|
return this.redis.lpush(key, ...stringifiedElements);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,4 +36,5 @@ process.env = {
|
|||||||
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
|
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
|
||||||
INTEGRATION_TEST_MODE: "true",
|
INTEGRATION_TEST_MODE: "true",
|
||||||
e2e: "true",
|
e2e: "true",
|
||||||
|
SLOTS_CACHE_TTL: "1"
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
export async function revalidateWebhooksListGetByViewer() {
|
export async function revalidateWebhooksList() {
|
||||||
revalidateTag("viewer.webhook.getByViewer");
|
revalidatePath("/settings/developer/webhooks");
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-16
@@ -1,13 +1,13 @@
|
|||||||
|
import { createRouterCaller } from "app/_trpc/context";
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
import { unstable_cache } from "next/cache";
|
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
|
||||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||||
|
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -20,17 +20,6 @@ export const generateMetadata = async () =>
|
|||||||
"/settings/developer/webhooks"
|
"/settings/developer/webhooks"
|
||||||
);
|
);
|
||||||
|
|
||||||
const getCachedWebhooksList = unstable_cache(
|
|
||||||
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
|
|
||||||
return await WebhookRepository.getAllWebhooksByUserId({
|
|
||||||
userId,
|
|
||||||
userRole,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const WebhooksViewServerWrapper = async () => {
|
const WebhooksViewServerWrapper = async () => {
|
||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -38,9 +27,8 @@ const WebhooksViewServerWrapper = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||||
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
|
const caller = await createRouterCaller(webhookRouter);
|
||||||
|
const data = await caller.getByViewer();
|
||||||
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
|
|
||||||
|
|
||||||
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
||||||
};
|
};
|
||||||
|
|||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
|
||||||
|
|
||||||
export async function revalidateWebhookById(id: string) {
|
|
||||||
revalidateTag(`viewer.webhook.get:${id}`);
|
|
||||||
}
|
|
||||||
+1
-14
@@ -1,6 +1,5 @@
|
|||||||
import type { PageProps } from "app/_types";
|
import type { PageProps } from "app/_types";
|
||||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
import { getTranslate, _generateMetadata } from "app/_utils";
|
||||||
import { unstable_cache } from "next/cache";
|
|
||||||
|
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
||||||
@@ -16,24 +15,12 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
|||||||
`/settings/developer/webhooks/${(await params).id}`
|
`/settings/developer/webhooks/${(await params).id}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const getCachedWebhook = (id?: string) => {
|
|
||||||
const fn = unstable_cache(
|
|
||||||
async () => {
|
|
||||||
return await WebhookRepository.findByWebhookId(id);
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
|
|
||||||
);
|
|
||||||
|
|
||||||
return fn();
|
|
||||||
};
|
|
||||||
|
|
||||||
const Page = async ({ params: _params }: PageProps) => {
|
const Page = async ({ params: _params }: PageProps) => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const params = await _params;
|
const params = await _params;
|
||||||
const id = typeof params?.id === "string" ? params.id : undefined;
|
const id = typeof params?.id === "string" ? params.id : undefined;
|
||||||
|
|
||||||
const webhook = await getCachedWebhook(id);
|
const webhook = await WebhookRepository.findByWebhookId(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
|
|||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
|
||||||
|
|
||||||
export async function revalidateWebhookList() {
|
|
||||||
revalidateTag("viewer.webhook.list");
|
|
||||||
}
|
|
||||||
+6
-22
@@ -1,16 +1,10 @@
|
|||||||
import { createRouterCaller } from "app/_trpc/context";
|
import { createRouterCaller } from "app/_trpc/context";
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
import { unstable_cache } from "next/cache";
|
|
||||||
import { cookies, headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
|
||||||
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
|
||||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||||
|
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -21,25 +15,15 @@ export const generateMetadata = async () =>
|
|||||||
"/settings/developer/webhooks/new"
|
"/settings/developer/webhooks/new"
|
||||||
);
|
);
|
||||||
|
|
||||||
const getCachedWebhooksList = unstable_cache(
|
|
||||||
async ({ userId }: { userId: number }) => {
|
|
||||||
return await WebhookRepository.findWebhooksByFilters({ userId });
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
const [appsCaller, webhookCaller] = await Promise.all([
|
||||||
if (!session?.user?.id) {
|
createRouterCaller(appsRouter),
|
||||||
redirect("/auth/login");
|
createRouterCaller(webhookRouter),
|
||||||
}
|
]);
|
||||||
|
|
||||||
const appsCaller = await createRouterCaller(appsRouter);
|
|
||||||
|
|
||||||
const [installedApps, webhooks] = await Promise.all([
|
const [installedApps, webhooks] = await Promise.all([
|
||||||
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
||||||
getCachedWebhooksList({ userId: session.user.id }),
|
webhookCaller.list(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
|
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetReq
|
|||||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||||
|
|
||||||
async function handler(req: NextRequest) {
|
async function handler(req: NextRequest) {
|
||||||
const body = await parseRequestData(req);
|
const body = await parseRequestData(req);
|
||||||
@@ -28,7 +29,7 @@ async function handler(req: NextRequest) {
|
|||||||
|
|
||||||
await checkRateLimitAndThrowError({
|
await checkRateLimitAndThrowError({
|
||||||
rateLimitingType: "core",
|
rateLimitingType: "core",
|
||||||
identifier: ip,
|
identifier: piiHasher.hash(ip),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
|
||||||
// This is the callback endpoint for the OIDC provider
|
// This is the callback endpoint for the OIDC provider
|
||||||
// A team must set this endpoint in the OIDC provider's configuration
|
// A team must set this endpoint in the OIDC provider's configuration
|
||||||
async function handler(req: NextRequest) {
|
async function handler(req: NextRequest) {
|
||||||
|
const log = logger.getSubLogger({ prefix: ["[ODIC auth]"] });
|
||||||
const { searchParams } = req.nextUrl;
|
const { searchParams } = req.nextUrl;
|
||||||
const code = searchParams.get("code");
|
const code = searchParams.get("code");
|
||||||
const state = searchParams.get("state");
|
const state = searchParams.get("state");
|
||||||
|
const tenant = searchParams.get("tenant");
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
return NextResponse.json({ message: "Code and state are required" }, { status: 400 });
|
return NextResponse.json({ message: "Code and state are required" }, { status: 400 });
|
||||||
@@ -30,6 +33,7 @@ async function handler(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.redirect(redirect_url, 302);
|
return NextResponse.redirect(redirect_url, 302);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
log.error(`Error authorizing tenant ${tenant}: ${err}`);
|
||||||
const { message, statusCode = 500 } = err as HttpError;
|
const { message, statusCode = 500 } = err as HttpError;
|
||||||
|
|
||||||
return NextResponse.json({ message }, { status: statusCode });
|
return NextResponse.json({ message }, { status: statusCode });
|
||||||
|
|||||||
@@ -5,17 +5,20 @@ import { NextResponse } from "next/server";
|
|||||||
import type { OAuthReq } from "@calcom/features/ee/sso/lib/jackson";
|
import type { OAuthReq } from "@calcom/features/ee/sso/lib/jackson";
|
||||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||||
import type { HttpError } from "@calcom/lib/http-error";
|
import type { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
|
||||||
async function handler(req: NextRequest) {
|
async function handler(req: NextRequest) {
|
||||||
|
const log = logger.getSubLogger({ prefix: ["[SAML authorize]"] });
|
||||||
const { oauthController } = await jackson();
|
const { oauthController } = await jackson();
|
||||||
|
|
||||||
|
const oAuthReq = Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { redirect_url } = await oauthController.authorize(
|
const { redirect_url } = await oauthController.authorize(oAuthReq);
|
||||||
Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.redirect(redirect_url as string, 302);
|
return NextResponse.redirect(redirect_url as string, 302);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
log.error(`Error initaiting SAML login for tenant ${oAuthReq?.tenant}: ${err}`);
|
||||||
const { message, statusCode = 500 } = err as HttpError;
|
const { message, statusCode = 500 } = err as HttpError;
|
||||||
|
|
||||||
return NextResponse.json({ message }, { status: statusCode });
|
return NextResponse.json({ message }, { status: statusCode });
|
||||||
|
|||||||
@@ -2,21 +2,32 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
|||||||
import { parseRequestData } from "app/api/parseRequestData";
|
import { parseRequestData } from "app/api/parseRequestData";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { uuid } from "short-uuid";
|
||||||
|
|
||||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||||
import type { SAMLResponsePayload } from "@calcom/features/ee/sso/lib/jackson";
|
import type { SAMLResponsePayload } from "@calcom/features/ee/sso/lib/jackson";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
|
||||||
async function handler(req: NextRequest) {
|
async function handler(req: NextRequest) {
|
||||||
|
const log = logger.getSubLogger({ prefix: ["[SAML callback]"] });
|
||||||
const { oauthController } = await jackson();
|
const { oauthController } = await jackson();
|
||||||
|
|
||||||
const { redirect_url } = await oauthController.samlResponse(
|
const requestData = (await parseRequestData(req)) as SAMLResponsePayload;
|
||||||
(await parseRequestData(req)) as SAMLResponsePayload
|
|
||||||
);
|
const { redirect_url, error } = await oauthController.samlResponse(requestData);
|
||||||
|
|
||||||
if (redirect_url) {
|
if (redirect_url) {
|
||||||
return NextResponse.redirect(redirect_url, 302);
|
return NextResponse.redirect(redirect_url, 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const uid = uuid();
|
||||||
|
log.error(
|
||||||
|
`Error authenticating user with error ${error} for relayState ${requestData?.RelayState} trace:${uid}`
|
||||||
|
);
|
||||||
|
return NextResponse.json({ message: `Error authorizing user. trace: ${uid}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: "No redirect URL provided" }, { status: 400 });
|
return NextResponse.json({ message: "No redirect URL provided" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
|||||||
import { parseRequestData } from "app/api/parseRequestData";
|
import { parseRequestData } from "app/api/parseRequestData";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { uuid } from "short-uuid";
|
||||||
|
|
||||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||||
import type { OAuthTokenReq } from "@calcom/features/ee/sso/lib/jackson";
|
import type { OAuthTokenReq } from "@calcom/features/ee/sso/lib/jackson";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
|
||||||
async function handler(req: NextRequest) {
|
async function handler(req: NextRequest) {
|
||||||
const { oauthController } = await jackson();
|
const { oauthController } = await jackson();
|
||||||
const tokenResponse = await oauthController.token((await parseRequestData(req)) as OAuthTokenReq);
|
const log = logger.getSubLogger({ prefix: ["[SAML token]"] });
|
||||||
return NextResponse.json(tokenResponse);
|
|
||||||
|
const oauthTokenReq = (await parseRequestData(req)) as OAuthTokenReq;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await oauthController.token(oauthTokenReq);
|
||||||
|
return NextResponse.json(tokenResponse);
|
||||||
|
} catch (error) {
|
||||||
|
const uid = uuid();
|
||||||
|
log.error(`Error getting auth token for client id ${oauthTokenReq?.client_id}: ${error} trace: ${uid}`);
|
||||||
|
throw new Error(`Error getting auth token with error ${error} trace: ${uid}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST = defaultResponderForAppDir(handler);
|
export const POST = defaultResponderForAppDir(handler);
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import z from "zod";
|
import { uuid } from "short-uuid";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
|
||||||
const extractAuthToken = (req: NextRequest) => {
|
const extractAuthToken = (req: NextRequest) => {
|
||||||
|
const log = logger.getSubLogger({ prefix: ["SAML extractAuthToken"] });
|
||||||
|
const uid = uuid();
|
||||||
const authHeader = req.headers.get("authorization");
|
const authHeader = req.headers.get("authorization");
|
||||||
const parts = (authHeader || "").split(" ");
|
const parts = (authHeader || "").split(" ");
|
||||||
if (parts.length > 1) return parts[1];
|
if (parts.length > 1) return parts[1];
|
||||||
|
|
||||||
// check for query param
|
// check for query param
|
||||||
let arr: string[] = [];
|
let arr: string[] = [];
|
||||||
const { access_token } = requestQuery.parse(Object.fromEntries(req.nextUrl.searchParams));
|
const tokenParse = requestQuery.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||||
|
let access_token;
|
||||||
|
if (!tokenParse.success) {
|
||||||
|
log.error(`Error parsing request query: ${tokenParse.error} trace ${uid}`);
|
||||||
|
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
|
||||||
|
}
|
||||||
|
access_token = tokenParse.data.access_token;
|
||||||
arr = arr.concat(access_token);
|
arr = arr.concat(access_token);
|
||||||
if (arr[0].length > 0) return arr[0];
|
if (arr[0].length > 0) return arr[0];
|
||||||
|
|
||||||
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
|
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestQuery = z.object({
|
const requestQuery = z.object({
|
||||||
@@ -25,10 +35,18 @@ const requestQuery = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handler(req: NextRequest) {
|
async function handler(req: NextRequest) {
|
||||||
|
const log = logger.getSubLogger({ prefix: ["SAML userinfo"] });
|
||||||
const { oauthController } = await jackson();
|
const { oauthController } = await jackson();
|
||||||
const token = extractAuthToken(req);
|
const token = extractAuthToken(req);
|
||||||
const userInfo = await oauthController.userInfo(token);
|
|
||||||
return NextResponse.json(userInfo);
|
try {
|
||||||
|
const userInfo = await oauthController.userInfo(token);
|
||||||
|
return NextResponse.json(userInfo);
|
||||||
|
} catch (error) {
|
||||||
|
const uid = uuid();
|
||||||
|
log.error(`trace: ${uid} Error getting user info from token: ${error}`);
|
||||||
|
throw new Error(`Error getting user info from token. trace: ${uid}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = defaultResponderForAppDir(handler);
|
export const GET = defaultResponderForAppDir(handler);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||||
|
|
||||||
|
import { handleQueuedFormResponseCleanup } from "@calcom/app-store/routing-forms/cron/queuedFormResponseCleanup";
|
||||||
|
|
||||||
|
export const POST = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
|
||||||
@@ -35,20 +35,31 @@ async function handler(req: NextRequest) {
|
|||||||
const imageType = searchParams.get("type");
|
const imageType = searchParams.get("type");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
const fontResults = await Promise.allSettled([
|
||||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||||
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const fonts: SatoriOptions["fonts"] = [];
|
||||||
|
|
||||||
|
if (fontResults[1].status === "fulfilled") {
|
||||||
|
fonts.push({ name: "inter", data: fontResults[1].value, weight: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontResults[2].status === "fulfilled") {
|
||||||
|
fonts.push({ name: "inter", data: fontResults[2].value, weight: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontResults[0].status === "fulfilled") {
|
||||||
|
fonts.push({ name: "cal", data: fontResults[0].value, weight: 400 });
|
||||||
|
fonts.push({ name: "cal", data: fontResults[0].value, weight: 600 });
|
||||||
|
}
|
||||||
|
|
||||||
const ogConfig = {
|
const ogConfig = {
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
fonts: [
|
fonts,
|
||||||
{ name: "inter", data: interFontData, weight: 400 },
|
|
||||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
|
||||||
{ name: "cal", data: calFontData, weight: 400 },
|
|
||||||
{ name: "cal", data: calFontData, weight: 600 },
|
|
||||||
] as SatoriOptions["fonts"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (imageType) {
|
switch (imageType) {
|
||||||
|
|||||||
@@ -546,9 +546,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
|
||||||
data-testid="title-and-attendees"
|
|
||||||
className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
|
||||||
<Link href={bookingLink}>
|
<Link href={bookingLink}>
|
||||||
{/* Time and Badges for mobile */}
|
{/* Time and Badges for mobile */}
|
||||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||||
@@ -893,7 +891,7 @@ const FirstAttendee = ({
|
|||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
key={user.email}
|
key={user.email}
|
||||||
className=" hover:text-blue-500"
|
className="hover:text-blue-500"
|
||||||
href={`mailto:${user.email}`}
|
href={`mailto:${user.email}`}
|
||||||
onClick={(e) => e.stopPropagation()}>
|
onClick={(e) => e.stopPropagation()}>
|
||||||
{user.name || user.email}
|
{user.name || user.email}
|
||||||
@@ -1081,7 +1079,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="flex justify-end p-2 ">
|
<div className="flex justify-end p-2">
|
||||||
<Button
|
<Button
|
||||||
data-testid="update-no-show"
|
data-testid="update-no-show"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
@@ -1204,7 +1202,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="flex justify-end space-x-2 p-2 ">
|
<div className="flex justify-end space-x-2 p-2">
|
||||||
<Link href={`mailto:${selectedEmail}`}>
|
<Link href={`mailto:${selectedEmail}`}>
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -278,13 +278,14 @@ describe("Booking Actions", () => {
|
|||||||
expect(rerouteAction).toBeDefined();
|
expect(rerouteAction).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should include reassign action for round robin events", () => {
|
it("should include reassign action for round robin events with no host groups", () => {
|
||||||
const context = createMockContext({
|
const context = createMockContext({
|
||||||
booking: {
|
booking: {
|
||||||
...createMockContext().booking,
|
...createMockContext().booking,
|
||||||
eventType: {
|
eventType: {
|
||||||
...createMockContext().booking.eventType,
|
...createMockContext().booking.eventType,
|
||||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
hostGroups: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -294,6 +295,60 @@ describe("Booking Actions", () => {
|
|||||||
expect(reassignAction).toBeDefined();
|
expect(reassignAction).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should include reassign action for round robin events with one host group", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
booking: {
|
||||||
|
...createMockContext().booking,
|
||||||
|
eventType: {
|
||||||
|
...createMockContext().booking.eventType,
|
||||||
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
hostGroups: [{ id: "group-1", name: "Group 1" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const actions = getEditEventActions(context);
|
||||||
|
|
||||||
|
const reassignAction = actions.find((a) => a.id === "reassign");
|
||||||
|
expect(reassignAction).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should exclude reassign action for round robin events with more than one host groups", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
booking: {
|
||||||
|
...createMockContext().booking,
|
||||||
|
eventType: {
|
||||||
|
...createMockContext().booking.eventType,
|
||||||
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
hostGroups: [
|
||||||
|
{ id: "group-1", name: "Group 1" },
|
||||||
|
{ id: "group-2", name: "Group 2" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const actions = getEditEventActions(context);
|
||||||
|
|
||||||
|
const reassignAction = actions.find((a) => a.id === "reassign");
|
||||||
|
expect(reassignAction).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should exclude reassign action for non-round-robin events", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
booking: {
|
||||||
|
...createMockContext().booking,
|
||||||
|
eventType: {
|
||||||
|
...createMockContext().booking.eventType,
|
||||||
|
schedulingType: SchedulingType.COLLECTIVE,
|
||||||
|
hostGroups: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const actions = getEditEventActions(context);
|
||||||
|
|
||||||
|
const reassignAction = actions.find((a) => a.id === "reassign");
|
||||||
|
expect(reassignAction).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("should exclude add_members when guests are disabled", () => {
|
it("should exclude add_members when guests are disabled", () => {
|
||||||
const context = createMockContext({
|
const context = createMockContext({
|
||||||
booking: {
|
booking: {
|
||||||
|
|||||||
@@ -146,8 +146,9 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
|||||||
icon: "user-plus",
|
icon: "user-plus",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
// Reassign (if round robin)
|
// Reassign if round robin with no or one host groups
|
||||||
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN
|
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
|
||||||
|
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
|
||||||
? {
|
? {
|
||||||
id: "reassign",
|
id: "reassign",
|
||||||
label: t("reassign"),
|
label: t("reassign"),
|
||||||
|
|||||||
+2
-2
@@ -144,7 +144,7 @@ const PlainContactForm = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-4 right-4 z-50">
|
<div className="absolute bottom-[1rem] right-[1rem] z-50">
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
|
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
|
||||||
<Button
|
<Button
|
||||||
@@ -156,7 +156,7 @@ const PlainContactForm = () => {
|
|||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
style={{ maxWidth: "450px", maxHeight: "650px" }}
|
style={{ maxWidth: "450px", maxHeight: "650px" }}
|
||||||
className="!bg-muted no-scrollbar mr-8 mb-2 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
|
className="!bg-muted no-scrollbar mb-2 mr-8 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
|
||||||
<div className="flex w-full justify-between">
|
<div className="flex w-full justify-between">
|
||||||
<p className="mb-5 text-lg font-semibold">Contact support</p>
|
<p className="mb-5 text-lg font-semibold">Contact support</p>
|
||||||
<Button
|
<Button
|
||||||
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
|
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
|
||||||
import PlainContactForm from "../../../lib/plain/PlainContactForm";
|
import PlainContactForm from "../PlainContactForm";
|
||||||
|
|
||||||
vi.mock("next-auth/react", () => ({
|
vi.mock("next-auth/react", () => ({
|
||||||
useSession: vi.fn(),
|
useSession: vi.fn(),
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ import dynamic from "next/dynamic";
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
|
||||||
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
|
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
|
||||||
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID ? dynamic(() => import("./plainChat")) : Fragment;
|
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID
|
||||||
|
? dynamic(() => import("../../components/plain/plainChat"))
|
||||||
|
: Fragment;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const BUTTONS = {
|
|||||||
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
|
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
|
||||||
},
|
},
|
||||||
START_TRANSCRIPTION: {
|
START_TRANSCRIPTION: {
|
||||||
label: "Cal.ai",
|
label: "Transcribe",
|
||||||
tooltip: "Transcription powered by AI",
|
tooltip: "Transcription powered by AI",
|
||||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function JoinCall(props: PageProps) {
|
|||||||
...(showTranscriptionButton
|
...(showTranscriptionButton
|
||||||
? {
|
? {
|
||||||
transcription: {
|
transcription: {
|
||||||
label: "Cal.ai",
|
label: "Transcribe",
|
||||||
tooltip: "Transcription powered by AI",
|
tooltip: "Transcription powered by AI",
|
||||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.5.11",
|
"version": "5.5.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"@team-plain/typescript-sdk": "^5.9.0",
|
"@team-plain/typescript-sdk": "^5.9.0",
|
||||||
"@types/turndown": "^5.0.1",
|
"@types/turndown": "^5.0.1",
|
||||||
"@unkey/ratelimit": "^0.1.1",
|
"@unkey/ratelimit": "^0.1.1",
|
||||||
"@upstash/redis": "^1.21.0",
|
"@upstash/redis": "^1.35.2",
|
||||||
"@vercel/edge-config": "^0.1.1",
|
"@vercel/edge-config": "^0.1.1",
|
||||||
"@vercel/edge-functions-ui": "^0.2.1",
|
"@vercel/edge-functions-ui": "^0.2.1",
|
||||||
"@vercel/og": "^0.6.3",
|
"@vercel/og": "^0.6.3",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import getIP from "@calcom/lib/getIP";
|
|||||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { CreationSource } from "@calcom/prisma/enums";
|
import { CreationSource } from "@calcom/prisma/enums";
|
||||||
|
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||||
|
|
||||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||||
const userIp = getIP(req);
|
const userIp = getIP(req);
|
||||||
@@ -20,7 +21,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
|||||||
|
|
||||||
await checkRateLimitAndThrowError({
|
await checkRateLimitAndThrowError({
|
||||||
rateLimitingType: "core",
|
rateLimitingType: "core",
|
||||||
identifier: userIp,
|
identifier: piiHasher.hash(userIp),
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await getServerSession({ req });
|
const session = await getServerSession({ req });
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
|
|||||||
import getIP from "@calcom/lib/getIP";
|
import getIP from "@calcom/lib/getIP";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { CreationSource } from "@calcom/prisma/enums";
|
import { CreationSource } from "@calcom/prisma/enums";
|
||||||
|
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||||
|
|
||||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||||
const userIp = getIP(req);
|
const userIp = getIP(req);
|
||||||
|
|
||||||
await checkRateLimitAndThrowError({
|
await checkRateLimitAndThrowError({
|
||||||
rateLimitingType: "core",
|
rateLimitingType: "core",
|
||||||
identifier: `instant.event-${userIp}`,
|
identifier: `instant.event-${piiHasher.hash(userIp)}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await getServerSession({ req });
|
const session = await getServerSession({ req });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
|
|||||||
import getIP from "@calcom/lib/getIP";
|
import getIP from "@calcom/lib/getIP";
|
||||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
|
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||||
|
|
||||||
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
|
|||||||
|
|
||||||
await checkRateLimitAndThrowError({
|
await checkRateLimitAndThrowError({
|
||||||
rateLimitingType: "core",
|
rateLimitingType: "core",
|
||||||
identifier: userIp,
|
identifier: piiHasher.hash(userIp),
|
||||||
});
|
});
|
||||||
const session = await getServerSession({ req });
|
const session = await getServerSession({ req });
|
||||||
/* To mimic API behavior and comply with types */
|
/* To mimic API behavior and comply with types */
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
|
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
|
||||||
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
|
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
|
||||||
"fixed_hosts_unavailable_for_booking": "Some of the fixed hosts are unavailable for booking.",
|
"fixed_hosts_unavailable_for_booking": "Some of the fixed hosts are unavailable for booking.",
|
||||||
"round_robin_hosts_unavailable_for_booking": "No Round Robin hosts is available for booking.",
|
"round_robin_host_unavailable_for_booking": "Round Robin host is unavailable for booking.",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
@@ -349,6 +349,7 @@
|
|||||||
"available_apps_lower_case": "Available apps",
|
"available_apps_lower_case": "Available apps",
|
||||||
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
|
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
|
||||||
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
|
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
|
||||||
|
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event.",
|
||||||
"round_robin_helper": "People in the group take turns and only one person will show up for the event.",
|
"round_robin_helper": "People in the group take turns and only one person will show up for the event.",
|
||||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||||
"finish": "Finish",
|
"finish": "Finish",
|
||||||
@@ -2251,6 +2252,8 @@
|
|||||||
"floating_button_trigger_modal": "Puts a floating button on your site that triggers a modal with your event type.",
|
"floating_button_trigger_modal": "Puts a floating button on your site that triggers a modal with your event type.",
|
||||||
"pop_up_element_click": "Pop up via element click",
|
"pop_up_element_click": "Pop up via element click",
|
||||||
"open_dialog_with_element_click": "Open your calendar as a dialog when someone clicks an element.",
|
"open_dialog_with_element_click": "Open your calendar as a dialog when someone clicks an element.",
|
||||||
|
"use_my_own_form": "Use my own form",
|
||||||
|
"use_our_headless_routing_api": "Use our headless routing API and use your own form for submissions.",
|
||||||
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
|
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
|
||||||
"book_my_cal": "Book my Cal",
|
"book_my_cal": "Book my Cal",
|
||||||
"first_name": "First name",
|
"first_name": "First name",
|
||||||
@@ -3286,6 +3289,7 @@
|
|||||||
"pbac_resource_booking": "Bookings",
|
"pbac_resource_booking": "Bookings",
|
||||||
"pbac_resource_insights": "Insights",
|
"pbac_resource_insights": "Insights",
|
||||||
"pbac_resource_role": "Roles",
|
"pbac_resource_role": "Roles",
|
||||||
|
"pbac_resource_workflow": "Workflows",
|
||||||
"pbac_action_all": "All Actions",
|
"pbac_action_all": "All Actions",
|
||||||
"pbac_action_create": "Create",
|
"pbac_action_create": "Create",
|
||||||
"pbac_action_read": "View",
|
"pbac_action_read": "View",
|
||||||
@@ -3317,6 +3321,11 @@
|
|||||||
"pbac_desc_update_roles": "Update roles",
|
"pbac_desc_update_roles": "Update roles",
|
||||||
"pbac_desc_delete_roles": "Delete roles",
|
"pbac_desc_delete_roles": "Delete roles",
|
||||||
"pbac_desc_manage_roles": "All actions on roles across organization teams",
|
"pbac_desc_manage_roles": "All actions on roles across organization teams",
|
||||||
|
"pbac_desc_create_workflows": "Create and set up new workflows",
|
||||||
|
"pbac_desc_view_workflows": "View existing workflows and their configurations",
|
||||||
|
"pbac_desc_update_workflows": "Edit and modify workflow settings",
|
||||||
|
"pbac_desc_delete_workflows": "Remove workflows from the system",
|
||||||
|
"pbac_desc_manage_workflows": "Full management access to all workflows",
|
||||||
"pbac_desc_create_event_types": "Create event types",
|
"pbac_desc_create_event_types": "Create event types",
|
||||||
"pbac_desc_view_event_types": "View event types",
|
"pbac_desc_view_event_types": "View event types",
|
||||||
"pbac_desc_update_event_types": "Update event types",
|
"pbac_desc_update_event_types": "Update event types",
|
||||||
@@ -3354,6 +3363,7 @@
|
|||||||
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
|
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
|
||||||
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
|
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
|
||||||
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
|
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
|
||||||
|
"rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups",
|
||||||
"round_robin_settings_description": "Customize the default round robin settings for this team",
|
"round_robin_settings_description": "Customize the default round robin settings for this team",
|
||||||
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
|
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
|
||||||
"booking_creation_time": "Booking Creation Time",
|
"booking_creation_time": "Booking Creation Time",
|
||||||
@@ -3370,6 +3380,7 @@
|
|||||||
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
|
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
|
||||||
"restriction_schedule_not_found_error": "Restriction schedule not found",
|
"restriction_schedule_not_found_error": "Restriction schedule not found",
|
||||||
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
|
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
|
||||||
|
"add_group": "Add Group",
|
||||||
"routing_funnel": "Routing Funnel",
|
"routing_funnel": "Routing Funnel",
|
||||||
"routing_funnel_total_submissions": "Total Submissions",
|
"routing_funnel_total_submissions": "Total Submissions",
|
||||||
"routing_funnel_successful_routings": "Successful Routings",
|
"routing_funnel_successful_routings": "Successful Routings",
|
||||||
|
|||||||
@@ -380,7 +380,7 @@
|
|||||||
"connect": "Ühenda",
|
"connect": "Ühenda",
|
||||||
"try_for_free": "Proovi tasuta",
|
"try_for_free": "Proovi tasuta",
|
||||||
"create_booking_link_with_calcom": "Looge oma broneerimislink rakendusega {{appName}}",
|
"create_booking_link_with_calcom": "Looge oma broneerimislink rakendusega {{appName}}",
|
||||||
"who": "WHO",
|
"who": "Kes",
|
||||||
"what": "Mida",
|
"what": "Mida",
|
||||||
"when": "Millal",
|
"when": "Millal",
|
||||||
"where": "Kus",
|
"where": "Kus",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
|
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
|
||||||
|
import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDate,
|
getDate,
|
||||||
@@ -23,12 +24,12 @@ import { expect, expectedSlotsForSchedule } from "./getSchedule/expects";
|
|||||||
import { setupAndTeardown } from "./getSchedule/setupAndTeardown";
|
import { setupAndTeardown } from "./getSchedule/setupAndTeardown";
|
||||||
import { timeTravelToTheBeginningOfToday } from "./getSchedule/utils";
|
import { timeTravelToTheBeginningOfToday } from "./getSchedule/utils";
|
||||||
|
|
||||||
vi.mock("@calcom/lib/constants", () => ({
|
constantsScenarios.set({
|
||||||
IS_PRODUCTION: true,
|
IS_PRODUCTION: true as any,
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
WEBAPP_URL: "http://localhost:3000",
|
||||||
RESERVED_SUBDOMAINS: ["auth", "docs"],
|
RESERVED_SUBDOMAINS: ["auth", "docs"] as any,
|
||||||
SINGLE_ORG_SLUG: "",
|
SINGLE_ORG_SLUG: "" as any,
|
||||||
}));
|
});
|
||||||
|
|
||||||
describe("getSchedule", () => {
|
describe("getSchedule", () => {
|
||||||
const availableSlotsService = getAvailableSlotsService();
|
const availableSlotsService = getAvailableSlotsService();
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ type InputHost = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
isFixed?: boolean;
|
isFixed?: boolean;
|
||||||
scheduleId?: number | null;
|
scheduleId?: number | null;
|
||||||
|
groupId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputSelectedSlot = {
|
type InputSelectedSlot = {
|
||||||
@@ -215,6 +216,7 @@ export type InputEventType = {
|
|||||||
useEventLevelSelectedCalendars?: boolean;
|
useEventLevelSelectedCalendars?: boolean;
|
||||||
users?: { id: number }[];
|
users?: { id: number }[];
|
||||||
hosts?: InputHost[];
|
hosts?: InputHost[];
|
||||||
|
hostGroups?: { id: string; name: string }[];
|
||||||
schedulingType?: SchedulingType;
|
schedulingType?: SchedulingType;
|
||||||
parent?: { id: number };
|
parent?: { id: number };
|
||||||
beforeEventBuffer?: number;
|
beforeEventBuffer?: number;
|
||||||
@@ -290,6 +292,17 @@ export const Timezones = {
|
|||||||
|
|
||||||
async function addHostsToDb(eventTypes: InputEventType[]) {
|
async function addHostsToDb(eventTypes: InputEventType[]) {
|
||||||
for (const eventType of eventTypes) {
|
for (const eventType of eventTypes) {
|
||||||
|
// Create host groups first if they exist
|
||||||
|
if (eventType.hostGroups?.length) {
|
||||||
|
await prismock.hostGroup.createMany({
|
||||||
|
data: eventType.hostGroups.map((group) => ({
|
||||||
|
id: group.id, // Preserve the input ID
|
||||||
|
name: group.name,
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!eventType.hosts?.length) continue;
|
if (!eventType.hosts?.length) continue;
|
||||||
for (const host of eventType.hosts) {
|
for (const host of eventType.hosts) {
|
||||||
const data: Prisma.HostCreateInput = {
|
const data: Prisma.HostCreateInput = {
|
||||||
@@ -311,6 +324,13 @@ async function addHostsToDb(eventTypes: InputEventType[]) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
group: host.groupId
|
||||||
|
? {
|
||||||
|
connect: {
|
||||||
|
id: host.groupId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await prismock.host.create({
|
await prismock.host.create({
|
||||||
@@ -363,6 +383,7 @@ export async function addEventTypesToDb(
|
|||||||
workflows: true,
|
workflows: true,
|
||||||
destinationCalendar: true,
|
destinationCalendar: true,
|
||||||
schedule: true,
|
schedule: true,
|
||||||
|
hostGroups: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,6 +494,7 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
|
|||||||
workflows: [],
|
workflows: [],
|
||||||
users,
|
users,
|
||||||
hosts,
|
hosts,
|
||||||
|
hostGroups: eventType.hostGroups || [],
|
||||||
destinationCalendar: eventType.destinationCalendar
|
destinationCalendar: eventType.destinationCalendar
|
||||||
? {
|
? {
|
||||||
create: eventType.destinationCalendar,
|
create: eventType.destinationCalendar,
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"path": "/api/cron/calendar-cache-cleanup",
|
"path": "/api/cron/calendar-cache-cleanup",
|
||||||
"schedule": "0 5 * * *"
|
"schedule": "0 5 * * *"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/cron/queuedFormResponseCleanup",
|
||||||
|
"schedule": "0 */12 * * *"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "/api/tasks/cron",
|
"path": "/api/tasks/cron",
|
||||||
"schedule": "* * * * *"
|
"schedule": "* * * * *"
|
||||||
|
|||||||
+825
-3526
File diff suppressed because it is too large
Load Diff
@@ -156,7 +156,16 @@ export function AvailabilityWithValidation() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
availabilityRef.current?.handleFormSubmit();
|
availabilityRef.current?.handleFormSubmit({
|
||||||
|
onSuccess: () => {
|
||||||
|
// Additional success handling logic here
|
||||||
|
console.log('Availability updated successfully');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
// Additional error handling logic here
|
||||||
|
console.error('Error updating availability:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -184,6 +193,20 @@ export function AvailabilityWithValidation() {
|
|||||||
| validateForm | Validates the current availability form state and returns a promise with validation results. |
|
| validateForm | Validates the current availability form state and returns a promise with validation results. |
|
||||||
| handleFormSubmit | Programmatically submits the availability form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
|
| handleFormSubmit | Programmatically submits the availability form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
|
||||||
|
|
||||||
|
### Callbacks
|
||||||
|
|
||||||
|
The `handleFormSubmit` method accepts an optional callbacks object with the following properties:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AvailabilitySettingsFormCallbacks = {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- **onSuccess**: Called when the form submission is successful. This allows you to execute additional logic after a successful update.
|
||||||
|
- **onError**: Called when an error occurs during form submission. The error parameter contains details about what went wrong, allowing you to handle specific error cases or display custom error messages.
|
||||||
|
|
||||||
The `validateForm` method returns an `AvailabilityFormValidationResult` object with:
|
The `validateForm` method returns an `AvailabilityFormValidationResult` object with:
|
||||||
- `isValid`: Boolean indicating if the form passed validation
|
- `isValid`: Boolean indicating if the form passed validation
|
||||||
- `errors`: Object containing any validation errors found
|
- `errors`: Object containing any validation errors found
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ Below is a list of props that can be passed to the booker atom.
|
|||||||
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
|
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
|
||||||
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
|
| 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. |
|
| 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. |
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,16 @@ export function EventTypeWithValidation(eventTypeId: number) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
eventTypeRef.current?.handleFormSubmit();
|
eventTypeRef.current?.handleFormSubmit({
|
||||||
|
onSuccess: () => {
|
||||||
|
// Additional success handling logic here
|
||||||
|
console.log('Event type updated successfully');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
// Additional error handling logic here
|
||||||
|
console.error('Error updating event type:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -244,6 +253,20 @@ export function EventTypeWithValidation(eventTypeId: number) {
|
|||||||
| validateForm | Validates the current event type form state and returns a promise with validation results. |
|
| validateForm | Validates the current event type form state and returns a promise with validation results. |
|
||||||
| handleFormSubmit | Programmatically submits the event type form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
|
| handleFormSubmit | Programmatically submits the event type form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
|
||||||
|
|
||||||
|
### Callbacks
|
||||||
|
|
||||||
|
The `handleFormSubmit` method accepts an optional callbacks object with the following properties:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type EventTypeFormCallbacks = {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- **onSuccess**: Called when the form submission is successful. This allows you to execute additional logic after a successful update.
|
||||||
|
- **onError**: Called when an error occurs during form submission. The error parameter contains details about what went wrong, allowing you to handle specific error cases or display custom error messages.
|
||||||
|
|
||||||
The `validateForm` method returns an `EventTypeFormValidationResult` object with:
|
The `validateForm` method returns an `EventTypeFormValidationResult` object with:
|
||||||
- `isValid`: Boolean indicating if the form passed validation
|
- `isValid`: Boolean indicating if the form passed validation
|
||||||
- `errors`: Object containing any validation errors found
|
- `errors`: Object containing any validation errors found
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
"@daily-co/daily-js": "^0.76.0",
|
"@daily-co/daily-js": "^0.76.0",
|
||||||
"@evyweb/ioctopus": "^1.2.0",
|
"@evyweb/ioctopus": "^1.2.0",
|
||||||
"@next/third-parties": "^14.2.5",
|
"@next/third-parties": "^14.2.5",
|
||||||
|
"@unkey/ratelimit": "^2.1.2",
|
||||||
"@vercel/functions": "^1.4.0",
|
"@vercel/functions": "^1.4.0",
|
||||||
"city-timezones": "^1.2.1",
|
"city-timezones": "^1.2.1",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
|
|||||||
@@ -60,10 +60,13 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
|
|||||||
) : (
|
) : (
|
||||||
requirePayment && (
|
requirePayment && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
|
<div className="mt-4 inline-block">
|
||||||
|
<label className="text-default block text-sm font-medium mb-1" htmlFor="price">
|
||||||
|
{t("price")}
|
||||||
|
</label>
|
||||||
<TextField
|
<TextField
|
||||||
label={t("price")}
|
label={t("price")}
|
||||||
className="text-black"
|
className="text-black dark:text-white w-auto"
|
||||||
addOnClassname="h-[38px]"
|
addOnClassname="h-[38px]"
|
||||||
min="1"
|
min="1"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -88,7 +91,6 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
|
|||||||
variant="default"
|
variant="default"
|
||||||
options={currencyOptions}
|
options={currencyOptions}
|
||||||
value={selectedCurrency}
|
value={selectedCurrency}
|
||||||
className="text-black"
|
|
||||||
defaultValue={selectedCurrency}
|
defaultValue={selectedCurrency}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
@@ -132,3 +134,4 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default EventTypeAppSettingsInterface;
|
export default EventTypeAppSettingsInterface;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
export const currencyOptions = [
|
export const currencyOptions = [
|
||||||
{ label: "SATS", value: "BTC", unit: "SATS" },
|
{ label: "SATS", value: "BTC", unit: "SATS" },
|
||||||
{ label: "USD", value: "USD", unit: "USD" },
|
{ label: "USD- US Dollar", value: "USD", unit: "USD" },
|
||||||
|
{ label: "EUR- Euro", value: "EUR", unit: "EUR" },
|
||||||
|
{ label: "JPY- Japanese Yen", value: "JPY", unit: "JPY" },
|
||||||
|
{ label: "CNY- Yuan Renminbi", value: "CNY", unit: "CNY" },
|
||||||
|
{ label: "GBP- Pounds Sterling", value: "GBP", unit: "GBP" },
|
||||||
|
{ label: "AED- UAE Dirham", value: "AED", unit: "AED" },
|
||||||
|
{ label: "ZAR- South African Rand", value: "ZAR", unit: "ZAR" },
|
||||||
|
{ label: "HKD- Hong Kong Dollar", value: "HKD", unit: "HKD" },
|
||||||
|
{ label: "BRL- Brazilian Real", value: "BRL", unit: "BRL" },
|
||||||
|
{ label: "AUD- Australian Dollar", value: "AUD", unit: "AUD" },
|
||||||
|
{ label: "CAD- Canadian Dollar", value: "CAD", unit: "CAD" },
|
||||||
|
{ label: "CZK- Czech Koruna", value: "CZK", unit: "CZK" },
|
||||||
|
{ label: "DKK- Danish Krone", value: "DKK", unit: "DKK" },
|
||||||
|
{ label: "NZD- New Zealand Dollar", value: "NZD", unit: "NZD" },
|
||||||
|
{ label: "MYR- Malaysian Ringgit", value: "MYR", unit: "MYR" },
|
||||||
|
{ label: "PHP- Philippine Peso", value: "PHP", unit: "PHP" },
|
||||||
|
{ label: "CHF- Swiss Franc", value: "CHF", unit: "CHF" },
|
||||||
|
{ label: "NOK- Norwegian Krone", value: "NOK", unit: "NOK" },
|
||||||
|
{ label: "THB- Thai Baht", value: "THB", unit: "THB" },
|
||||||
|
{ label: "SEK- Swedish Krona", value: "SEK", unit: "SEK" },
|
||||||
|
{ label: "SGD- Singapore Dollar", value: "SGD", unit: "SGD" },
|
||||||
|
{ label: "PLN- Polish Zloty", value: "PLN", unit: "PLN" },
|
||||||
|
{ label: "TWD- New Taiwan Dollar", value: "TWD", unit: "TWD" },
|
||||||
|
{ label: "MXN- Mexican Peso", value: "MXN", unit: "MXN" },
|
||||||
|
{ label: "ILS- New Isreali Shekel", value: "ILS", unit: "ILS" },
|
||||||
|
{ label: "NGN- Nigerian Naira", value: "NGN", unit: "NGN" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const zeroDecimalCurrencies = ["SATS", "BTC"];
|
|
||||||
|
const zeroDecimalCurrencies = ["SATS", "BTC", "JPY"];
|
||||||
|
|
||||||
export const convertToSmallestCurrencyUnit = (amount: number, currency: string) => {
|
export const convertToSmallestCurrencyUnit = (amount: number, currency: string) => {
|
||||||
if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
|
if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
|
||||||
@@ -17,4 +43,4 @@ export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, cur
|
|||||||
return amount;
|
return amount;
|
||||||
}
|
}
|
||||||
return amount / 100;
|
return amount / 100;
|
||||||
};
|
};
|
||||||
@@ -1114,4 +1114,18 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMainTimeZone(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const primaryCalendar = await this.getPrimaryCalendar();
|
||||||
|
if (!primaryCalendar?.timeZone) {
|
||||||
|
this.log.warn("No timezone found in primary calendar, defaulting to UTC");
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
return primaryCalendar.timeZone;
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("Error getting main timezone from Google Calendar", { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -625,4 +625,21 @@ export default class Office365CalendarService implements Calendar {
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async getMainTimeZone(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await this.fetcher(`${await this.getUserEndpoint()}/mailboxSettings/timeZone`);
|
||||||
|
const timezone = await handleErrorsJson<string>(response);
|
||||||
|
|
||||||
|
if (!timezone) {
|
||||||
|
this.log.warn("No timezone found in mailbox settings, defaulting to Europe/London");
|
||||||
|
return "Europe/London";
|
||||||
|
}
|
||||||
|
|
||||||
|
return timezone;
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("Error getting main timezone from Office365 Calendar", { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { PrismaQueuedFormResponseRepository } from "@calcom/lib/server/repository/routingForm/queuedFormResponse/PrismaQueuedFormResponseRepository";
|
||||||
|
import { QueuedFormResponseService } from "@calcom/lib/server/service/routingForm/queuedFormResponse/QueuedFormResponseService";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
function validateRequest(request: NextRequest) {
|
||||||
|
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
|
||||||
|
|
||||||
|
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||||
|
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleQueuedFormResponseCleanup(request: NextRequest) {
|
||||||
|
const authError = validateRequest(request);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queuedFormResponseRepo = new PrismaQueuedFormResponseRepository(prisma);
|
||||||
|
const queuedFormResponseService = new QueuedFormResponseService({
|
||||||
|
logger,
|
||||||
|
queuedFormResponseRepo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await queuedFormResponseService.cleanupExpiredResponses({
|
||||||
|
batchSize: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "success",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during queued form response cleanup:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: "error", message: "Failed to cleanup queued form responses" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,11 +79,23 @@ export const zoomUserSettingsSchema = z.object({
|
|||||||
waiting_room: z.boolean(),
|
waiting_room: z.boolean(),
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
|
meeting_security: z
|
||||||
|
.object({
|
||||||
|
waiting_room_settings: z
|
||||||
|
.object({
|
||||||
|
participants_to_place_in_waiting_room: z.number().optional(),
|
||||||
|
users_who_can_admit_participants_from_waiting_room: z.number().optional(),
|
||||||
|
whitelisted_domains_for_waiting_room: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings
|
// https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings
|
||||||
// append comma separated settings here, to retrieve only these specific settings
|
// append comma separated settings here, to retrieve only these specific settings
|
||||||
const settingsApiFilterResp = "default_password_for_scheduled_meetings,auto_recording,waiting_room";
|
const settingsApiFilterResp =
|
||||||
|
"default_password_for_scheduled_meetings,auto_recording,waiting_room,waiting_room_settings";
|
||||||
|
|
||||||
type ZoomRecurrence = {
|
type ZoomRecurrence = {
|
||||||
end_date_time?: string;
|
end_date_time?: string;
|
||||||
@@ -175,6 +187,12 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
const userSettings = await getUserSettings();
|
const userSettings = await getUserSettings();
|
||||||
const recurrence = getRecurrence(event);
|
const recurrence = getRecurrence(event);
|
||||||
const waitingRoomEnabled = userSettings?.in_meeting?.waiting_room ?? false;
|
const waitingRoomEnabled = userSettings?.in_meeting?.waiting_room ?? false;
|
||||||
|
const advancedWaitingRoomSettings = userSettings?.meeting_security?.waiting_room_settings;
|
||||||
|
const hasAdvancedWaitingRoomSettings =
|
||||||
|
waitingRoomEnabled &&
|
||||||
|
!!advancedWaitingRoomSettings &&
|
||||||
|
typeof advancedWaitingRoomSettings === "object" &&
|
||||||
|
Object.keys(advancedWaitingRoomSettings).length > 0;
|
||||||
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
|
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
|
||||||
return {
|
return {
|
||||||
topic: event.title,
|
topic: event.title,
|
||||||
@@ -200,6 +218,9 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
enforce_login: false,
|
enforce_login: false,
|
||||||
registrants_email_notification: true,
|
registrants_email_notification: true,
|
||||||
waiting_room: waitingRoomEnabled,
|
waiting_room: waitingRoomEnabled,
|
||||||
|
...(hasAdvancedWaitingRoomSettings && {
|
||||||
|
waiting_room_settings: advancedWaitingRoomSettings,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
...recurrence,
|
...recurrence,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/eslint-plugin-eslint": "*",
|
"@calcom/eslint-plugin-eslint": "*",
|
||||||
"@todesktop/tailwind-variants": "^1.0.0",
|
"@todesktop/tailwind-variants": "^1.0.0",
|
||||||
"eslint-config-next": "^15.4.5",
|
"eslint-config-next": "^13.2.1",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-config-turbo": "^0.0.7",
|
"eslint-config-turbo": "^0.0.7",
|
||||||
"eslint-plugin-playwright": "^0.12.0",
|
"eslint-plugin-playwright": "^0.12.0",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ afterEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.clearAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("embedStore.router.ensureQueryParamsInUrl", async () => {
|
describe("embedStore.router.ensureQueryParamsInUrl", async () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const rule = createRule({
|
|||||||
create(context) {
|
create(context) {
|
||||||
// Track imported names from @calcom/lib/webstorage
|
// Track imported names from @calcom/lib/webstorage
|
||||||
const safeImportedNames = new Set<string>();
|
const safeImportedNames = new Set<string>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ImportDeclaration(node) {
|
ImportDeclaration(node) {
|
||||||
// Check if this is an import from the safe webstorage module
|
// Check if this is an import from the safe webstorage module
|
||||||
@@ -18,11 +18,11 @@ const rule = createRule({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
CallExpression(node) {
|
CallExpression(node) {
|
||||||
const webStorages = ["localStorage", "sessionStorage"];
|
const webStorages = ["localStorage", "sessionStorage"];
|
||||||
const callee = node.callee;
|
const callee = node.callee;
|
||||||
|
|
||||||
// Check for window.localStorage or window.sessionStorage
|
// Check for window.localStorage or window.sessionStorage
|
||||||
if (
|
if (
|
||||||
// Can't figure out how to fix this TS issue
|
// Can't figure out how to fix this TS issue
|
||||||
@@ -40,7 +40,7 @@ const rule = createRule({
|
|||||||
messageId: "possible-issue-with-embed",
|
messageId: "possible-issue-with-embed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for direct localStorage or sessionStorage usage
|
// Check for direct localStorage or sessionStorage usage
|
||||||
if (
|
if (
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
@@ -63,11 +63,11 @@ const rule = createRule({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Also check for property access like localStorage.length
|
// Also check for property access like localStorage.length
|
||||||
MemberExpression(node) {
|
MemberExpression(node) {
|
||||||
const webStorages = ["localStorage", "sessionStorage"];
|
const webStorages = ["localStorage", "sessionStorage"];
|
||||||
|
|
||||||
// Check for direct property access on localStorage/sessionStorage
|
// Check for direct property access on localStorage/sessionStorage
|
||||||
if (
|
if (
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
@@ -86,7 +86,7 @@ const rule = createRule({
|
|||||||
messageId: "possible-issue-with-embed",
|
messageId: "possible-issue-with-embed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for window.localStorage/sessionStorage property access
|
// Check for window.localStorage/sessionStorage property access
|
||||||
if (
|
if (
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { isENVDev } from "@calcom/lib/env";
|
|||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { randomString } from "@calcom/lib/random";
|
import { randomString } from "@calcom/lib/random";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
|
import { hashEmail } from "@calcom/lib/server/PiiHasher";
|
||||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||||
@@ -131,7 +132,7 @@ const providers: Provider[] = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
await checkRateLimitAndThrowError({
|
await checkRateLimitAndThrowError({
|
||||||
identifier: user.email,
|
identifier: hashEmail(user.email),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user.password?.hash && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
|
if (!user.password?.hash && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
|||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
import { hashEmail } from "@calcom/lib/server/PiiHasher";
|
||||||
|
|
||||||
const log = logger.getSubLogger({ prefix: [`[[Auth] `] });
|
const log = logger.getSubLogger({ prefix: [`[[Auth] `] });
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ export const sendEmailVerification = async ({
|
|||||||
|
|
||||||
await checkRateLimitAndThrowError({
|
await checkRateLimitAndThrowError({
|
||||||
rateLimitingType: "core",
|
rateLimitingType: "core",
|
||||||
identifier: email,
|
identifier: hashEmail(email),
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.verificationToken.create({
|
await prisma.verificationToken.create({
|
||||||
@@ -142,7 +143,7 @@ export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOf
|
|||||||
|
|
||||||
await checkRateLimitAndThrowError({
|
await checkRateLimitAndThrowError({
|
||||||
rateLimitingType: "core",
|
rateLimitingType: "core",
|
||||||
identifier: user.emailFrom,
|
identifier: hashEmail(user.emailFrom),
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.verificationToken.create({
|
await prisma.verificationToken.create({
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const BookerComponent = ({
|
|||||||
confirmButtonDisabled,
|
confirmButtonDisabled,
|
||||||
timeZones,
|
timeZones,
|
||||||
eventMetaChildren,
|
eventMetaChildren,
|
||||||
|
roundRobinHideOrgAndTeam,
|
||||||
}: BookerProps & WrappedBookerProps) => {
|
}: BookerProps & WrappedBookerProps) => {
|
||||||
const searchParams = useCompatSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
|
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
|
||||||
@@ -403,7 +404,8 @@ const BookerComponent = ({
|
|||||||
isPlatform={isPlatform}
|
isPlatform={isPlatform}
|
||||||
isPrivateLink={!!hashedLink}
|
isPrivateLink={!!hashedLink}
|
||||||
locale={userLocale}
|
locale={userLocale}
|
||||||
timeZones={timeZones}>
|
timeZones={timeZones}
|
||||||
|
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
|
||||||
{eventMetaChildren}
|
{eventMetaChildren}
|
||||||
</EventMeta>
|
</EventMeta>
|
||||||
{layout !== BookerLayouts.MONTH_VIEW &&
|
{layout !== BookerLayouts.MONTH_VIEW &&
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const EventMeta = ({
|
|||||||
locale,
|
locale,
|
||||||
timeZones,
|
timeZones,
|
||||||
children,
|
children,
|
||||||
|
roundRobinHideOrgAndTeam,
|
||||||
}: {
|
}: {
|
||||||
event?: Pick<
|
event?: Pick<
|
||||||
BookerEvent,
|
BookerEvent,
|
||||||
@@ -90,6 +91,7 @@ export const EventMeta = ({
|
|||||||
locale?: string | null;
|
locale?: string | null;
|
||||||
timeZones?: Timezone[];
|
timeZones?: Timezone[];
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
roundRobinHideOrgAndTeam?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { timeFormat, timezone } = useBookerTime();
|
const { timeFormat, timezone } = useBookerTime();
|
||||||
const [setTimezone] = useTimePreferences((state) => [state.setTimezone]);
|
const [setTimezone] = useTimePreferences((state) => [state.setTimezone]);
|
||||||
@@ -167,6 +169,7 @@ export const EventMeta = ({
|
|||||||
profile={event.profile}
|
profile={event.profile}
|
||||||
entity={event.entity}
|
entity={event.entity}
|
||||||
isPrivateLink={isPrivateLink}
|
isPrivateLink={isPrivateLink}
|
||||||
|
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}
|
||||||
/>
|
/>
|
||||||
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>
|
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>
|
||||||
{translatedTitle ?? event?.title}
|
{translatedTitle ?? event?.title}
|
||||||
|
|||||||
@@ -140,11 +140,13 @@ export type WrappedBookerPropsForPlatform = WrappedBookerPropsMain & {
|
|||||||
verifyCode: undefined;
|
verifyCode: undefined;
|
||||||
customClassNames?: CustomClassNames;
|
customClassNames?: CustomClassNames;
|
||||||
timeZones?: Timezone[];
|
timeZones?: Timezone[];
|
||||||
|
roundRobinHideOrgAndTeam?: boolean;
|
||||||
};
|
};
|
||||||
export type WrappedBookerPropsForWeb = WrappedBookerPropsMain & {
|
export type WrappedBookerPropsForWeb = WrappedBookerPropsMain & {
|
||||||
isPlatform: false;
|
isPlatform: false;
|
||||||
verifyCode: UseVerifyCodeReturnType;
|
verifyCode: UseVerifyCodeReturnType;
|
||||||
timeZones?: Timezone[];
|
timeZones?: Timezone[];
|
||||||
|
roundRobinHideOrgAndTeam?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WrappedBookerProps = WrappedBookerPropsForPlatform | WrappedBookerPropsForWeb;
|
export type WrappedBookerProps = WrappedBookerPropsForPlatform | WrappedBookerPropsForWeb;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface EventMembersProps {
|
|||||||
profile: BookerEvent["profile"];
|
profile: BookerEvent["profile"];
|
||||||
entity: BookerEvent["entity"];
|
entity: BookerEvent["entity"];
|
||||||
isPrivateLink: boolean;
|
isPrivateLink: boolean;
|
||||||
|
roundRobinHideOrgAndTeam?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventMembers = ({
|
export const EventMembers = ({
|
||||||
@@ -26,6 +27,7 @@ export const EventMembers = ({
|
|||||||
profile,
|
profile,
|
||||||
entity,
|
entity,
|
||||||
isPrivateLink,
|
isPrivateLink,
|
||||||
|
roundRobinHideOrgAndTeam,
|
||||||
}: EventMembersProps) => {
|
}: EventMembersProps) => {
|
||||||
const username = useBookerStore((state) => state.username);
|
const username = useBookerStore((state) => state.username);
|
||||||
const isDynamic = !!(username && username.indexOf("+") > -1);
|
const isDynamic = !!(username && username.indexOf("+") > -1);
|
||||||
@@ -40,6 +42,10 @@ export const EventMembers = ({
|
|||||||
!users.length ||
|
!users.length ||
|
||||||
(profile.name !== users[0].name && schedulingType === SchedulingType.COLLECTIVE);
|
(profile.name !== users[0].name && schedulingType === SchedulingType.COLLECTIVE);
|
||||||
|
|
||||||
|
if (schedulingType === SchedulingType.ROUND_ROBIN && roundRobinHideOrgAndTeam) {
|
||||||
|
return <div className="h-6" />;
|
||||||
|
}
|
||||||
|
|
||||||
const orgOrTeamAvatarItem =
|
const orgOrTeamAvatarItem =
|
||||||
isDynamic || (!profile.image && !entity.logoUrl) || !entity.teamSlug
|
isDynamic || (!profile.image && !entity.logoUrl) || !entity.teamSlug
|
||||||
? []
|
? []
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||||
import EventManager, { placeholderCreatedEvent } from "@calcom/lib/EventManager";
|
import EventManager, { placeholderCreatedEvent } from "@calcom/lib/EventManager";
|
||||||
import { handleAnalyticsEvents } from "@calcom/lib/analyticsManager/handleAnalyticsEvents";
|
import { handleAnalyticsEvents } from "@calcom/lib/analyticsManager/handleAnalyticsEvents";
|
||||||
|
import { groupHostsByGroupId } from "@calcom/lib/bookings/hostGroupUtils";
|
||||||
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
|
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
|
||||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||||
import {
|
import {
|
||||||
@@ -281,9 +282,7 @@ export const buildEventForTeamEventType = async ({
|
|||||||
const fixedUsers = users.filter((user) => user.isFixed);
|
const fixedUsers = users.filter((user) => user.isFixed);
|
||||||
const nonFixedUsers = users.filter((user) => !user.isFixed);
|
const nonFixedUsers = users.filter((user) => !user.isFixed);
|
||||||
const filteredUsers =
|
const filteredUsers =
|
||||||
schedulingType === SchedulingType.ROUND_ROBIN
|
schedulingType === SchedulingType.ROUND_ROBIN ? [...fixedUsers, ...nonFixedUsers] : users;
|
||||||
? [...fixedUsers, ...(nonFixedUsers.length > 0 ? [nonFixedUsers[0]] : [])]
|
|
||||||
: users;
|
|
||||||
|
|
||||||
// Organizer or user owner of this event type it's not listed as a team member.
|
// Organizer or user owner of this event type it's not listed as a team member.
|
||||||
const teamMemberPromises = filteredUsers
|
const teamMemberPromises = filteredUsers
|
||||||
@@ -825,11 +824,17 @@ async function handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const luckyUserPool: IsFixedAwareUser[] = [];
|
|
||||||
const fixedUserPool: IsFixedAwareUser[] = [];
|
const fixedUserPool: IsFixedAwareUser[] = [];
|
||||||
|
const nonFixedUsers: IsFixedAwareUser[] = [];
|
||||||
|
|
||||||
availableUsers.forEach((user) => {
|
availableUsers.forEach((user) => {
|
||||||
user.isFixed ? fixedUserPool.push(user) : luckyUserPool.push(user);
|
user.isFixed ? fixedUserPool.push(user) : nonFixedUsers.push(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group non-fixed users by their group IDs
|
||||||
|
const luckyUserPools = groupHostsByGroupId({
|
||||||
|
hosts: nonFixedUsers,
|
||||||
|
hostGroups: eventType.hostGroups,
|
||||||
});
|
});
|
||||||
|
|
||||||
const notAvailableLuckyUsers: typeof users = [];
|
const notAvailableLuckyUsers: typeof users = [];
|
||||||
@@ -838,81 +843,88 @@ async function handler(
|
|||||||
"Computed available users",
|
"Computed available users",
|
||||||
safeStringify({
|
safeStringify({
|
||||||
availableUsers: availableUsers.map((user) => user.id),
|
availableUsers: availableUsers.map((user) => user.id),
|
||||||
luckyUserPool: luckyUserPool.map((user) => user.id),
|
luckyUserPools: Object.fromEntries(
|
||||||
|
Object.entries(luckyUserPools).map(([groupId, users]) => [groupId, users.map((user) => user.id)])
|
||||||
|
),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const luckyUsers: typeof users = [];
|
const luckyUsers: typeof users = [];
|
||||||
|
|
||||||
// loop through all non-fixed hosts and get the lucky users
|
// loop through all non-fixed hosts and get the lucky users
|
||||||
// This logic doesn't run when contactOwner is used because in that case, luckUsers.length === 1
|
// This logic doesn't run when contactOwner is used because in that case, luckUsers.length === 1
|
||||||
while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) {
|
for (const [groupId, luckyUserPool] of Object.entries(luckyUserPools)) {
|
||||||
const freeUsers = luckyUserPool.filter(
|
let luckUserFound = false;
|
||||||
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
|
while (luckyUserPool.length > 0 && !luckUserFound) {
|
||||||
);
|
const freeUsers = luckyUserPool.filter(
|
||||||
// no more freeUsers after subtracting notAvailableLuckyUsers from luckyUsers :(
|
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
|
||||||
if (freeUsers.length === 0) break;
|
);
|
||||||
assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too with an assertion; the error will never be thrown.
|
// no more freeUsers after subtracting notAvailableLuckyUsers from luckyUsers :(
|
||||||
// freeUsers is ensured
|
if (freeUsers.length === 0) break;
|
||||||
|
assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too with an assertion; the error will never be thrown.
|
||||||
|
// freeUsers is ensured
|
||||||
|
|
||||||
const userIdsSet = new Set(users.map((user) => user.id));
|
const userIdsSet = new Set(users.map((user) => user.id));
|
||||||
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
|
const firstUserOrgId = await getOrgIdFromMemberOrTeamId({
|
||||||
memberId: eventTypeWithUsers.users[0].id ?? null,
|
memberId: eventTypeWithUsers.users[0].id ?? null,
|
||||||
teamId: eventType.teamId,
|
teamId: eventType.teamId,
|
||||||
});
|
});
|
||||||
const newLuckyUser = await getLuckyUser({
|
const newLuckyUser = await getLuckyUser({
|
||||||
// find a lucky user that is not already in the luckyUsers array
|
// find a lucky user that is not already in the luckyUsers array
|
||||||
availableUsers: freeUsers,
|
availableUsers: freeUsers,
|
||||||
allRRHosts: (
|
// only hosts from the same group
|
||||||
await enrichHostsWithDelegationCredentials({
|
allRRHosts: (
|
||||||
orgId: firstUserOrgId ?? null,
|
await enrichHostsWithDelegationCredentials({
|
||||||
hosts: eventTypeWithUsers.hosts,
|
orgId: firstUserOrgId ?? null,
|
||||||
})
|
hosts: eventTypeWithUsers.hosts,
|
||||||
).filter((host) => !host.isFixed && userIdsSet.has(host.user.id)),
|
})
|
||||||
eventType,
|
).filter((host) => !host.isFixed && userIdsSet.has(host.user.id) && host.groupId === groupId),
|
||||||
routingFormResponse,
|
eventType,
|
||||||
meetingStartTime: new Date(reqBody.start),
|
routingFormResponse,
|
||||||
});
|
meetingStartTime: new Date(reqBody.start),
|
||||||
if (!newLuckyUser) {
|
});
|
||||||
break; // prevent infinite loop
|
if (!newLuckyUser) {
|
||||||
}
|
break; // prevent infinite loop
|
||||||
if (
|
}
|
||||||
input.bookingData.isFirstRecurringSlot &&
|
if (
|
||||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN
|
input.bookingData.isFirstRecurringSlot &&
|
||||||
) {
|
eventType.schedulingType === SchedulingType.ROUND_ROBIN
|
||||||
// for recurring round robin events check if lucky user is available for next slots
|
) {
|
||||||
try {
|
// for recurring round robin events check if lucky user is available for next slots
|
||||||
for (
|
try {
|
||||||
let i = 0;
|
for (
|
||||||
i < input.bookingData.allRecurringDates.length &&
|
let i = 0;
|
||||||
i < input.bookingData.numSlotsToCheckForAvailability;
|
i < input.bookingData.allRecurringDates.length &&
|
||||||
i++
|
i < input.bookingData.numSlotsToCheckForAvailability;
|
||||||
) {
|
i++
|
||||||
const start = input.bookingData.allRecurringDates[i].start;
|
) {
|
||||||
const end = input.bookingData.allRecurringDates[i].end;
|
const start = input.bookingData.allRecurringDates[i].start;
|
||||||
|
const end = input.bookingData.allRecurringDates[i].end;
|
||||||
|
|
||||||
await ensureAvailableUsers(
|
await ensureAvailableUsers(
|
||||||
{ ...eventTypeWithUsers, users: [newLuckyUser] },
|
{ ...eventTypeWithUsers, users: [newLuckyUser] },
|
||||||
{
|
{
|
||||||
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
|
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
|
||||||
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
|
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
|
||||||
timeZone: reqBody.timeZone,
|
timeZone: reqBody.timeZone,
|
||||||
originalRescheduledBooking,
|
originalRescheduledBooking,
|
||||||
},
|
},
|
||||||
loggerWithEventDetails,
|
loggerWithEventDetails,
|
||||||
shouldServeCache
|
shouldServeCache
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// if no error, then lucky user is available for the next slots
|
||||||
|
luckyUsers.push(newLuckyUser);
|
||||||
|
luckUserFound = true;
|
||||||
|
} catch {
|
||||||
|
notAvailableLuckyUsers.push(newLuckyUser);
|
||||||
|
loggerWithEventDetails.info(
|
||||||
|
`Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// if no error, then lucky user is available for the next slots
|
} else {
|
||||||
luckyUsers.push(newLuckyUser);
|
luckyUsers.push(newLuckyUser);
|
||||||
} catch {
|
luckUserFound = true;
|
||||||
notAvailableLuckyUsers.push(newLuckyUser);
|
|
||||||
loggerWithEventDetails.info(
|
|
||||||
`Round robin host ${newLuckyUser.name} not available for first two slots. Trying to find another host.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
luckyUsers.push(newLuckyUser);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,8 +933,22 @@ async function handler(
|
|||||||
throw new Error(ErrorCode.FixedHostsUnavailableForBooking);
|
throw new Error(ErrorCode.FixedHostsUnavailableForBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
|
||||||
|
|
||||||
|
const hostGroups = groupHostsByGroupId({
|
||||||
|
hosts: roundRobinHosts,
|
||||||
|
hostGroups: eventType.hostGroups,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out host groups that have no hosts in them
|
||||||
|
const nonEmptyHostGroups = Object.fromEntries(
|
||||||
|
Object.entries(hostGroups).filter(([groupId, hosts]) => hosts.length > 0)
|
||||||
|
);
|
||||||
// If there are RR hosts, we need to find a lucky user
|
// If there are RR hosts, we need to find a lucky user
|
||||||
if ([...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 && luckyUsers.length === 0) {
|
if (
|
||||||
|
[...qualifiedRRUsers, ...additionalFallbackRRUsers].length > 0 &&
|
||||||
|
luckyUsers.length !== (Object.keys(nonEmptyHostGroups).length || 1)
|
||||||
|
) {
|
||||||
throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking);
|
throw new Error(ErrorCode.RoundRobinHostsUnavailableForBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,7 +959,9 @@ async function handler(
|
|||||||
...troubleshooterData,
|
...troubleshooterData,
|
||||||
luckyUsers: luckyUsers.map((u) => u.id),
|
luckyUsers: luckyUsers.map((u) => u.id),
|
||||||
fixedUsers: fixedUserPool.map((u) => u.id),
|
fixedUsers: fixedUserPool.map((u) => u.id),
|
||||||
luckyUserPool: luckyUserPool.map((u) => u.id),
|
luckyUserPool: Object.values(luckyUserPools)
|
||||||
|
.flat()
|
||||||
|
.map((u) => u.id),
|
||||||
};
|
};
|
||||||
} else if (
|
} else if (
|
||||||
input.bookingData.allRecurringDates &&
|
input.bookingData.allRecurringDates &&
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import dayjs from "@calcom/dayjs";
|
|||||||
import type { Dayjs } from "@calcom/dayjs";
|
import type { Dayjs } from "@calcom/dayjs";
|
||||||
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
|
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
|
||||||
import { buildDateRanges } from "@calcom/lib/date-ranges";
|
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 { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { getBusyTimesForLimitChecks } from "@calcom/lib/getBusyTimes";
|
|
||||||
import { getUsersAvailability } from "@calcom/lib/getUserAvailability";
|
|
||||||
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
|
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
|
||||||
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
|
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
|
||||||
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
|
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
|
||||||
@@ -62,6 +62,7 @@ const _ensureAvailableUsers = async (
|
|||||||
shouldServeCache?: boolean
|
shouldServeCache?: boolean
|
||||||
// ReturnType hint of at least one IsFixedAwareUser, as it's made sure at least one entry exists
|
// ReturnType hint of at least one IsFixedAwareUser, as it's made sure at least one entry exists
|
||||||
): Promise<[IsFixedAwareUser, ...IsFixedAwareUser[]]> => {
|
): Promise<[IsFixedAwareUser, ...IsFixedAwareUser[]]> => {
|
||||||
|
const userAvailabilityService = getUserAvailabilityService();
|
||||||
const availableUsers: IsFixedAwareUser[] = [];
|
const availableUsers: IsFixedAwareUser[] = [];
|
||||||
|
|
||||||
const startDateTimeUtc = getDateTimeInUtc(input.dateFrom, input.timeZone);
|
const startDateTimeUtc = getDateTimeInUtc(input.dateFrom, input.timeZone);
|
||||||
@@ -73,9 +74,12 @@ const _ensureAvailableUsers = async (
|
|||||||
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
|
const bookingLimits = parseBookingLimit(eventType?.bookingLimits);
|
||||||
const durationLimits = parseDurationLimit(eventType?.durationLimits);
|
const durationLimits = parseDurationLimit(eventType?.durationLimits);
|
||||||
|
|
||||||
const busyTimesFromLimitsBookingsAllUsers: Awaited<ReturnType<typeof getBusyTimesForLimitChecks>> =
|
const busyTimesService = getBusyTimesService();
|
||||||
|
const busyTimesFromLimitsBookingsAllUsers: Awaited<
|
||||||
|
ReturnType<typeof busyTimesService.getBusyTimesForLimitChecks>
|
||||||
|
> =
|
||||||
eventType && (bookingLimits || durationLimits)
|
eventType && (bookingLimits || durationLimits)
|
||||||
? await getBusyTimesForLimitChecks({
|
? await busyTimesService.getBusyTimesForLimitChecks({
|
||||||
userIds: eventType.users.map((u) => u.id),
|
userIds: eventType.users.map((u) => u.id),
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
startDate: startDateTimeUtc.format(),
|
startDate: startDateTimeUtc.format(),
|
||||||
@@ -86,7 +90,7 @@ const _ensureAvailableUsers = async (
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const usersAvailability = await getUsersAvailability({
|
const usersAvailability = await userAvailabilityService.getUsersAvailability({
|
||||||
users: eventType.users,
|
users: eventType.users,
|
||||||
query: {
|
query: {
|
||||||
...input,
|
...input,
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||||||
priority: true,
|
priority: true,
|
||||||
weight: true,
|
weight: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
groupId: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -177,6 +178,12 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||||||
assignRRMembersUsingSegment: true,
|
assignRRMembersUsingSegment: true,
|
||||||
rrSegmentQueryValue: true,
|
rrSegmentQueryValue: true,
|
||||||
useEventLevelSelectedCalendars: true,
|
useEventLevelSelectedCalendars: true,
|
||||||
|
hostGroups: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,6 +213,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||||||
bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent }),
|
bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent }),
|
||||||
rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue) ?? null,
|
rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue) ?? null,
|
||||||
isDynamic: false,
|
isDynamic: false,
|
||||||
|
hostGroups: eventType.hostGroups || [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message.includes("Record to update not found")) {
|
if (error instanceof Error && error.message.includes("Record to update not found")) {
|
||||||
|
|||||||
@@ -85,12 +85,13 @@ const loadUsersByEventType = async (eventType: EventType): Promise<NewBookingEve
|
|||||||
eventType,
|
eventType,
|
||||||
hosts: hosts ?? fallbackHosts,
|
hosts: hosts ?? fallbackHosts,
|
||||||
});
|
});
|
||||||
return matchingHosts.map(({ user, isFixed, priority, weight, createdAt }) => ({
|
return matchingHosts.map(({ user, isFixed, priority, weight, createdAt, groupId }) => ({
|
||||||
...user,
|
...user,
|
||||||
isFixed,
|
isFixed,
|
||||||
priority,
|
priority,
|
||||||
weight,
|
weight,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
groupId,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -139,7 +139,7 @@ describe("buildEventForTeamEventType", () => {
|
|||||||
|
|
||||||
expect(memberEmails).toContain("fixed@example.com");
|
expect(memberEmails).toContain("fixed@example.com");
|
||||||
expect(memberEmails).toContain("nonfixed1@example.com");
|
expect(memberEmails).toContain("nonfixed1@example.com");
|
||||||
expect(memberEmails).not.toContain("nonfixed2@example.com");
|
expect(memberEmails).toContain("nonfixed2@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds a team with fallback name and id", async () => {
|
it("builds a team with fallback name and id", async () => {
|
||||||
|
|||||||
+652
@@ -0,0 +1,652 @@
|
|||||||
|
import {
|
||||||
|
getBooker,
|
||||||
|
TestData,
|
||||||
|
getOrganizer,
|
||||||
|
createBookingScenario,
|
||||||
|
Timezones,
|
||||||
|
getScenarioData,
|
||||||
|
mockSuccessfulVideoMeetingCreation,
|
||||||
|
BookingLocations,
|
||||||
|
getDate,
|
||||||
|
getMockBookingAttendee,
|
||||||
|
getGoogleCalendarCredential,
|
||||||
|
mockCalendarToHaveNoBusySlots,
|
||||||
|
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||||
|
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
|
||||||
|
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
||||||
|
|
||||||
|
import { describe, test, vi, expect } from "vitest";
|
||||||
|
|
||||||
|
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
|
||||||
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
|
import { SchedulingType } from "@calcom/prisma/enums";
|
||||||
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
describe("Round Robin handleNewBooking", () => {
|
||||||
|
setupAndTeardown();
|
||||||
|
|
||||||
|
describe("Round Robin with groups", () => {
|
||||||
|
test("Books one host from each round robin group", async () => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
const teamMemberOne = [
|
||||||
|
{
|
||||||
|
name: "Team Member One",
|
||||||
|
username: "other-team-member-1",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-one@example.com",
|
||||||
|
id: 102,
|
||||||
|
schedule: TestData.schedules.IstMorningShift,
|
||||||
|
credentials: [getGoogleCalendarCredential()],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: TestData.apps["google-calendar"].type,
|
||||||
|
externalId: "other-team-member-1@google-calendar.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const organizer = getOrganizer({
|
||||||
|
name: "Organizer",
|
||||||
|
email: "organizer@example.com",
|
||||||
|
id: 101,
|
||||||
|
defaultScheduleId: null,
|
||||||
|
schedules: [TestData.schedules.IstMorningShift],
|
||||||
|
credentials: [getGoogleCalendarCredential()],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: TestData.apps["google-calendar"].type,
|
||||||
|
externalId: "organizer@google-calendar.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMembers = [
|
||||||
|
{
|
||||||
|
name: "Team Member 1",
|
||||||
|
username: "team-member-1",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-1@example.com",
|
||||||
|
id: 102,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Team Member 2",
|
||||||
|
username: "team-member-2",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-2@example.com",
|
||||||
|
id: 103,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Team Member 3",
|
||||||
|
username: "team-member-3",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-3@example.com",
|
||||||
|
id: 104,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Team Member 4",
|
||||||
|
username: "team-member-4",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-4@example.com",
|
||||||
|
id: 105,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 15,
|
||||||
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
length: 15,
|
||||||
|
users: [
|
||||||
|
{ id: teamMembers[0].id },
|
||||||
|
{ id: teamMembers[1].id },
|
||||||
|
{ id: teamMembers[2].id },
|
||||||
|
{ id: teamMembers[3].id },
|
||||||
|
],
|
||||||
|
hosts: [
|
||||||
|
{ userId: teamMembers[0].id, isFixed: false, groupId: "group-1" },
|
||||||
|
{ userId: teamMembers[1].id, isFixed: false, groupId: "group-1" },
|
||||||
|
{ userId: teamMembers[2].id, isFixed: false, groupId: "group-2" },
|
||||||
|
{ userId: teamMembers[3].id, isFixed: false, groupId: "group-2" },
|
||||||
|
],
|
||||||
|
hostGroups: [
|
||||||
|
{ id: "group-1", name: "Group 1" },
|
||||||
|
{ id: "group-2", name: "Group 2" },
|
||||||
|
],
|
||||||
|
schedule: TestData.schedules.IstMorningShift,
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: TestData.apps["google-calendar"].type,
|
||||||
|
externalId: "event-type-1@google-calendar.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer,
|
||||||
|
usersApartFromOrganizer: teamMembers,
|
||||||
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mockSuccessfulVideoMeetingCreation({
|
||||||
|
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
|
||||||
|
videoMeetingData: {
|
||||||
|
id: "MOCK_ID",
|
||||||
|
password: "MOCK_PASS",
|
||||||
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||||
|
create: {
|
||||||
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
||||||
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mockBookingData = getMockRequestDataForBooking({
|
||||||
|
data: {
|
||||||
|
// Try booking the first available free timeslot in both the users' schedules
|
||||||
|
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
|
||||||
|
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
|
||||||
|
eventTypeId: 1,
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const createdBooking = await handleNewBooking({
|
||||||
|
bookingData: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that the booking was created successfully
|
||||||
|
expect(createdBooking).toBeDefined();
|
||||||
|
expect(createdBooking.responses).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that lucky users were selected (one from each group)
|
||||||
|
expect(createdBooking.luckyUsers).toBeDefined();
|
||||||
|
expect(createdBooking.luckyUsers).toHaveLength(2);
|
||||||
|
|
||||||
|
// Verify that the selected users are from different groups
|
||||||
|
const selectedUserIds = createdBooking.luckyUsers;
|
||||||
|
const group1UserIds = [teamMembers[0].id, teamMembers[1].id]; // group-1
|
||||||
|
const group2UserIds = [teamMembers[2].id, teamMembers[3].id]; // group-2
|
||||||
|
|
||||||
|
// Check that one user is from group-1 and one is from group-2
|
||||||
|
const hasGroup1User = selectedUserIds.some((id) => group1UserIds.includes(id));
|
||||||
|
const hasGroup2User = selectedUserIds.some((id) => group2UserIds.includes(id));
|
||||||
|
|
||||||
|
expect(hasGroup1User).toBe(true);
|
||||||
|
expect(hasGroup2User).toBe(true);
|
||||||
|
|
||||||
|
// Verify that the booking has the correct attendees
|
||||||
|
expect(createdBooking.attendees).toHaveLength(2);
|
||||||
|
expect(createdBooking.attendees[0].email).toBe(booker.email);
|
||||||
|
|
||||||
|
// The second attendee should be one of the selected lucky users
|
||||||
|
const secondAttendeeEmail = createdBooking.attendees[1].email;
|
||||||
|
const selectedUserEmails = selectedUserIds.map(
|
||||||
|
(id) => teamMembers.find((member) => member.id === id)?.email
|
||||||
|
);
|
||||||
|
expect(selectedUserEmails).toContain(secondAttendeeEmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Throws error when one round robin group has no available hosts", async () => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMembers = [
|
||||||
|
{
|
||||||
|
name: "Team Member 1",
|
||||||
|
username: "team-member-1",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-1@example.com",
|
||||||
|
id: 102,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Team Member 2",
|
||||||
|
username: "team-member-2",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-2@example.com",
|
||||||
|
id: 103,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Team Member 3",
|
||||||
|
username: "team-member-3",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-3@example.com",
|
||||||
|
id: 104,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Team Member 4",
|
||||||
|
username: "team-member-4",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-4@example.com",
|
||||||
|
id: 105,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const organizer = getOrganizer({
|
||||||
|
name: "Organizer",
|
||||||
|
email: "organizer@example.com",
|
||||||
|
id: 101,
|
||||||
|
defaultScheduleId: null,
|
||||||
|
schedules: [TestData.schedules.IstMorningShift],
|
||||||
|
credentials: [getGoogleCalendarCredential()],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: TestData.apps["google-calendar"].type,
|
||||||
|
externalId: "organizer@google-calendar.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 15,
|
||||||
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
length: 15,
|
||||||
|
users: [
|
||||||
|
{ id: teamMembers[0].id },
|
||||||
|
{ id: teamMembers[1].id },
|
||||||
|
{ id: teamMembers[2].id },
|
||||||
|
{ id: teamMembers[3].id },
|
||||||
|
],
|
||||||
|
hosts: [
|
||||||
|
{ userId: teamMembers[0].id, isFixed: false, groupId: "group-1" },
|
||||||
|
{ userId: teamMembers[1].id, isFixed: false, groupId: "group-1" },
|
||||||
|
{ userId: teamMembers[2].id, isFixed: false, groupId: "group-2" },
|
||||||
|
{ userId: teamMembers[3].id, isFixed: false, groupId: "group-2" },
|
||||||
|
],
|
||||||
|
hostGroups: [
|
||||||
|
{ id: "group-1", name: "Group 1" },
|
||||||
|
{ id: "group-2", name: "Group 2" },
|
||||||
|
],
|
||||||
|
schedule: TestData.schedules.IstMorningShift,
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: TestData.apps["google-calendar"].type,
|
||||||
|
externalId: "event-type-1@google-calendar.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer,
|
||||||
|
usersApartFromOrganizer: teamMembers,
|
||||||
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||||
|
// Add existing bookings to make only group-2 hosts busy
|
||||||
|
bookings: [
|
||||||
|
{
|
||||||
|
userId: teamMembers[0].id, // Team Member 1 (group-1)
|
||||||
|
eventTypeId: 1,
|
||||||
|
startTime: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
|
||||||
|
endTime: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
|
||||||
|
status: "ACCEPTED",
|
||||||
|
attendees: [
|
||||||
|
{
|
||||||
|
email: "existing-booker-1@example.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: teamMembers[1].id, // Team Member 2 (group-1)
|
||||||
|
eventTypeId: 1,
|
||||||
|
startTime: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
|
||||||
|
endTime: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
|
||||||
|
status: "ACCEPTED",
|
||||||
|
attendees: [
|
||||||
|
{
|
||||||
|
email: "existing-booker-2@example.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mockSuccessfulVideoMeetingCreation({
|
||||||
|
metadataLookupKey: appStoreMetadata.dailyvideo.dirName || "dailyvideo",
|
||||||
|
videoMeetingData: {
|
||||||
|
id: "MOCK_ID",
|
||||||
|
password: "MOCK_PASS",
|
||||||
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||||
|
create: {
|
||||||
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
||||||
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBookingData = getMockRequestDataForBooking({
|
||||||
|
data: {
|
||||||
|
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
|
||||||
|
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
|
||||||
|
eventTypeId: 1,
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expect handleNewBooking to throw an error because group-2 has no available hosts
|
||||||
|
await expect(
|
||||||
|
handleNewBooking({
|
||||||
|
bookingData: mockBookingData,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(ErrorCode.RoundRobinHostsUnavailableForBooking);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Creates successful booking even when one group has no hosts", async () => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "booker@example.com",
|
||||||
|
name: "Booker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMembers = [
|
||||||
|
{
|
||||||
|
name: "Team Member 1",
|
||||||
|
username: "team-member-1",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-1@example.com",
|
||||||
|
id: 102,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Team Member 2",
|
||||||
|
username: "team-member-2",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-2@example.com",
|
||||||
|
id: 103,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const organizer = getOrganizer({
|
||||||
|
name: "Organizer",
|
||||||
|
email: "organizer@example.com",
|
||||||
|
id: 101,
|
||||||
|
defaultScheduleId: null,
|
||||||
|
schedules: [TestData.schedules.IstMorningShift],
|
||||||
|
credentials: [getGoogleCalendarCredential()],
|
||||||
|
selectedCalendars: [TestData.selectedCalendars.google],
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: TestData.apps["google-calendar"].type,
|
||||||
|
externalId: "organizer@google-calendar.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 15,
|
||||||
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
length: 15,
|
||||||
|
users: [{ id: teamMembers[0].id }, { id: teamMembers[1].id }],
|
||||||
|
hosts: [
|
||||||
|
{ userId: teamMembers[0].id, isFixed: false, groupId: "group-1" },
|
||||||
|
{ userId: teamMembers[1].id, isFixed: false, groupId: "group-1" },
|
||||||
|
],
|
||||||
|
hostGroups: [
|
||||||
|
{ id: "group-1", name: "Group 1" },
|
||||||
|
{ id: "group-2", name: "Group 2" }, // Empty group with no hosts
|
||||||
|
],
|
||||||
|
schedule: TestData.schedules.IstMorningShift,
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: TestData.apps["google-calendar"].type,
|
||||||
|
externalId: "event-type-1@google-calendar.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer,
|
||||||
|
usersApartFromOrganizer: teamMembers,
|
||||||
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mockSuccessfulVideoMeetingCreation({
|
||||||
|
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
|
||||||
|
videoMeetingData: {
|
||||||
|
id: "MOCK_ID",
|
||||||
|
password: "MOCK_PASS",
|
||||||
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||||
|
create: {
|
||||||
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
||||||
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockBookingData = getMockRequestDataForBooking({
|
||||||
|
data: {
|
||||||
|
start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`,
|
||||||
|
end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`,
|
||||||
|
eventTypeId: 1,
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdBooking = await handleNewBooking({
|
||||||
|
bookingData: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that the booking was created successfully
|
||||||
|
expect(createdBooking).toBeDefined();
|
||||||
|
expect(createdBooking.responses).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify that lucky users were selected (only from group-1 since group-2 has no hosts)
|
||||||
|
expect(createdBooking.luckyUsers).toBeDefined();
|
||||||
|
expect(createdBooking.luckyUsers).toHaveLength(1);
|
||||||
|
|
||||||
|
// Verify that the selected user is from group-1
|
||||||
|
const selectedUserId = createdBooking.luckyUsers[0];
|
||||||
|
const group1UserIds = [teamMembers[0].id, teamMembers[1].id]; // group-1
|
||||||
|
expect(group1UserIds).toContain(selectedUserId);
|
||||||
|
|
||||||
|
// Verify that the booking has the correct attendees
|
||||||
|
expect(createdBooking.attendees).toHaveLength(1);
|
||||||
|
expect(createdBooking.attendees[0].email).toBe(booker.email);
|
||||||
|
|
||||||
|
// Verify that the booking user is the selected lucky user
|
||||||
|
expect(createdBooking.userId).toBe(selectedUserId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Seated Round Robin Event", () => {
|
||||||
|
test("For second seat booking, organizer remains the same with no team members included", async () => {
|
||||||
|
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||||
|
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||||
|
|
||||||
|
const eventManagerSpy = vi.spyOn(EventManager.prototype, "updateCalendarAttendees");
|
||||||
|
|
||||||
|
const booker = getBooker({
|
||||||
|
email: "seat2@example.com",
|
||||||
|
name: "Seat 2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignedHost = getOrganizer({
|
||||||
|
name: "Assigned Host",
|
||||||
|
email: "assigned-host@example.com",
|
||||||
|
id: 101,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMembers = [
|
||||||
|
{
|
||||||
|
name: "Team Member 1",
|
||||||
|
username: "team-member-1",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-1@example.com",
|
||||||
|
id: 102,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Team Member 2",
|
||||||
|
username: "team-member-2",
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
defaultScheduleId: null,
|
||||||
|
email: "team-member-2@example.com",
|
||||||
|
id: 103,
|
||||||
|
schedules: [TestData.schedules.IstEveningShift],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bookingId = 1;
|
||||||
|
const bookingUid = "abc123";
|
||||||
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
|
const bookingStartTime = `${plus1DateString}T04:00:00Z`;
|
||||||
|
const bookingEndTime = `${plus1DateString}T04:30:00Z`;
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slug: "seated-round-robin-event",
|
||||||
|
slotInterval: 30,
|
||||||
|
length: 30,
|
||||||
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
|
users: [{ id: assignedHost.id }, { id: teamMembers[0].id }, { id: teamMembers[1].id }],
|
||||||
|
hosts: [
|
||||||
|
{ userId: assignedHost.id, isFixed: false },
|
||||||
|
{ userId: teamMembers[0].id, isFixed: false },
|
||||||
|
{ userId: teamMembers[1].id, isFixed: false },
|
||||||
|
],
|
||||||
|
seatsPerTimeSlot: 3,
|
||||||
|
seatsShowAttendees: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
{
|
||||||
|
id: bookingId,
|
||||||
|
uid: bookingUid,
|
||||||
|
eventTypeId: 1,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
startTime: bookingStartTime,
|
||||||
|
endTime: bookingEndTime,
|
||||||
|
userId: assignedHost.id, // This is the assigned host for the booking
|
||||||
|
metadata: {
|
||||||
|
videoCallUrl: "https://existing-daily-video-call-url.example.com",
|
||||||
|
},
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
type: appStoreMetadata.dailyvideo.type,
|
||||||
|
uid: "MOCK_ID",
|
||||||
|
meetingId: "MOCK_ID",
|
||||||
|
meetingPassword: "MOCK_PASS",
|
||||||
|
meetingUrl: "http://mock-dailyvideo.example.com",
|
||||||
|
credentialId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attendees: [
|
||||||
|
getMockBookingAttendee({
|
||||||
|
id: 1,
|
||||||
|
name: "Seat 1",
|
||||||
|
email: "seat1@test.com",
|
||||||
|
locale: "en",
|
||||||
|
timeZone: "America/Toronto",
|
||||||
|
bookingSeat: {
|
||||||
|
referenceUid: "booking-seat-1",
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organizer: assignedHost,
|
||||||
|
usersApartFromOrganizer: [...teamMembers],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mockSuccessfulVideoMeetingCreation({
|
||||||
|
metadataLookupKey: "dailyvideo",
|
||||||
|
videoMeetingData: {
|
||||||
|
id: "MOCK_ID",
|
||||||
|
password: "MOCK_PASS",
|
||||||
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqBookingUser = "seatedAttendee";
|
||||||
|
|
||||||
|
const mockBookingData = getMockRequestDataForBooking({
|
||||||
|
data: {
|
||||||
|
eventTypeId: 1,
|
||||||
|
responses: {
|
||||||
|
email: booker.email,
|
||||||
|
name: booker.name,
|
||||||
|
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||||
|
},
|
||||||
|
bookingUid: bookingUid,
|
||||||
|
user: reqBookingUser,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleNewBooking({
|
||||||
|
bookingData: mockBookingData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(eventManagerSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const calendarEvent = eventManagerSpy.mock.calls[0][0];
|
||||||
|
|
||||||
|
expect(calendarEvent.organizer.email).toBe(assignedHost.email);
|
||||||
|
|
||||||
|
expect(calendarEvent.team?.members).toBeDefined();
|
||||||
|
expect(calendarEvent.team?.members.length).toBe(0);
|
||||||
|
|
||||||
|
const teamMemberEmails = calendarEvent.team?.members.map((member) => member.email);
|
||||||
|
expect(teamMemberEmails).not.toContain(teamMembers[0].email);
|
||||||
|
expect(teamMemberEmails).not.toContain(teamMembers[1].email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
-172
@@ -1,172 +0,0 @@
|
|||||||
import {
|
|
||||||
getBooker,
|
|
||||||
TestData,
|
|
||||||
getOrganizer,
|
|
||||||
createBookingScenario,
|
|
||||||
Timezones,
|
|
||||||
getScenarioData,
|
|
||||||
mockSuccessfulVideoMeetingCreation,
|
|
||||||
BookingLocations,
|
|
||||||
getDate,
|
|
||||||
getMockBookingAttendee,
|
|
||||||
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
|
||||||
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
|
|
||||||
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
|
||||||
|
|
||||||
import { describe, test, vi, expect } from "vitest";
|
|
||||||
|
|
||||||
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
|
|
||||||
import { SchedulingType } from "@calcom/prisma/enums";
|
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
|
||||||
|
|
||||||
describe("Seated Round Robin Events", () => {
|
|
||||||
setupAndTeardown();
|
|
||||||
|
|
||||||
test("For second seat booking, organizer remains the same with no team members included", async () => {
|
|
||||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
|
||||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
|
||||||
|
|
||||||
const eventManagerSpy = vi.spyOn(EventManager.prototype, "updateCalendarAttendees");
|
|
||||||
|
|
||||||
const booker = getBooker({
|
|
||||||
email: "seat2@example.com",
|
|
||||||
name: "Seat 2",
|
|
||||||
});
|
|
||||||
|
|
||||||
const assignedHost = getOrganizer({
|
|
||||||
name: "Assigned Host",
|
|
||||||
email: "assigned-host@example.com",
|
|
||||||
id: 101,
|
|
||||||
schedules: [TestData.schedules.IstWorkHours],
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamMembers = [
|
|
||||||
{
|
|
||||||
name: "Team Member 1",
|
|
||||||
username: "team-member-1",
|
|
||||||
timeZone: Timezones["+5:30"],
|
|
||||||
defaultScheduleId: null,
|
|
||||||
email: "team-member-1@example.com",
|
|
||||||
id: 102,
|
|
||||||
schedules: [TestData.schedules.IstEveningShift],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Team Member 2",
|
|
||||||
username: "team-member-2",
|
|
||||||
timeZone: Timezones["+5:30"],
|
|
||||||
defaultScheduleId: null,
|
|
||||||
email: "team-member-2@example.com",
|
|
||||||
id: 103,
|
|
||||||
schedules: [TestData.schedules.IstEveningShift],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const bookingId = 1;
|
|
||||||
const bookingUid = "abc123";
|
|
||||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
|
||||||
const bookingStartTime = `${plus1DateString}T04:00:00Z`;
|
|
||||||
const bookingEndTime = `${plus1DateString}T04:30:00Z`;
|
|
||||||
|
|
||||||
await createBookingScenario(
|
|
||||||
getScenarioData({
|
|
||||||
eventTypes: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
slug: "seated-round-robin-event",
|
|
||||||
slotInterval: 30,
|
|
||||||
length: 30,
|
|
||||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
||||||
users: [{ id: assignedHost.id }, { id: teamMembers[0].id }, { id: teamMembers[1].id }],
|
|
||||||
hosts: [
|
|
||||||
{ userId: assignedHost.id, isFixed: false },
|
|
||||||
{ userId: teamMembers[0].id, isFixed: false },
|
|
||||||
{ userId: teamMembers[1].id, isFixed: false },
|
|
||||||
],
|
|
||||||
seatsPerTimeSlot: 3,
|
|
||||||
seatsShowAttendees: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
bookings: [
|
|
||||||
{
|
|
||||||
id: bookingId,
|
|
||||||
uid: bookingUid,
|
|
||||||
eventTypeId: 1,
|
|
||||||
status: BookingStatus.ACCEPTED,
|
|
||||||
startTime: bookingStartTime,
|
|
||||||
endTime: bookingEndTime,
|
|
||||||
userId: assignedHost.id, // This is the assigned host for the booking
|
|
||||||
metadata: {
|
|
||||||
videoCallUrl: "https://existing-daily-video-call-url.example.com",
|
|
||||||
},
|
|
||||||
references: [
|
|
||||||
{
|
|
||||||
type: appStoreMetadata.dailyvideo.type,
|
|
||||||
uid: "MOCK_ID",
|
|
||||||
meetingId: "MOCK_ID",
|
|
||||||
meetingPassword: "MOCK_PASS",
|
|
||||||
meetingUrl: "http://mock-dailyvideo.example.com",
|
|
||||||
credentialId: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
attendees: [
|
|
||||||
getMockBookingAttendee({
|
|
||||||
id: 1,
|
|
||||||
name: "Seat 1",
|
|
||||||
email: "seat1@test.com",
|
|
||||||
locale: "en",
|
|
||||||
timeZone: "America/Toronto",
|
|
||||||
bookingSeat: {
|
|
||||||
referenceUid: "booking-seat-1",
|
|
||||||
data: {},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
organizer: assignedHost,
|
|
||||||
usersApartFromOrganizer: [...teamMembers],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
mockSuccessfulVideoMeetingCreation({
|
|
||||||
metadataLookupKey: "dailyvideo",
|
|
||||||
videoMeetingData: {
|
|
||||||
id: "MOCK_ID",
|
|
||||||
password: "MOCK_PASS",
|
|
||||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const reqBookingUser = "seatedAttendee";
|
|
||||||
|
|
||||||
const mockBookingData = getMockRequestDataForBooking({
|
|
||||||
data: {
|
|
||||||
eventTypeId: 1,
|
|
||||||
responses: {
|
|
||||||
email: booker.email,
|
|
||||||
name: booker.name,
|
|
||||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
|
||||||
},
|
|
||||||
bookingUid: bookingUid,
|
|
||||||
user: reqBookingUser,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await handleNewBooking({
|
|
||||||
bookingData: mockBookingData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(eventManagerSpy).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const calendarEvent = eventManagerSpy.mock.calls[0][0];
|
|
||||||
|
|
||||||
expect(calendarEvent.organizer.email).toBe(assignedHost.email);
|
|
||||||
|
|
||||||
expect(calendarEvent.team?.members).toBeDefined();
|
|
||||||
expect(calendarEvent.team?.members.length).toBe(0);
|
|
||||||
|
|
||||||
const teamMemberEmails = calendarEvent.team?.members.map((member) => member.email);
|
|
||||||
expect(teamMemberEmails).not.toContain(teamMembers[0].email);
|
|
||||||
expect(teamMemberEmails).not.toContain(teamMembers[1].email);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -41,6 +41,7 @@ export type IsFixedAwareUser = User & {
|
|||||||
weight?: number;
|
weight?: number;
|
||||||
userLevelSelectedCalendars: SelectedCalendar[];
|
userLevelSelectedCalendars: SelectedCalendar[];
|
||||||
allSelectedCalendars: SelectedCalendar[];
|
allSelectedCalendars: SelectedCalendar[];
|
||||||
|
groupId?: string | null;
|
||||||
availabilityData?: GetUserAvailabilityResult;
|
availabilityData?: GetUserAvailabilityResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send emails to new members
|
// Send emails to new members
|
||||||
const newMembers = users.filter((user) => !user.teams.find((team) => team.id === group.teamId));
|
const newMembers = users.filter((user) => !user.teams.find((team) => team.teamId === group.teamId));
|
||||||
const newOrgMembers = users.filter(
|
const newOrgMembers = users.filter(
|
||||||
(user) => !user.profiles.find((profile) => profile.organizationId === organizationId)
|
(user) => !user.profiles.find((profile) => profile.organizationId === organizationId)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ export const roundRobinManualReassignment = async ({
|
|||||||
throw new Error("Event type not found");
|
throw new Error("Event type not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventType.hostGroups && eventType.hostGroups.length > 1) {
|
||||||
|
roundRobinReassignLogger.error(
|
||||||
|
`Event type ${eventTypeId} has more than one round robin group, reassignment is not allowed`
|
||||||
|
);
|
||||||
|
throw new Error("Reassignment not allowed with more than one round robin group");
|
||||||
|
}
|
||||||
|
|
||||||
const eventTypeHosts = eventType.hosts.length
|
const eventTypeHosts = eventType.hosts.length
|
||||||
? eventType.hosts
|
? eventType.hosts
|
||||||
: eventType.users.map((user) => ({
|
: eventType.users.map((user) => ({
|
||||||
@@ -100,6 +107,7 @@ export const roundRobinManualReassignment = async ({
|
|||||||
weight: 100,
|
weight: 100,
|
||||||
schedule: null,
|
schedule: null,
|
||||||
createdAt: new Date(0), // use earliest possible date as fallback
|
createdAt: new Date(0), // use earliest possible date as fallback
|
||||||
|
groupId: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const fixedHost = eventTypeHosts.find((host) => host.isFixed);
|
const fixedHost = eventTypeHosts.find((host) => host.isFixed);
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ export const roundRobinReassignment = async ({
|
|||||||
throw new Error("Event type not found");
|
throw new Error("Event type not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventType.hostGroups && eventType.hostGroups.length > 1) {
|
||||||
|
logger.error(
|
||||||
|
`Event type ${eventTypeId} has more than one round robin group, reassignment is not allowed`
|
||||||
|
);
|
||||||
|
throw new Error("Reassignment not allowed with more than one round robin group");
|
||||||
|
}
|
||||||
|
|
||||||
eventType.hosts = eventType.hosts.length
|
eventType.hosts = eventType.hosts.length
|
||||||
? eventType.hosts
|
? eventType.hosts
|
||||||
: eventType.users.map((user) => ({
|
: eventType.users.map((user) => ({
|
||||||
@@ -98,6 +105,7 @@ export const roundRobinReassignment = async ({
|
|||||||
weight: 100,
|
weight: 100,
|
||||||
schedule: null,
|
schedule: null,
|
||||||
createdAt: new Date(0), // use earliest possible date as fallback
|
createdAt: new Date(0), // use earliest possible date as fallback
|
||||||
|
groupId: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (eventType.hosts.length === 0) {
|
if (eventType.hosts.length === 0) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
|
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
import type { CreateBtnProps, Option } from "./CreateButton";
|
import type { CreateBtnProps, Option } from "./CreateButton";
|
||||||
@@ -11,10 +13,20 @@ export function CreateButtonWithTeamsList(
|
|||||||
onlyShowWithNoTeams?: boolean;
|
onlyShowWithNoTeams?: boolean;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
includeOrg?: boolean;
|
includeOrg?: boolean;
|
||||||
|
withPermission?: {
|
||||||
|
permission: PermissionString;
|
||||||
|
fallbackRoles?: MembershipRole[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const query = trpc.viewer.loggedInViewerRouter.teamsAndUserProfilesQuery.useQuery({
|
const query = trpc.viewer.loggedInViewerRouter.teamsAndUserProfilesQuery.useQuery({
|
||||||
includeOrg: props.includeOrg,
|
includeOrg: props.includeOrg,
|
||||||
|
withPermission: props.withPermission
|
||||||
|
? {
|
||||||
|
permission: props.withPermission.permission,
|
||||||
|
fallbackRoles: props.withPermission.fallbackRoles,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
if (!query.data) return null;
|
if (!query.data) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ import WorkflowStepContainer from "./WorkflowStepContainer";
|
|||||||
|
|
||||||
type User = RouterOutputs["viewer"]["me"]["get"];
|
type User = RouterOutputs["viewer"]["me"]["get"];
|
||||||
|
|
||||||
|
interface WorkflowPermissions {
|
||||||
|
canView: boolean;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canManage: boolean;
|
||||||
|
readOnly: boolean; // Keep for backward compatibility
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
form: UseFormReturn<FormValues>;
|
form: UseFormReturn<FormValues>;
|
||||||
workflowId: number;
|
workflowId: number;
|
||||||
@@ -33,13 +41,31 @@ interface Props {
|
|||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
isOrg: boolean;
|
isOrg: boolean;
|
||||||
allOptions: Option[];
|
allOptions: Option[];
|
||||||
|
permissions?: WorkflowPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WorkflowDetailsPage(props: Props) {
|
export default function WorkflowDetailsPage(props: Props) {
|
||||||
const { form, workflowId, selectedOptions, setSelectedOptions, teamId, isOrg, allOptions } = props;
|
const {
|
||||||
|
form,
|
||||||
|
workflowId,
|
||||||
|
selectedOptions,
|
||||||
|
setSelectedOptions,
|
||||||
|
teamId,
|
||||||
|
isOrg,
|
||||||
|
allOptions,
|
||||||
|
permissions: _permissions,
|
||||||
|
} = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const permissions = _permissions || {
|
||||||
|
canView: !teamId ? true : !props.readOnly,
|
||||||
|
canUpdate: !teamId ? true : !props.readOnly,
|
||||||
|
canDelete: !teamId ? true : !props.readOnly,
|
||||||
|
canManage: !teamId ? true : !props.readOnly,
|
||||||
|
readOnly: !teamId ? false : props.readOnly,
|
||||||
|
};
|
||||||
|
|
||||||
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
|
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
|
||||||
|
|
||||||
const [reload, setReload] = useState(false);
|
const [reload, setReload] = useState(false);
|
||||||
@@ -160,7 +186,7 @@ export default function WorkflowDetailsPage(props: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:border-subtle my-7 border-transparent md:border-t" />
|
<div className="md:border-subtle my-7 border-transparent md:border-t" />
|
||||||
{!props.readOnly && (
|
{permissions.canDelete && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
StartIcon="trash-2"
|
StartIcon="trash-2"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import type { WorkflowPermissions } from "@calcom/lib/server/repository/workflow-permissions";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { ArrowButton } from "@calcom/ui/components/arrow-button";
|
import { ArrowButton } from "@calcom/ui/components/arrow-button";
|
||||||
@@ -52,7 +53,8 @@ export type WorkflowType = Workflow & {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean; // Keep for backward compatibility
|
||||||
|
permissions?: WorkflowPermissions;
|
||||||
isOrg?: boolean;
|
isOrg?: boolean;
|
||||||
};
|
};
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -136,7 +138,7 @@ export default function WorkflowListPage({ workflows }: Props) {
|
|||||||
: "Untitled"}
|
: "Untitled"}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{workflow.readOnly && (
|
{(workflow.permissions?.readOnly ?? workflow.readOnly) && (
|
||||||
<Badge variant="gray" className="ml-2 ">
|
<Badge variant="gray" className="ml-2 ">
|
||||||
{t("readonly")}
|
{t("readonly")}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -257,7 +259,9 @@ export default function WorkflowListPage({ workflows }: Props) {
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
StartIcon="pencil"
|
StartIcon="pencil"
|
||||||
disabled={workflow.readOnly}
|
disabled={
|
||||||
|
workflow.permissions ? !workflow.permissions.canUpdate : workflow.readOnly
|
||||||
|
}
|
||||||
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}
|
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}
|
||||||
data-testid="edit-button"
|
data-testid="edit-button"
|
||||||
/>
|
/>
|
||||||
@@ -270,40 +274,52 @@ export default function WorkflowListPage({ workflows }: Props) {
|
|||||||
}}
|
}}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
disabled={workflow.readOnly}
|
disabled={
|
||||||
|
workflow.permissions ? !workflow.permissions.canDelete : workflow.readOnly
|
||||||
|
}
|
||||||
StartIcon="trash-2"
|
StartIcon="trash-2"
|
||||||
data-testid="delete-button"
|
data-testid="delete-button"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
{!workflow.readOnly && (
|
{(workflow.permissions?.canUpdate ||
|
||||||
|
workflow.permissions?.canDelete ||
|
||||||
|
(!workflow.permissions && !workflow.readOnly)) && (
|
||||||
<div className="block sm:hidden">
|
<div className="block sm:hidden">
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" color="minimal" variant="icon" StartIcon="ellipsis" />
|
<Button type="button" color="minimal" variant="icon" StartIcon="ellipsis" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem>
|
{(workflow.permissions
|
||||||
<DropdownItem
|
? workflow.permissions.canUpdate
|
||||||
type="button"
|
: !workflow.readOnly) && (
|
||||||
StartIcon="pencil"
|
<DropdownMenuItem>
|
||||||
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}>
|
<DropdownItem
|
||||||
{t("edit")}
|
type="button"
|
||||||
</DropdownItem>
|
StartIcon="pencil"
|
||||||
</DropdownMenuItem>
|
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}>
|
||||||
<DropdownMenuItem>
|
{t("edit")}
|
||||||
<DropdownItem
|
</DropdownItem>
|
||||||
type="button"
|
</DropdownMenuItem>
|
||||||
color="destructive"
|
)}
|
||||||
StartIcon="trash-2"
|
{(workflow.permissions
|
||||||
onClick={() => {
|
? workflow.permissions.canDelete
|
||||||
setDeleteDialogOpen(true);
|
: !workflow.readOnly) && (
|
||||||
setwWorkflowToDeleteId(workflow.id);
|
<DropdownMenuItem>
|
||||||
}}>
|
<DropdownItem
|
||||||
{t("delete")}
|
type="button"
|
||||||
</DropdownItem>
|
color="destructive"
|
||||||
</DropdownMenuItem>
|
StartIcon="trash-2"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
setwWorkflowToDeleteId(workflow.id);
|
||||||
|
}}>
|
||||||
|
{t("delete")}
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
|||||||
type: evt.eventType?.slug || "",
|
type: evt.eventType?.slug || "",
|
||||||
organizer: { ...evt.organizer, language: { ...evt.organizer.language, translate: organizerT } },
|
organizer: { ...evt.organizer, language: { ...evt.organizer.language, translate: organizerT } },
|
||||||
attendees: [attendee],
|
attendees: [attendee],
|
||||||
|
location: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || evt.location,
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachments = includeCalendarEvent
|
const attachments = includeCalendarEvent
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ function WorkflowsPage({ filteredList }: PageProps) {
|
|||||||
disableMobileButton={true}
|
disableMobileButton={true}
|
||||||
onlyShowWithNoTeams={true}
|
onlyShowWithNoTeams={true}
|
||||||
includeOrg={true}
|
includeOrg={true}
|
||||||
|
withPermission={{
|
||||||
|
permission: "workflow.create",
|
||||||
|
fallbackRoles: ["ADMIN", "OWNER"],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}>
|
}>
|
||||||
@@ -99,6 +103,10 @@ function WorkflowsPage({ filteredList }: PageProps) {
|
|||||||
disableMobileButton={true}
|
disableMobileButton={true}
|
||||||
onlyShowWithTeams={true}
|
onlyShowWithTeams={true}
|
||||||
includeOrg={true}
|
includeOrg={true}
|
||||||
|
withPermission={{
|
||||||
|
permission: "workflow.create",
|
||||||
|
fallbackRoles: ["ADMIN", "OWNER"],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import Shell, { ShellMain } from "@calcom/features/shell/Shell";
|
|||||||
import { SENDER_ID } from "@calcom/lib/constants";
|
import { SENDER_ID } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import type { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
|
||||||
import type { TimeUnit, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
import type { TimeUnit, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||||
import { MembershipRole, WorkflowActions } from "@calcom/prisma/enums";
|
import { WorkflowActions } from "@calcom/prisma/enums";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||||
@@ -43,17 +42,9 @@ export type FormValues = {
|
|||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
workflow: number;
|
workflow: number;
|
||||||
workflowData?: Awaited<ReturnType<typeof WorkflowRepository.getById>>;
|
|
||||||
verifiedNumbers?: Awaited<ReturnType<typeof WorkflowRepository.getVerifiedNumbers>>;
|
|
||||||
verifiedEmails?: Awaited<ReturnType<typeof WorkflowRepository.getVerifiedEmails>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function WorkflowPage({
|
function WorkflowPage({ workflow: workflowId }: PageProps) {
|
||||||
workflow: workflowId,
|
|
||||||
workflowData: workflowDataProp,
|
|
||||||
verifiedNumbers: verifiedNumbersProp,
|
|
||||||
verifiedEmails: verifiedEmailsProp,
|
|
||||||
}: PageProps) {
|
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
|
||||||
@@ -73,35 +64,23 @@ function WorkflowPage({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
data: workflowData,
|
data: workflowData,
|
||||||
isError: _isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
isPending: _isPendingWorkflow,
|
isPending: isPendingWorkflow,
|
||||||
} = trpc.viewer.workflows.get.useQuery(
|
} = trpc.viewer.workflows.get.useQuery({ id: +workflowId });
|
||||||
{ id: +workflowId },
|
|
||||||
{
|
|
||||||
enabled: workflowDataProp ? false : !!workflowId,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const workflow = workflowDataProp || workflowData;
|
const workflow = workflowData;
|
||||||
const isPendingWorkflow = workflowDataProp ? false : _isPendingWorkflow;
|
|
||||||
const isError = workflowDataProp ? false : _isError;
|
|
||||||
|
|
||||||
const { data: verifiedNumbersData } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(
|
const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(
|
||||||
{ teamId: workflow?.team?.id },
|
{ teamId: workflow?.team?.id },
|
||||||
{
|
{
|
||||||
enabled: verifiedNumbersProp ? false : !!workflow?.id,
|
enabled: !!workflow?.id,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const verifiedNumbers = verifiedNumbersProp || verifiedNumbersData;
|
|
||||||
|
|
||||||
const { data: verifiedEmailsData } = trpc.viewer.workflows.getVerifiedEmails.useQuery(
|
const { data: verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({
|
||||||
{
|
teamId: workflow?.team?.id,
|
||||||
teamId: workflow?.team?.id,
|
});
|
||||||
},
|
|
||||||
{ enabled: !verifiedEmailsProp }
|
|
||||||
);
|
|
||||||
const verifiedEmails = verifiedEmailsProp || verifiedEmailsData;
|
|
||||||
|
|
||||||
const isOrg = workflow?.team?.isOrganization ?? false;
|
const isOrg = workflow?.team?.isOrganization ?? false;
|
||||||
|
|
||||||
@@ -126,9 +105,7 @@ function WorkflowPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const readOnly =
|
const readOnly = !workflow?.permissions.canUpdate;
|
||||||
workflow?.team?.members?.find((member) => member.userId === session.data?.user.id)?.role ===
|
|
||||||
MembershipRole.MEMBER;
|
|
||||||
|
|
||||||
const isPending = isPendingWorkflow || isPendingEventTypes;
|
const isPending = isPendingWorkflow || isPendingEventTypes;
|
||||||
|
|
||||||
@@ -210,8 +187,8 @@ function WorkflowPage({
|
|||||||
const updateMutation = trpc.viewer.workflows.update.useMutation({
|
const updateMutation = trpc.viewer.workflows.update.useMutation({
|
||||||
onSuccess: async ({ workflow }) => {
|
onSuccess: async ({ workflow }) => {
|
||||||
if (workflow) {
|
if (workflow) {
|
||||||
utils.viewer.workflows.get.setData({ id: +workflow.id }, workflow);
|
await utils.viewer.workflows.get.invalidate({ id: +workflow.id });
|
||||||
setFormData(workflow);
|
|
||||||
showToast(
|
showToast(
|
||||||
t("workflow_updated_successfully", {
|
t("workflow_updated_successfully", {
|
||||||
workflowName: workflow.name,
|
workflowName: workflow.name,
|
||||||
@@ -348,6 +325,7 @@ function WorkflowPage({
|
|||||||
{isAllDataLoaded && user ? (
|
{isAllDataLoaded && user ? (
|
||||||
<>
|
<>
|
||||||
<WorkflowDetailsPage
|
<WorkflowDetailsPage
|
||||||
|
permissions={workflow?.permissions}
|
||||||
form={form}
|
form={form}
|
||||||
workflowId={+workflowId}
|
workflowId={+workflowId}
|
||||||
user={user}
|
user={user}
|
||||||
|
|||||||
@@ -207,9 +207,13 @@ const ChooseEmbedTypesDialogContent = ({
|
|||||||
key={index}
|
key={index}
|
||||||
data-testid={embed.type}
|
data-testid={embed.type}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
gotoState({
|
if (embed.type === "headless") {
|
||||||
embedType: embed.type as EmbedType,
|
window.open("https://cal.com/help/routing/headless-routing", "_blank");
|
||||||
});
|
} else {
|
||||||
|
gotoState({
|
||||||
|
embedType: embed.type as EmbedType,
|
||||||
|
});
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
<div className="bg-default order-none box-border flex-none rounded-md border border-solid transition dark:bg-transparent dark:invert">
|
<div className="bg-default order-none box-border flex-none rounded-md border border-solid transition dark:bg-transparent dark:invert">
|
||||||
{embed.illustration}
|
{embed.illustration}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
import { EmbedDialog, EmbedButton } from "@calcom/features/embed/Embed";
|
import { EmbedDialog, EmbedButton } from "@calcom/features/embed/Embed";
|
||||||
|
import { IS_CALCOM } from "@calcom/lib/constants";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
import { tabs } from "./lib/EmbedTabs";
|
import { tabs } from "./lib/EmbedTabs";
|
||||||
@@ -8,11 +10,63 @@ import { useEmbedTypes } from "./lib/hooks";
|
|||||||
|
|
||||||
export const RoutingFormEmbedDialog = () => {
|
export const RoutingFormEmbedDialog = () => {
|
||||||
const types = useEmbedTypes();
|
const types = useEmbedTypes();
|
||||||
|
const { t } = useLocale();
|
||||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||||
const routingFormTypes = types.filter((type) => type.type !== "email");
|
const routingFormTypes = types.filter((type) => type.type !== "email");
|
||||||
|
|
||||||
|
// Add the headless option specifically for routing forms
|
||||||
|
const headlessType = {
|
||||||
|
title: t("use_my_own_form"),
|
||||||
|
subtitle: t("use_our_headless_routing_api"),
|
||||||
|
type: "headless",
|
||||||
|
illustration: (
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
className="rounded-md"
|
||||||
|
viewBox="0 0 308 265"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M0 1.99999C0 0.895423 0.895431 0 2 0H306C307.105 0 308 0.895431 308 2V263C308 264.105 307.105 265 306 265H2C0.895431 265 0 264.105 0 263V1.99999Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<rect x="24" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||||
|
<rect x="24" y="50.5" width="139" height="163" rx="6" fill="#F8F8F8" />
|
||||||
|
<g opacity="0.8">
|
||||||
|
<rect x="42" y="74" width="20" height="20" rx="2" fill="#374151" />
|
||||||
|
<path
|
||||||
|
d="M48 81L52 85L56 81"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<rect x="48" y="106" width="80" height="8" rx="6" fill="#F3F4F6" />
|
||||||
|
<rect x="48" y="118" width="48" height="4" rx="6" fill="#F3F4F6" />
|
||||||
|
<rect x="48" y="130" width="64" height="4" rx="6" fill="#F3F4F6" />
|
||||||
|
<rect x="48" y="142" width="32" height="4" rx="6" fill="#F3F4F6" />
|
||||||
|
<rect x="48" y="160" width="72" height="6" rx="3" fill="#292929" />
|
||||||
|
<rect x="176" y="50.5" width="108" height="164" rx="6" fill="#F3F4F6" />
|
||||||
|
<text x="220" y="120" textAnchor="middle" className="fill-gray-400" fontSize="8">
|
||||||
|
API
|
||||||
|
</text>
|
||||||
|
<rect x="200" y="125" width="40" height="2" rx="1" fill="#9CA3AF" />
|
||||||
|
<rect x="200" y="130" width="32" height="2" rx="1" fill="#9CA3AF" />
|
||||||
|
<rect x="200" y="135" width="36" height="2" rx="1" fill="#9CA3AF" />
|
||||||
|
<rect x="24" y="226.5" width="260" height="38.5" rx="6" fill="#F3F4F6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const routingFormTypesWithHeadless = IS_CALCOM
|
||||||
|
? [...routingFormTypes, headlessType]
|
||||||
|
: routingFormTypes;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmbedDialog
|
<EmbedDialog
|
||||||
types={routingFormTypes}
|
types={routingFormTypesWithHeadless}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
eventTypeHideOptionDisabled={true}
|
eventTypeHideOptionDisabled={true}
|
||||||
defaultBrandColor={user ? { brandColor: user.brandColor, darkBrandColor: user.darkBrandColor } : null}
|
defaultBrandColor={user ? { brandColor: user.brandColor, darkBrandColor: user.darkBrandColor } : null}
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ export default function MyApp() {
|
|||||||
>Click me</button>;
|
>Click me</button>;
|
||||||
};`;
|
};`;
|
||||||
},
|
},
|
||||||
|
headless: () => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"react-atom": {
|
"react-atom": {
|
||||||
inline: ({
|
inline: ({
|
||||||
@@ -230,17 +233,22 @@ export default function Booker( props : BookerProps ) {
|
|||||||
);
|
);
|
||||||
};`;
|
};`;
|
||||||
},
|
},
|
||||||
|
headless: () => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
HTML: {
|
HTML: {
|
||||||
inline: ({
|
inline: ({
|
||||||
calLink,
|
calLink,
|
||||||
uiInstructionCode,
|
uiInstructionCode,
|
||||||
previewState,
|
previewState,
|
||||||
|
embedCalOrigin,
|
||||||
namespace,
|
namespace,
|
||||||
}: {
|
}: {
|
||||||
calLink: string;
|
calLink: string;
|
||||||
uiInstructionCode: string;
|
uiInstructionCode: string;
|
||||||
previewState: PreviewState["inline"];
|
previewState: PreviewState["inline"];
|
||||||
|
embedCalOrigin: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
}) => {
|
}) => {
|
||||||
return code`${getApiNameForVanillaJsSnippet({ namespace, mainApiName: "Cal" })}("inline", {
|
return code`${getApiNameForVanillaJsSnippet({ namespace, mainApiName: "Cal" })}("inline", {
|
||||||
@@ -251,16 +259,17 @@ export default function Booker( props : BookerProps ) {
|
|||||||
|
|
||||||
${uiInstructionCode}`;
|
${uiInstructionCode}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
"floating-popup": ({
|
"floating-popup": ({
|
||||||
calLink,
|
calLink,
|
||||||
uiInstructionCode,
|
uiInstructionCode,
|
||||||
previewState,
|
previewState,
|
||||||
|
embedCalOrigin,
|
||||||
namespace,
|
namespace,
|
||||||
}: {
|
}: {
|
||||||
calLink: string;
|
calLink: string;
|
||||||
uiInstructionCode: string;
|
uiInstructionCode: string;
|
||||||
previewState: PreviewState["floatingPopup"];
|
previewState: PreviewState["floatingPopup"];
|
||||||
|
embedCalOrigin: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
}) => {
|
}) => {
|
||||||
const floatingButtonArg = JSON.stringify({
|
const floatingButtonArg = JSON.stringify({
|
||||||
@@ -277,11 +286,13 @@ export default function Booker( props : BookerProps ) {
|
|||||||
calLink,
|
calLink,
|
||||||
uiInstructionCode,
|
uiInstructionCode,
|
||||||
previewState,
|
previewState,
|
||||||
|
embedCalOrigin,
|
||||||
namespace,
|
namespace,
|
||||||
}: {
|
}: {
|
||||||
calLink: string;
|
calLink: string;
|
||||||
uiInstructionCode: string;
|
uiInstructionCode: string;
|
||||||
previewState: PreviewState["elementClick"];
|
previewState: PreviewState["elementClick"];
|
||||||
|
embedCalOrigin: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
}) => {
|
}) => {
|
||||||
return code`
|
return code`
|
||||||
@@ -292,6 +303,9 @@ export default function Booker( props : BookerProps ) {
|
|||||||
|
|
||||||
${uiInstructionCode}`;
|
${uiInstructionCode}`;
|
||||||
},
|
},
|
||||||
|
headless: () => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,8 @@ const getEmbedTypeSpecificString = ({
|
|||||||
...codeGeneratorInput,
|
...codeGeneratorInput,
|
||||||
previewState: previewState.elementClick,
|
previewState: previewState.elementClick,
|
||||||
});
|
});
|
||||||
|
} else if (embedType === "headless") {
|
||||||
|
return frameworkCodes[embedType]();
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import type { Brand } from "@calcom/types/utils";
|
|||||||
import type { tabs } from "../lib/EmbedTabs";
|
import type { tabs } from "../lib/EmbedTabs";
|
||||||
import type { useEmbedTypes } from "../lib/hooks";
|
import type { useEmbedTypes } from "../lib/hooks";
|
||||||
|
|
||||||
export type EmbedType = "inline" | "floating-popup" | "element-click" | "email";
|
export type EmbedType = "inline" | "floating-popup" | "element-click" | "email" | "headless";
|
||||||
type EmbedConfig = {
|
type EmbedConfig = {
|
||||||
layout?: BookerLayouts;
|
layout?: BookerLayouts;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const CheckedHostField = ({
|
|||||||
onChange,
|
onChange,
|
||||||
helperText,
|
helperText,
|
||||||
isRRWeightsEnabled,
|
isRRWeightsEnabled,
|
||||||
|
groupId,
|
||||||
customClassNames,
|
customClassNames,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
@@ -72,6 +73,7 @@ const CheckedHostField = ({
|
|||||||
options?: Options<CheckedSelectOption>;
|
options?: Options<CheckedSelectOption>;
|
||||||
helperText?: React.ReactNode | string;
|
helperText?: React.ReactNode | string;
|
||||||
isRRWeightsEnabled?: boolean;
|
isRRWeightsEnabled?: boolean;
|
||||||
|
groupId: string | null;
|
||||||
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
|
} & Omit<Partial<ComponentProps<typeof CheckedTeamSelect>>, "onChange" | "value">) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-md">
|
<div className="flex flex-col rounded-md">
|
||||||
@@ -88,6 +90,7 @@ const CheckedHostField = ({
|
|||||||
priority: option.priority ?? 2,
|
priority: option.priority ?? 2,
|
||||||
weight: option.weight ?? 100,
|
weight: option.weight ?? 100,
|
||||||
scheduleId: option.defaultScheduleId,
|
scheduleId: option.defaultScheduleId,
|
||||||
|
groupId: option.groupId,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -97,7 +100,13 @@ const CheckedHostField = ({
|
|||||||
const option = options.find((member) => member.value === host.userId.toString());
|
const option = options.find((member) => member.value === host.userId.toString());
|
||||||
if (!option) return acc;
|
if (!option) return acc;
|
||||||
|
|
||||||
acc.push({ ...option, priority: host.priority ?? 2, isFixed, weight: host.weight ?? 100 });
|
acc.push({
|
||||||
|
...option,
|
||||||
|
priority: host.priority ?? 2,
|
||||||
|
isFixed,
|
||||||
|
weight: host.weight ?? 100,
|
||||||
|
groupId: host.groupId,
|
||||||
|
});
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, [] as CheckedSelectOption[])}
|
}, [] as CheckedSelectOption[])}
|
||||||
@@ -106,6 +115,7 @@ const CheckedHostField = ({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
isRRWeightsEnabled={isRRWeightsEnabled}
|
isRRWeightsEnabled={isRRWeightsEnabled}
|
||||||
customClassNames={customClassNames}
|
customClassNames={customClassNames}
|
||||||
|
groupId={groupId}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,6 +188,7 @@ export type AddMembersWithSwitchProps = {
|
|||||||
isRRWeightsEnabled?: boolean;
|
isRRWeightsEnabled?: boolean;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
isSegmentApplicable?: boolean;
|
isSegmentApplicable?: boolean;
|
||||||
|
groupId: string | null;
|
||||||
"data-testid"?: string;
|
"data-testid"?: string;
|
||||||
customClassNames?: AddMembersWithSwitchCustomClassNames;
|
customClassNames?: AddMembersWithSwitchCustomClassNames;
|
||||||
};
|
};
|
||||||
@@ -244,6 +255,7 @@ export function AddMembersWithSwitch({
|
|||||||
isRRWeightsEnabled,
|
isRRWeightsEnabled,
|
||||||
teamId,
|
teamId,
|
||||||
isSegmentApplicable,
|
isSegmentApplicable,
|
||||||
|
groupId,
|
||||||
customClassNames,
|
customClassNames,
|
||||||
...rest
|
...rest
|
||||||
}: AddMembersWithSwitchProps) {
|
}: AddMembersWithSwitchProps) {
|
||||||
@@ -273,13 +285,15 @@ export function AddMembersWithSwitch({
|
|||||||
case AssignmentState.TEAM_MEMBERS_IN_SEGMENT_ENABLED:
|
case AssignmentState.TEAM_MEMBERS_IN_SEGMENT_ENABLED:
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AssignAllTeamMembers
|
{!groupId && (
|
||||||
assignAllTeamMembers={assignAllTeamMembers}
|
<AssignAllTeamMembers
|
||||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
assignAllTeamMembers={assignAllTeamMembers}
|
||||||
onActive={onActive}
|
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||||
onInactive={onAssignAllTeamMembersInactive}
|
onActive={onActive}
|
||||||
customClassNames={customClassNames?.assingAllTeamMembers}
|
onInactive={onAssignAllTeamMembersInactive}
|
||||||
/>
|
customClassNames={customClassNames?.assingAllTeamMembers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{assignmentState !== AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE && (
|
{assignmentState !== AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -300,7 +314,7 @@ export function AddMembersWithSwitch({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
{assignmentState === AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE && (
|
{assignmentState === AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE && !groupId && (
|
||||||
<AssignAllTeamMembers
|
<AssignAllTeamMembers
|
||||||
assignAllTeamMembers={assignAllTeamMembers}
|
assignAllTeamMembers={assignAllTeamMembers}
|
||||||
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
setAssignAllTeamMembers={setAssignAllTeamMembers}
|
||||||
@@ -317,9 +331,15 @@ export function AddMembersWithSwitch({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
isFixed={isFixed}
|
isFixed={isFixed}
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
options={teamMembers.sort(sortByLabel)}
|
options={teamMembers
|
||||||
|
.map((member) => ({
|
||||||
|
...member,
|
||||||
|
groupId: groupId,
|
||||||
|
}))
|
||||||
|
.sort(sortByLabel)}
|
||||||
placeholder={placeholder ?? t("add_attendees")}
|
placeholder={placeholder ?? t("add_attendees")}
|
||||||
isRRWeightsEnabled={isRRWeightsEnabled}
|
isRRWeightsEnabled={isRRWeightsEnabled}
|
||||||
|
groupId={groupId}
|
||||||
customClassNames={customClassNames?.teamMemberSelect}
|
customClassNames={customClassNames?.teamMemberSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +360,7 @@ const AddMembersWithSwitchWrapper = ({
|
|||||||
[isPlatform]
|
[isPlatform]
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md ">
|
<div className="rounded-md">
|
||||||
<div className={`flex flex-col rounded-md pb-2 pt-6 ${containerClassName}`}>
|
<div className={`flex flex-col rounded-md pb-2 pt-6 ${containerClassName}`}>
|
||||||
<AddMembersWithSwitchWrapped {...props} />
|
<AddMembersWithSwitchWrapped {...props} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,17 +2,18 @@
|
|||||||
|
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Props } from "react-select";
|
import type { Options, Props } from "react-select";
|
||||||
|
|
||||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||||
import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types";
|
import type { SelectClassNames } from "@calcom/features/eventtypes/lib/types";
|
||||||
|
import { getHostsFromOtherGroups } from "@calcom/lib/bookings/hostGroupUtils";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { Select } from "@calcom/ui/components/form";
|
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
|
||||||
import { Avatar } from "@calcom/ui/components/avatar";
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import { Select } from "@calcom/ui/components/form";
|
||||||
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
import type { PriorityDialogCustomClassNames, WeightDialogCustomClassNames } from "./HostEditDialogs";
|
import type { PriorityDialogCustomClassNames, WeightDialogCustomClassNames } from "./HostEditDialogs";
|
||||||
import { PriorityDialog, WeightDialog } from "./HostEditDialogs";
|
import { PriorityDialog, WeightDialog } from "./HostEditDialogs";
|
||||||
@@ -26,6 +27,7 @@ export type CheckedSelectOption = {
|
|||||||
isFixed?: boolean;
|
isFixed?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
defaultScheduleId?: number | null;
|
defaultScheduleId?: number | null;
|
||||||
|
groupId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CheckedTeamSelectCustomClassNames = {
|
export type CheckedTeamSelectCustomClassNames = {
|
||||||
@@ -49,12 +51,15 @@ export const CheckedTeamSelect = ({
|
|||||||
value = [],
|
value = [],
|
||||||
isRRWeightsEnabled,
|
isRRWeightsEnabled,
|
||||||
customClassNames,
|
customClassNames,
|
||||||
|
groupId,
|
||||||
...props
|
...props
|
||||||
}: Omit<Props<CheckedSelectOption, true>, "value" | "onChange"> & {
|
}: Omit<Props<CheckedSelectOption, true>, "value" | "onChange"> & {
|
||||||
|
options?: Options<CheckedSelectOption>;
|
||||||
value?: readonly CheckedSelectOption[];
|
value?: readonly CheckedSelectOption[];
|
||||||
onChange: (value: readonly CheckedSelectOption[]) => void;
|
onChange: (value: readonly CheckedSelectOption[]) => void;
|
||||||
isRRWeightsEnabled?: boolean;
|
isRRWeightsEnabled?: boolean;
|
||||||
customClassNames?: CheckedTeamSelectCustomClassNames;
|
customClassNames?: CheckedTeamSelectCustomClassNames;
|
||||||
|
groupId: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const [priorityDialogOpen, setPriorityDialogOpen] = useState(false);
|
const [priorityDialogOpen, setPriorityDialogOpen] = useState(false);
|
||||||
@@ -65,6 +70,15 @@ export const CheckedTeamSelect = ({
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||||
|
|
||||||
|
const valueFromGroup = groupId ? value.filter((host) => host.groupId === groupId) : value;
|
||||||
|
|
||||||
|
const handleSelectChange = (newValue: readonly CheckedSelectOption[]) => {
|
||||||
|
const otherGroupsHosts = getHostsFromOtherGroups(value, groupId);
|
||||||
|
|
||||||
|
const newValueAllGroups = [...otherGroupsHosts, ...newValue.map((host) => ({ ...host, groupId }))];
|
||||||
|
props.onChange(newValueAllGroups);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Select
|
<Select
|
||||||
@@ -73,26 +87,30 @@ export const CheckedTeamSelect = ({
|
|||||||
placeholder={props.placeholder || t("select")}
|
placeholder={props.placeholder || t("select")}
|
||||||
isSearchable={true}
|
isSearchable={true}
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={valueFromGroup}
|
||||||
|
onChange={handleSelectChange}
|
||||||
isMulti
|
isMulti
|
||||||
className={customClassNames?.hostsSelect?.select}
|
className={customClassNames?.hostsSelect?.select}
|
||||||
innerClassNames={customClassNames?.hostsSelect?.innerClassNames}
|
innerClassNames={{
|
||||||
|
...customClassNames?.hostsSelect?.innerClassNames,
|
||||||
|
control: "rounded-md",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* This class name conditional looks a bit odd but it allows a seamless transition when using autoanimate
|
{/* This class name conditional looks a bit odd but it allows a seamless transition when using autoanimate
|
||||||
- Slides down from the top instead of just teleporting in from nowhere*/}
|
- Slides down from the top instead of just teleporting in from nowhere*/}
|
||||||
<ul
|
<ul
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mb-4 mt-3 rounded-md",
|
"mb-4 mt-3 rounded-md",
|
||||||
value.length >= 1 && "border-subtle border",
|
valueFromGroup.length >= 1 && "border-subtle border",
|
||||||
customClassNames?.selectedHostList?.container
|
customClassNames?.selectedHostList?.container
|
||||||
)}
|
)}
|
||||||
ref={animationRef}>
|
ref={animationRef}>
|
||||||
{value.map((option, index) => (
|
{valueFromGroup.map((option, index) => (
|
||||||
<>
|
<>
|
||||||
<li
|
<li
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
`flex px-3 py-2 ${index === value.length - 1 ? "" : "border-subtle border-b"}`,
|
`flex px-3 py-2 ${index === valueFromGroup.length - 1 ? "" : "border-subtle border-b"}`,
|
||||||
customClassNames?.selectedHostList?.listItem?.container
|
customClassNames?.selectedHostList?.listItem?.container
|
||||||
)}>
|
)}>
|
||||||
{!isPlatform && <Avatar size="sm" imageSrc={option.avatar} alt={option.label} />}
|
{!isPlatform && <Avatar size="sm" imageSrc={option.avatar} alt={option.label} />}
|
||||||
@@ -170,6 +188,7 @@ export const CheckedTeamSelect = ({
|
|||||||
isOpenDialog={priorityDialogOpen}
|
isOpenDialog={priorityDialogOpen}
|
||||||
setIsOpenDialog={setPriorityDialogOpen}
|
setIsOpenDialog={setPriorityDialogOpen}
|
||||||
option={currentOption}
|
option={currentOption}
|
||||||
|
options={options}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
customClassNames={customClassNames?.priorityDialog}
|
customClassNames={customClassNames?.priorityDialog}
|
||||||
/>
|
/>
|
||||||
@@ -177,6 +196,7 @@ export const CheckedTeamSelect = ({
|
|||||||
isOpenDialog={weightDialogOpen}
|
isOpenDialog={weightDialogOpen}
|
||||||
setIsOpenDialog={setWeightDialogOpen}
|
setIsOpenDialog={setWeightDialogOpen}
|
||||||
option={currentOption}
|
option={currentOption}
|
||||||
|
options={options}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
customClassNames={customClassNames?.weightDialog}
|
customClassNames={customClassNames?.weightDialog}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ export const EditWeightsForAllTeamMembers = ({
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// Create a map of existing hosts for easy lookup
|
// Create a map of existing hosts for easy lookup
|
||||||
const existingHostsMap = new Map(value.map((host) => [host.userId.toString(), host]));
|
const existingHostsMap = new Map(
|
||||||
|
value.filter((host) => !host.isFixed).map((host) => [host.userId.toString(), host])
|
||||||
|
);
|
||||||
|
|
||||||
// Create the updated value by processing all team members
|
// Create the updated value by processing all team members
|
||||||
const updatedValue = teamMembers
|
const updatedValue = teamMembers
|
||||||
@@ -156,6 +158,7 @@ export const EditWeightsForAllTeamMembers = ({
|
|||||||
isFixed: existingHost?.isFixed ?? false,
|
isFixed: existingHost?.isFixed ?? false,
|
||||||
priority: existingHost?.priority ?? 0,
|
priority: existingHost?.priority ?? 0,
|
||||||
weight: localWeights[member.value] ?? existingHost?.weight ?? 100,
|
weight: localWeights[member.value] ?? existingHost?.weight ?? 100,
|
||||||
|
groupId: existingHost?.groupId ?? null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean) as Host[];
|
.filter(Boolean) as Host[];
|
||||||
@@ -238,7 +241,10 @@ export const EditWeightsForAllTeamMembers = ({
|
|||||||
)
|
)
|
||||||
.filter((member) => {
|
.filter((member) => {
|
||||||
// When assignAllTeamMembers is false, only include members that exist in value array
|
// When assignAllTeamMembers is false, only include members that exist in value array
|
||||||
return assignAllTeamMembers || value.some((host) => host.userId === parseInt(member.value, 10));
|
return (
|
||||||
|
assignAllTeamMembers ||
|
||||||
|
value.some((host) => !host.isFixed && host.userId === parseInt(member.value, 10))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}, [teamMembers, localWeights, searchQuery, assignAllTeamMembers, value]);
|
}, [teamMembers, localWeights, searchQuery, assignAllTeamMembers, value]);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type Host = {
|
|||||||
priority: number;
|
priority: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
scheduleId?: number | null;
|
scheduleId?: number | null;
|
||||||
|
groupId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomInputParsed = typeof customInputSchema._output;
|
export type CustomInputParsed = typeof customInputSchema._output;
|
||||||
|
|||||||
@@ -96,10 +96,11 @@ function EventTypeSingleLayout({
|
|||||||
const [Shell] = useMemo(() => {
|
const [Shell] = useMemo(() => {
|
||||||
return isPlatform ? [PlatformShell] : [WebShell];
|
return isPlatform ? [PlatformShell] : [WebShell];
|
||||||
}, [isPlatform]);
|
}, [isPlatform]);
|
||||||
|
const teamId = eventType.team?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
backPath="/event-types"
|
backPath={teamId ? `/event-types?teamId=${teamId}` : "/event-types"}
|
||||||
title={`${eventType.title} | ${t("event_type")}`}
|
title={`${eventType.title} | ${t("event_type")}`}
|
||||||
heading={eventType.title}
|
heading={eventType.title}
|
||||||
CTA={
|
CTA={
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
import type { Options } from "react-select";
|
||||||
|
|
||||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||||
import type {
|
import type {
|
||||||
@@ -9,6 +10,8 @@ import type {
|
|||||||
InputClassNames,
|
InputClassNames,
|
||||||
SelectClassNames,
|
SelectClassNames,
|
||||||
} from "@calcom/features/eventtypes/lib/types";
|
} from "@calcom/features/eventtypes/lib/types";
|
||||||
|
import { groupHostsByGroupId, getHostsFromOtherGroups } from "@calcom/lib/bookings/hostGroupUtils";
|
||||||
|
import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
@@ -24,6 +27,7 @@ interface IDialog {
|
|||||||
isOpenDialog: boolean;
|
isOpenDialog: boolean;
|
||||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||||
option: CheckedSelectOption;
|
option: CheckedSelectOption;
|
||||||
|
options: Options<CheckedSelectOption>;
|
||||||
onChange: (value: readonly CheckedSelectOption[]) => void;
|
onChange: (value: readonly CheckedSelectOption[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +41,7 @@ export const PriorityDialog = (
|
|||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { isOpenDialog, setIsOpenDialog, option, onChange, customClassNames } = props;
|
const { isOpenDialog, setIsOpenDialog, option, options, onChange, customClassNames } = props;
|
||||||
const { getValues } = useFormContext<FormValues>();
|
const { getValues } = useFormContext<FormValues>();
|
||||||
|
|
||||||
const priorityOptions = [
|
const priorityOptions = [
|
||||||
@@ -53,21 +57,46 @@ export const PriorityDialog = (
|
|||||||
if (!!newPriority) {
|
if (!!newPriority) {
|
||||||
const hosts: Host[] = getValues("hosts");
|
const hosts: Host[] = getValues("hosts");
|
||||||
const isRRWeightsEnabled = getValues("isRRWeightsEnabled");
|
const isRRWeightsEnabled = getValues("isRRWeightsEnabled");
|
||||||
const updatedHosts = hosts
|
const hostGroups = getValues("hostGroups");
|
||||||
.filter((host) => !host.isFixed)
|
const rrHosts = hosts.filter((host) => !host.isFixed);
|
||||||
.map((host) => {
|
|
||||||
return {
|
|
||||||
...option,
|
|
||||||
value: host.userId.toString(),
|
|
||||||
priority: host.userId === parseInt(option.value, 10) ? newPriority.value : host.priority,
|
|
||||||
isFixed: false,
|
|
||||||
weight: host.weight,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedHosts = updatedHosts.sort((a, b) => sortHosts(a, b, isRRWeightsEnabled));
|
const groupedHosts = groupHostsByGroupId({ hosts: rrHosts, hostGroups });
|
||||||
|
|
||||||
onChange(sortedHosts);
|
let sortedHostGroup: CheckedSelectOption[] = [];
|
||||||
|
|
||||||
|
const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID];
|
||||||
|
|
||||||
|
if (hostGroupToSort) {
|
||||||
|
sortedHostGroup = hostGroupToSort
|
||||||
|
.map((host) => {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
value: host.userId.toString(),
|
||||||
|
priority: host.userId === parseInt(option.value, 10) ? newPriority.value : host.priority,
|
||||||
|
isFixed: false,
|
||||||
|
weight: host.weight,
|
||||||
|
groupId: host.groupId,
|
||||||
|
userId: host.userId,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => sortHosts(a, b, isRRWeightsEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherGroupsHosts = getHostsFromOtherGroups(rrHosts, option.groupId);
|
||||||
|
|
||||||
|
const otherGroupsOptions = otherGroupsHosts.map((host) => {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
value: host.userId.toString(),
|
||||||
|
priority: host.priority,
|
||||||
|
weight: host.weight,
|
||||||
|
isFixed: host.isFixed,
|
||||||
|
groupId: host.groupId,
|
||||||
|
userId: host.userId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const updatedHosts = [...otherGroupsOptions, ...sortedHostGroup];
|
||||||
|
onChange(updatedHosts);
|
||||||
}
|
}
|
||||||
setIsOpenDialog(false);
|
setIsOpenDialog(false);
|
||||||
};
|
};
|
||||||
@@ -128,27 +157,75 @@ export type WeightDialogCustomClassNames = {
|
|||||||
};
|
};
|
||||||
export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogCustomClassNames }) => {
|
export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogCustomClassNames }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { isOpenDialog, setIsOpenDialog, option, onChange, customClassNames } = props;
|
const { isOpenDialog, setIsOpenDialog, option, options, onChange, customClassNames } = props;
|
||||||
const { getValues } = useFormContext<FormValues>();
|
const { getValues } = useFormContext<FormValues>();
|
||||||
const [newWeight, setNewWeight] = useState<number | undefined>();
|
const [newWeight, setNewWeight] = useState<number | undefined>();
|
||||||
|
|
||||||
const setWeight = () => {
|
const setWeight = () => {
|
||||||
if (!!newWeight) {
|
if (!!newWeight) {
|
||||||
const hosts: Host[] = getValues("hosts");
|
const hosts: Host[] = getValues("hosts");
|
||||||
const updatedHosts = hosts
|
const isRRWeightsEnabled = getValues("isRRWeightsEnabled");
|
||||||
.filter((host) => !host.isFixed)
|
const hostGroups = getValues("hostGroups");
|
||||||
.map((host) => {
|
const rrHosts = hosts.filter((host) => !host.isFixed);
|
||||||
return {
|
|
||||||
...option,
|
|
||||||
value: host.userId.toString(),
|
|
||||||
priority: host.priority,
|
|
||||||
weight: host.userId === parseInt(option.value, 10) ? newWeight : host.weight,
|
|
||||||
isFixed: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedHosts = updatedHosts.sort((a, b) => sortHosts(a, b, true));
|
const groupedHosts = groupHostsByGroupId({ hosts: rrHosts, hostGroups });
|
||||||
onChange(sortedHosts);
|
|
||||||
|
const updateHostWeight = (host: Host) => {
|
||||||
|
if (host.userId === parseInt(option.value, 10)) {
|
||||||
|
return { ...host, weight: newWeight };
|
||||||
|
}
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort hosts within the group
|
||||||
|
let sortedHostGroup: (Host & {
|
||||||
|
avatar: string;
|
||||||
|
label: string;
|
||||||
|
})[] = [];
|
||||||
|
|
||||||
|
const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID];
|
||||||
|
|
||||||
|
if (hostGroupToSort) {
|
||||||
|
sortedHostGroup = hostGroupToSort
|
||||||
|
.map((host) => {
|
||||||
|
const userOption = options.find((opt) => opt.value === host.userId.toString());
|
||||||
|
const updatedHost = updateHostWeight(host);
|
||||||
|
return {
|
||||||
|
...updatedHost,
|
||||||
|
avatar: userOption?.avatar ?? "",
|
||||||
|
label: userOption?.label ?? host.userId.toString(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => sortHosts(a, b, isRRWeightsEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedOptions = sortedHostGroup.map((host) => ({
|
||||||
|
avatar: host.avatar,
|
||||||
|
label: host.label,
|
||||||
|
value: host.userId.toString(),
|
||||||
|
priority: host.priority,
|
||||||
|
weight: host.weight,
|
||||||
|
isFixed: host.isFixed,
|
||||||
|
groupId: host.groupId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Preserve hosts from other groups
|
||||||
|
const otherGroupsHosts = getHostsFromOtherGroups(rrHosts, option.groupId);
|
||||||
|
|
||||||
|
const otherGroupsOptions = otherGroupsHosts.map((host) => {
|
||||||
|
const userOption = options.find((opt) => opt.value === host.userId.toString());
|
||||||
|
return {
|
||||||
|
avatar: userOption?.avatar ?? "",
|
||||||
|
label: userOption?.label ?? host.userId.toString(),
|
||||||
|
value: host.userId.toString(),
|
||||||
|
priority: host.priority,
|
||||||
|
weight: host.weight,
|
||||||
|
isFixed: host.isFixed,
|
||||||
|
groupId: host.groupId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const newFullValue = [...otherGroupsOptions, ...updatedOptions];
|
||||||
|
onChange(newFullValue);
|
||||||
}
|
}
|
||||||
setIsOpenDialog(false);
|
setIsOpenDialog(false);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user