Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d61da9b0 | ||
|
|
c0017114ef | ||
|
|
4b99072bcc | ||
|
|
821dce6a04 | ||
|
|
3860717ae3 | ||
|
|
f4303d8d60 | ||
|
|
851aa30829 | ||
|
|
2664eb459b | ||
|
|
ce997447a4 | ||
|
|
5fa76f943b | ||
|
|
4226231b8a | ||
|
|
69695d3022 | ||
|
|
c13fce4483 | ||
|
|
f32ea8b4c5 | ||
|
|
36cf6e3703 | ||
|
|
455973b394 | ||
|
|
0b73491367 | ||
|
|
573756eaa2 | ||
|
|
5c686d90f9 | ||
|
|
9ad8aa981a | ||
|
|
bb9581d0d9 | ||
|
|
75d611c2e8 |
+1
-1
@@ -94,7 +94,7 @@ CRON_ENABLE_APP_SYNC=false
|
||||
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# must be 32 bytes for AES256 encryption algorithm
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
# You can use: `openssl rand -base64 24` to generate a 32-character key
|
||||
CALENDSO_ENCRYPTION_KEY=
|
||||
|
||||
# Intercom Config
|
||||
|
||||
@@ -13,7 +13,8 @@ Development
|
||||
- Install dependencies: `yarn`
|
||||
- Set up environment:
|
||||
- Copy `.env.example` to `.env`
|
||||
- Generate keys with `openssl rand -base64 32` for both `NEXTAUTH_SECRET` and `CALENDSO_ENCRYPTION_KEY`
|
||||
- Generate NEXTAUTH_SECRET with `openssl rand -base64 32`
|
||||
- Generate CALENDSO_ENCRYPTION_KEY with `openssl rand -base64 24` (must be 32 characters for AES256)
|
||||
- Configure Postgres database URL in `.env`
|
||||
- Set DATABASE_DIRECT_URL to the same value as DATABASE_URL
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@ ENV NODE_ENV="production"
|
||||
ENV NODE_OPTIONS="--max-old-space-size=8192"
|
||||
ENV DATABASE_DIRECT_URL=${DATABASE_DIRECT_URL}
|
||||
ENV DATABASE_URL=${DATABASE_URL}
|
||||
ENV USE_POOL="true"
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn install
|
||||
RUN yarn workspace @calcom/api-v2 run generate-schemas
|
||||
RUN yarn workspace @calcom/platform-libraries run build
|
||||
RUN yarn workspace @calcom/api-v2 run build:docker
|
||||
RUN yarn workspace @calcom/api-v2 run build
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "yarn dev:build && nest build",
|
||||
"build": "nest build",
|
||||
"build:docker": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build && yarn workspace @calcom/trpc build:server",
|
||||
"format": "biome format --write src test",
|
||||
"start": "nest start",
|
||||
"dev:build:watch": "concurrently --names \"libraries,lru-fix,constants,enums,utils,types\" \"yarn _dev:build:watch:libraries\" \"yarn _dev:build:watch:libraries:lru-fix\" \"yarn _dev:build:watch:constants\" \"yarn _dev:build:watch:enums\" \"yarn _dev:build:watch:utils\" \"yarn _dev:build:watch:types\"",
|
||||
@@ -47,7 +48,7 @@
|
||||
"@microsoft/microsoft-graph-types-beta": "0.42.0-preview",
|
||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||
"@nestjs/axios": "4.0.0",
|
||||
"@nestjs/bull": "10.1.1",
|
||||
"@nestjs/bull": "11.0.4",
|
||||
"@nestjs/common": "10.4.20",
|
||||
"@nestjs/config": "3.2.0",
|
||||
"@nestjs/core": "10.4.20",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
X_CAL_SECRET_KEY,
|
||||
} from "@calcom/platform-constants";
|
||||
import type { ValidationError } from "@nestjs/common";
|
||||
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { BadRequestException, Logger, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Request } from "express";
|
||||
@@ -21,62 +21,68 @@ import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||
import { CalendarServiceExceptionFilter } from "./filters/calendar-service-exception.filter";
|
||||
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
|
||||
|
||||
const logger: Logger = new Logger("Bootstrap");
|
||||
|
||||
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
|
||||
app.enableShutdownHooks();
|
||||
app.enableVersioning({
|
||||
type: VersioningType.CUSTOM,
|
||||
extractor: (request: unknown) => {
|
||||
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
|
||||
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
|
||||
return headerVersion;
|
||||
}
|
||||
return VERSION_2024_04_15;
|
||||
},
|
||||
defaultVersion: VERSION_2024_04_15,
|
||||
});
|
||||
app.use(helmet());
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
X_CAL_PLATFORM_EMBED,
|
||||
CAL_API_VERSION_HEADER,
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Origin",
|
||||
],
|
||||
maxAge: 86_400,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
validationError: {
|
||||
target: true,
|
||||
value: true,
|
||||
try {
|
||||
if (!process.env.VERCEL) {
|
||||
app.enableShutdownHooks();
|
||||
}
|
||||
app.enableVersioning({
|
||||
type: VersioningType.CUSTOM,
|
||||
extractor: (request: unknown) => {
|
||||
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
|
||||
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
|
||||
return headerVersion;
|
||||
}
|
||||
return VERSION_2024_04_15;
|
||||
},
|
||||
exceptionFactory(errors: ValidationError[]): BadRequestException {
|
||||
return new BadRequestException({ errors });
|
||||
},
|
||||
})
|
||||
);
|
||||
defaultVersion: VERSION_2024_04_15,
|
||||
});
|
||||
app.use(helmet());
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
X_CAL_CLIENT_ID,
|
||||
X_CAL_SECRET_KEY,
|
||||
X_CAL_PLATFORM_EMBED,
|
||||
CAL_API_VERSION_HEADER,
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Origin",
|
||||
],
|
||||
maxAge: 86_400,
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
validationError: {
|
||||
target: true,
|
||||
value: true,
|
||||
},
|
||||
exceptionFactory(errors: ValidationError[]): BadRequestException {
|
||||
return new BadRequestException({ errors });
|
||||
},
|
||||
})
|
||||
);
|
||||
// Exception filters, new filters go at the bottom, keep the order
|
||||
app.useGlobalFilters(new PrismaExceptionFilter());
|
||||
app.useGlobalFilters(new ZodExceptionFilter());
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalFilters(new TRPCExceptionFilter());
|
||||
app.useGlobalFilters(new CalendarServiceExceptionFilter());
|
||||
app.use(cookieParser());
|
||||
|
||||
// Exception filters, new filters go at the bottom, keep the order
|
||||
app.useGlobalFilters(new PrismaExceptionFilter());
|
||||
app.useGlobalFilters(new ZodExceptionFilter());
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalFilters(new TRPCExceptionFilter());
|
||||
app.useGlobalFilters(new CalendarServiceExceptionFilter());
|
||||
if (process?.env?.API_GLOBAL_PREFIX) {
|
||||
app.setGlobalPrefix(process?.env?.API_GLOBAL_PREFIX);
|
||||
}
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
if (process?.env?.API_GLOBAL_PREFIX) {
|
||||
app.setGlobalPrefix(process?.env?.API_GLOBAL_PREFIX);
|
||||
return app;
|
||||
} catch (error) {
|
||||
logger.error("Error starting NestJS app:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
@@ -349,6 +349,7 @@ export class BookingsController_2024_04_15 {
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
noEmail: bookingRequest.body.noEmail,
|
||||
},
|
||||
creationSource: "API_V2",
|
||||
});
|
||||
|
||||
createdBookings.forEach(async (booking) => {
|
||||
|
||||
@@ -37,7 +37,6 @@ import { BadRequestException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking";
|
||||
|
||||
import {
|
||||
@@ -116,7 +115,7 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly recurringBookingService: RecurringBookingService,
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||
private readonly eventTypeAccessService: EventTypeAccessService
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
|
||||
let bookingTeamEventType = false;
|
||||
@@ -322,8 +321,7 @@ export class BookingsService_2024_08_13 {
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid option '${submittedValue}' for booking field '${
|
||||
eventTypeBookingField.name
|
||||
`Invalid option '${submittedValue}' for booking field '${eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
@@ -337,8 +335,7 @@ export class BookingsService_2024_08_13 {
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`One or more invalid options for booking field '${
|
||||
eventTypeBookingField.name
|
||||
`One or more invalid options for booking field '${eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
@@ -352,8 +349,7 @@ export class BookingsService_2024_08_13 {
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`One or more invalid options for booking field '${
|
||||
eventTypeBookingField.name
|
||||
`One or more invalid options for booking field '${eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
@@ -368,8 +364,7 @@ export class BookingsService_2024_08_13 {
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid option '${submittedValue}' for booking field '${
|
||||
eventTypeBookingField.name
|
||||
`Invalid option '${submittedValue}' for booking field '${eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
@@ -465,6 +460,7 @@ export class BookingsService_2024_08_13 {
|
||||
noEmail: bookingRequest.noEmail,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
},
|
||||
creationSource: "API_V2",
|
||||
});
|
||||
const ids = bookings.map((booking) => booking.id || 0);
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
@@ -489,6 +485,7 @@ export class BookingsService_2024_08_13 {
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
},
|
||||
creationSource: "API_V2",
|
||||
});
|
||||
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })),
|
||||
|
||||
@@ -65,6 +65,7 @@ export class EventTypesRepository_2024_06_14 {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
isPlatformManaged: true,
|
||||
avatarUrl: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
|
||||
+52
@@ -82,6 +82,58 @@ describe("OutputEventTypesService_2024_06_14", () => {
|
||||
expect(result).toBe("https://acme.cal.com/owner1/30min");
|
||||
});
|
||||
|
||||
it("should use cal.com for non-managed users in platform orgs", () => {
|
||||
const user = {
|
||||
id: 1,
|
||||
name: "Dhairyashil Shinde",
|
||||
username: "dhairyashil10101010-gmail-com",
|
||||
avatarUrl: null,
|
||||
brandColor: null,
|
||||
darkBrandColor: null,
|
||||
weekStart: "Monday",
|
||||
metadata: {},
|
||||
organizationId: 1,
|
||||
organization: { slug: "gmail-platform-9041df0e" },
|
||||
movedToProfile: null,
|
||||
profiles: [
|
||||
{
|
||||
id: 100,
|
||||
username: "dhairyashil",
|
||||
organizationId: 1,
|
||||
organization: { id: 1, slug: "gmail-platform-9041df0e", isPlatform: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
const slug = "secret";
|
||||
|
||||
const result = service.buildBookingUrl(user, slug);
|
||||
|
||||
expect(result).toBe("https://cal.com/dhairyashil/secret");
|
||||
});
|
||||
|
||||
it("should return empty string for managed users", () => {
|
||||
const user = {
|
||||
id: 1,
|
||||
name: "Managed User",
|
||||
username: "managed-user-abc123",
|
||||
isPlatformManaged: true,
|
||||
avatarUrl: null,
|
||||
brandColor: null,
|
||||
darkBrandColor: null,
|
||||
weekStart: "Monday",
|
||||
metadata: {},
|
||||
organizationId: 1,
|
||||
organization: { slug: "platform-org" },
|
||||
movedToProfile: null,
|
||||
profiles: [],
|
||||
};
|
||||
const slug = "30min";
|
||||
|
||||
const result = service.buildBookingUrl(user, slug);
|
||||
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should fall back to user username when profile has no username", () => {
|
||||
const user = {
|
||||
id: 1,
|
||||
|
||||
+10
-2
@@ -47,13 +47,14 @@ type EventTypeUser = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
isPlatformManaged?: boolean;
|
||||
avatarUrl: string | null;
|
||||
brandColor: string | null;
|
||||
darkBrandColor: string | null;
|
||||
weekStart: string;
|
||||
metadata: Prisma.JsonValue;
|
||||
organizationId: number | null;
|
||||
organization?: { slug: string | null } | null;
|
||||
organization?: { slug: string | null; isPlatform?: boolean } | null;
|
||||
movedToProfile?: ProfileMinimal | null;
|
||||
profiles?: ProfileMinimal[];
|
||||
};
|
||||
@@ -441,6 +442,11 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Managed users don't have public booking pages
|
||||
if (user.isPlatformManaged) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
const username = profile?.username ?? user.username;
|
||||
|
||||
@@ -448,7 +454,9 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
return "";
|
||||
}
|
||||
|
||||
const orgSlug = profile?.organization?.slug ?? null;
|
||||
const org = profile?.organization;
|
||||
// Don't use org subdomain for platform organizations - they don't have public-facing subdomains
|
||||
const orgSlug = org && !org.isPlatform ? org.slug : null;
|
||||
const webAppUrl = this.configService.get<string>("app.baseUrl", "https://app.cal.com");
|
||||
const baseUrl = orgSlug ? getBookerBaseUrlSync(orgSlug) : webAppUrl;
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
|
||||
|
||||
+23
-11
@@ -4,18 +4,34 @@ import { Injectable } from "@nestjs/common";
|
||||
import type { WeekDay } from "@calcom/platform-types";
|
||||
import type { Availability, Schedule } from "@calcom/prisma/client";
|
||||
|
||||
type DatabaseSchedule = Schedule & { availability: Availability[] };
|
||||
|
||||
@Injectable()
|
||||
export class OutputSchedulesService_2024_06_11 {
|
||||
constructor(private readonly usersRepository: UsersRepository) {}
|
||||
|
||||
async getResponseSchedule(databaseSchedule: Schedule & { availability: Availability[] }) {
|
||||
if (!databaseSchedule.timeZone) {
|
||||
databaseSchedule.timeZone = "Europe/London";
|
||||
}
|
||||
async getResponseSchedules(databaseSchedules: DatabaseSchedule[]) {
|
||||
if (databaseSchedules.length === 0) return [];
|
||||
const userIds = [...new Set(databaseSchedules.map((schedule) => schedule.userId))];
|
||||
const defaultScheduleIds = await this.usersRepository.getUsersScheduleDefaultIds(userIds);
|
||||
|
||||
return databaseSchedules.map((schedule) =>
|
||||
this.transformScheduleToOutput(schedule, defaultScheduleIds.get(schedule.userId) ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
async getResponseSchedule(databaseSchedule: DatabaseSchedule) {
|
||||
const ownerDefaultScheduleId = await this.usersRepository.getUserScheduleDefaultId(
|
||||
databaseSchedule.userId
|
||||
);
|
||||
return this.transformScheduleToOutput(databaseSchedule, ownerDefaultScheduleId);
|
||||
}
|
||||
|
||||
private transformScheduleToOutput(
|
||||
databaseSchedule: DatabaseSchedule,
|
||||
ownerDefaultScheduleId: number | null
|
||||
) {
|
||||
const timeZone = databaseSchedule.timeZone || "Europe/London";
|
||||
|
||||
const createdScheduleAvailabilities = databaseSchedule.availability.filter(
|
||||
(availability) => !!availability.days.length
|
||||
@@ -26,7 +42,7 @@ export class OutputSchedulesService_2024_06_11 {
|
||||
id: databaseSchedule.id,
|
||||
ownerId: databaseSchedule.userId,
|
||||
name: databaseSchedule.name,
|
||||
timeZone: databaseSchedule.timeZone,
|
||||
timeZone,
|
||||
availability: createdScheduleAvailabilities.map((availability) => ({
|
||||
days: availability.days.map(transformNumberToDay),
|
||||
startTime: this.padHoursMinutesWithZeros(
|
||||
@@ -54,13 +70,9 @@ export class OutputSchedulesService_2024_06_11 {
|
||||
};
|
||||
}
|
||||
|
||||
padHoursMinutesWithZeros(hhMM: string) {
|
||||
private padHoursMinutesWithZeros(hhMM: string) {
|
||||
const [hours, minutes] = hhMM.split(":");
|
||||
|
||||
const formattedHours = hours.padStart(2, "0");
|
||||
const formattedMinutes = minutes.padStart(2, "0");
|
||||
|
||||
return `${formattedHours}:${formattedMinutes}`;
|
||||
return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { Scope } from "@nestjs/common";
|
||||
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
|
||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
@Module({
|
||||
imports: [RegularBookingModule],
|
||||
providers: [RecurringBookingService],
|
||||
providers: [
|
||||
RecurringBookingService,
|
||||
BookingEventHandlerService,
|
||||
/** Required by BookingEventHandlerService - Starts **/
|
||||
HashedLinkService,
|
||||
{
|
||||
provide: Logger,
|
||||
useFactory: () => {
|
||||
return new Logger();
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
BookingAuditProducerService,
|
||||
TaskerService,
|
||||
/** Required by BookingEventHandlerService - Ends **/
|
||||
],
|
||||
exports: [RecurringBookingService],
|
||||
})
|
||||
export class RecurringBookingModule {}
|
||||
export class RecurringBookingModule { }
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class RecurringBookingService extends BaseRecurringBookingService {
|
||||
constructor(regularBookingService: RegularBookingService) {
|
||||
constructor(
|
||||
regularBookingService: RegularBookingService,
|
||||
bookingEventHandler: BookingEventHandlerService
|
||||
) {
|
||||
super({
|
||||
regularBookingService,
|
||||
bookingEventHandler,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+76
-11
@@ -1,26 +1,64 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { IncomingMessage, Server, ServerResponse } from "node:http";
|
||||
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { WinstonModule } from "nest-winston";
|
||||
import qs from "qs";
|
||||
import type { AppConfig } from "@/config/type";
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { loggerConfig } from "./lib/logger";
|
||||
|
||||
run().catch((error: Error) => {
|
||||
console.error("Failed to start Cal Platform API", { error: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
const logger: Logger = new Logger("App");
|
||||
|
||||
/**
|
||||
* Singleton Class to manage the NestJS App instance.
|
||||
* Ensures we only initialize the app once per container lifecycle.
|
||||
*/
|
||||
class NestServer {
|
||||
private static server: Express; // The underlying Express instance
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Returns the cached server instance.
|
||||
* If it doesn't exist, it creates, bootstraps, and initializes it.
|
||||
*/
|
||||
public static async getInstance(): Promise<Express> {
|
||||
if (!NestServer.server) {
|
||||
const app = await createNestApp();
|
||||
|
||||
// Execute bootstrap (Pipes, Interceptors, CORS, etc.)
|
||||
bootstrap(app);
|
||||
|
||||
// Initialize the app (connects to DB, resolves modules)
|
||||
await app.init();
|
||||
|
||||
// extract the Express instance to pass to Vercel
|
||||
NestServer.server = app.getHttpAdapter().getInstance();
|
||||
}
|
||||
return NestServer.server;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LOCAL DEVELOPMENT STARTUP
|
||||
// -----------------------------------------------------------------------------
|
||||
if (!process.env.VERCEL) {
|
||||
run().catch((error: Error) => {
|
||||
logger.error("Failed to start Cal Platform API", { error: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const app = await createNestApp();
|
||||
const logger = new Logger("App");
|
||||
|
||||
try {
|
||||
bootstrap(app);
|
||||
const config = app.get(ConfigService<AppConfig, true>);
|
||||
@@ -32,23 +70,50 @@ async function run(): Promise<void> {
|
||||
}
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Application started on port: ${port}`);
|
||||
logger.log(`Application started locally on port: ${port}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.error("Application crashed", {
|
||||
error,
|
||||
});
|
||||
logger.error("Application crashed during local startup", { error });
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// VERCEL SERVERLESS HANDLER
|
||||
// -----------------------------------------------------------------------------
|
||||
export default async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const server = await NestServer.getInstance();
|
||||
|
||||
// Vercel/AWS specific: Re-parse query strings to support array formats
|
||||
// (e.g., ?ids[]=1&ids[]=2) which Vercel's native parser might simplify.
|
||||
if (req.url) {
|
||||
const [_path, queryString] = req.url.split("?");
|
||||
if (queryString) {
|
||||
req.query = qs.parse(queryString, { arrayLimit: 1000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate request to the cached Express instance
|
||||
return server(req, res);
|
||||
} catch (error) {
|
||||
logger.error("Critical: Failed to initialize NestJS Serverless instance", error);
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error: Initialization Failed");
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// APP FACTORY
|
||||
// -----------------------------------------------------------------------------
|
||||
export async function createNestApp(): Promise<
|
||||
NestExpressApplication<Server<typeof IncomingMessage, typeof ServerResponse>>
|
||||
> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(loggerConfig()),
|
||||
// Preserved as requested:
|
||||
bodyParser: false,
|
||||
});
|
||||
|
||||
// Custom query parser configuration for the underlying Express app
|
||||
app.set("query parser", (str: string) => qs.parse(str, { arrayLimit: 1000 }));
|
||||
|
||||
return app;
|
||||
|
||||
+1
-9
@@ -3,8 +3,6 @@ import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsSchedulesService {
|
||||
constructor(
|
||||
@@ -19,12 +17,6 @@ export class OrganizationsSchedulesService {
|
||||
|
||||
const schedules = await this.schedulesRepository.getSchedulesByUserIds(usersIds, skip, take);
|
||||
|
||||
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
|
||||
}
|
||||
|
||||
return responseSchedules;
|
||||
return this.outputSchedulesService.getResponseSchedules(schedules);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,9 @@ export class PrismaReadService implements OnModuleInit, OnModuleDestroy {
|
||||
const adapter = new PrismaPg(this.pool);
|
||||
this.prisma = new PrismaClient({ adapter });
|
||||
} else {
|
||||
const adapter = new PrismaPg({ connectionString: dbUrl });
|
||||
this.prisma = new PrismaClient({
|
||||
datasourceUrl: dbUrl,
|
||||
adapter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,9 @@ export class PrismaWriteService implements OnModuleInit, OnModuleDestroy {
|
||||
const adapter = new PrismaPg(this.pool);
|
||||
this.prisma = new PrismaClient({ adapter });
|
||||
} else {
|
||||
const adapter = new PrismaPg({ connectionString: dbUrl });
|
||||
this.prisma = new PrismaClient({
|
||||
datasourceUrl: dbUrl,
|
||||
adapter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsSchedulesService {
|
||||
constructor(
|
||||
@@ -17,12 +15,6 @@ export class TeamsSchedulesService {
|
||||
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
|
||||
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
|
||||
|
||||
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
|
||||
}
|
||||
|
||||
return responseSchedules;
|
||||
return this.outputSchedulesService.getResponseSchedules(schedules);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ export class UsersRepository {
|
||||
oAuthClientId: string,
|
||||
isPlatformManaged: boolean
|
||||
) {
|
||||
this.formatInput(user);
|
||||
|
||||
return this.dbWrite.prisma.user.create({
|
||||
data: {
|
||||
...user,
|
||||
@@ -227,8 +225,6 @@ export class UsersRepository {
|
||||
}
|
||||
|
||||
async update(userId: number, updateData: UpdateManagedUserInput) {
|
||||
this.formatInput(updateData);
|
||||
|
||||
return this.dbWrite.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
@@ -257,12 +253,6 @@ export class UsersRepository {
|
||||
});
|
||||
}
|
||||
|
||||
formatInput(userInput: CreateManagedUserInput | UpdateManagedUserInput) {
|
||||
if (userInput.weekStart) {
|
||||
userInput.weekStart = userInput.weekStart;
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultSchedule(userId: number, scheduleId: number) {
|
||||
return this.dbWrite.prisma.user.update({
|
||||
where: { id: userId },
|
||||
@@ -280,6 +270,14 @@ export class UsersRepository {
|
||||
return user?.defaultScheduleId;
|
||||
}
|
||||
|
||||
async getUsersScheduleDefaultIds(userIds: number[]): Promise<Map<number, number | null>> {
|
||||
const users = await this.dbRead.prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, defaultScheduleId: true },
|
||||
});
|
||||
return new Map(users.map((user) => [user.id, user.defaultScheduleId]));
|
||||
}
|
||||
|
||||
async getOrganizationUsers(organizationId: number) {
|
||||
const profiles = await this.dbRead.prisma.profile.findMany({
|
||||
where: {
|
||||
|
||||
+33
@@ -11,6 +11,11 @@ import React, { useEffect, useState, useMemo } from "react";
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import {
|
||||
HAS_ORG_OPT_IN_FEATURES,
|
||||
HAS_TEAM_OPT_IN_FEATURES,
|
||||
HAS_USER_OPT_IN_FEATURES,
|
||||
} from "@calcom/features/feature-opt-in/config";
|
||||
import type { TeamFeatures } from "@calcom/features/flags/config";
|
||||
import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam";
|
||||
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
@@ -73,6 +78,15 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
href: "/settings/my-account/push-notifications",
|
||||
trackingMetadata: { section: "my_account", page: "push_notifications" },
|
||||
},
|
||||
...(HAS_USER_OPT_IN_FEATURES
|
||||
? [
|
||||
{
|
||||
name: "features",
|
||||
href: "/settings/my-account/features",
|
||||
trackingMetadata: { section: "my_account", page: "features" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// TODO
|
||||
// { name: "referrals", href: "/settings/my-account/referrals" },
|
||||
],
|
||||
@@ -190,6 +204,15 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
isExternalLink: true,
|
||||
trackingMetadata: { section: "organization", page: "admin_api" },
|
||||
},
|
||||
...(HAS_ORG_OPT_IN_FEATURES
|
||||
? [
|
||||
{
|
||||
name: "features",
|
||||
href: "/settings/organizations/features",
|
||||
trackingMetadata: { section: "organization", page: "features" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -637,6 +660,16 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
{HAS_TEAM_OPT_IN_FEATURES && (
|
||||
<VerticalTabItem
|
||||
name={t("features")}
|
||||
href={`/settings/teams/${team.id}/features`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
trackingMetadata={{ section: "team", page: "features", teamId: team.id }}
|
||||
className="px-2! me-5 h-7 w-auto"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
{/* Hide if there is a parent ID */}
|
||||
{!team.parentId ? (
|
||||
<>
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import FeaturesView from "~/settings/my-account/features-view";
|
||||
|
||||
const generateMetadata = async (): Promise<Metadata> =>
|
||||
await _generateMetadata(
|
||||
(t) => t("features"),
|
||||
(t) => t("feature_opt_in_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/my-account/features"
|
||||
);
|
||||
|
||||
const Page = (): ReactElement => {
|
||||
return <FeaturesView />;
|
||||
};
|
||||
|
||||
export { generateMetadata };
|
||||
export default Page;
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import OrganizationFeaturesView from "~/ee/organizations/features-view";
|
||||
|
||||
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async (): Promise<Metadata> =>
|
||||
await _generateMetadata(
|
||||
(t) => t("features"),
|
||||
(t) => t("feature_opt_in_org_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/features"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
const { canRead, canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.FeatureOptIn,
|
||||
userRole: session.user.org?.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!canRead) {
|
||||
return redirect("/settings/organizations/profile");
|
||||
}
|
||||
|
||||
return <OrganizationFeaturesView canEdit={canEdit} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import TeamFeaturesView from "~/settings/teams/[id]/features-view";
|
||||
|
||||
type PageParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const generateMetadata = async ({ params }: PageParams): Promise<Metadata> =>
|
||||
await _generateMetadata(
|
||||
(t) => t("features"),
|
||||
(t) => t("feature_opt_in_team_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
`/settings/teams/${(await params).id}/features`
|
||||
);
|
||||
|
||||
const Page = async ({ params }: PageParams) => {
|
||||
const { id } = await params;
|
||||
const teamId = Number(id);
|
||||
|
||||
if (Number.isNaN(teamId)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
userId: session.user.id,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { canRead, canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId,
|
||||
resource: Resource.FeatureOptIn,
|
||||
userRole: membership.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!canRead) {
|
||||
return redirect(`/settings/teams/${teamId}/profile`);
|
||||
}
|
||||
|
||||
return <TeamFeaturesView teamId={teamId} canEdit={canEdit} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -120,6 +120,10 @@ export const AvailabilitySettingsWebWrapper = ({
|
||||
scheduleId && deleteMutation.mutate({ scheduleId });
|
||||
}}
|
||||
handleSubmit={async ({ dateOverrides, ...values }) => {
|
||||
if (!values.name.trim()) {
|
||||
showToast(t("schedule_name_cannot_be_empty"), "error");
|
||||
return;
|
||||
}
|
||||
scheduleId &&
|
||||
updateMutation.mutate({
|
||||
scheduleId,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks";
|
||||
|
||||
interface OrganizationFeaturesViewProps {
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
const OrganizationFeaturesView = ({ canEdit }: OrganizationFeaturesViewProps): ReactElement => {
|
||||
const { t } = useLocale();
|
||||
const featureOptIn = useOrganizationFeatureOptIn();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("features")}
|
||||
description={t("feature_opt_in_org_description")}
|
||||
borderInShellHeader={true}>
|
||||
<FeaturesSettings featureOptIn={featureOptIn} canEdit={canEdit} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationFeaturesView;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useUserFeatureOptIn } from "./useUserFeatureOptIn";
|
||||
export { useTeamFeatureOptIn } from "./useTeamFeatureOptIn";
|
||||
export { useOrganizationFeatureOptIn } from "./useOrganizationFeatureOptIn";
|
||||
export type { UseFeatureOptInResult, NormalizedFeature, ToggleLabels } from "@calcom/features/feature-opt-in/types";
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
|
||||
const { t } = useLocale();
|
||||
return useMemo(
|
||||
() => ({
|
||||
onSuccess: (): void => {
|
||||
onSuccessCallback();
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
},
|
||||
onError: (): void => {
|
||||
showToast(t("error_updating_settings"), "error");
|
||||
},
|
||||
}),
|
||||
[onSuccessCallback, t]
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFeatures(
|
||||
data: Array<{ featureId: string; globalEnabled: boolean; teamState: FeatureState }> | undefined
|
||||
): NormalizedFeature[] {
|
||||
return (data ?? []).map((feature) => ({
|
||||
slug: feature.featureId,
|
||||
globalEnabled: feature.globalEnabled,
|
||||
currentState: feature.teamState,
|
||||
}));
|
||||
}
|
||||
|
||||
function getOrgBlockedWarning(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOrgBlockedByHigherLevel(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing feature opt-in at the organization level.
|
||||
*/
|
||||
export function useOrganizationFeatureOptIn(): UseFeatureOptInResult {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForOrganization.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getOrganizationAutoOptIn.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForOrganization.invalidate(), [utils]);
|
||||
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
|
||||
utils.viewer.featureOptIn.getOrganizationAutoOptIn.invalidate();
|
||||
utils.viewer.featureOptIn.listForOrganization.invalidate();
|
||||
}, [utils]);
|
||||
|
||||
const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures);
|
||||
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
|
||||
|
||||
const setStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation(
|
||||
setAutoOptInMutationCallbacks
|
||||
);
|
||||
|
||||
const features = normalizeFeatures(featuresQuery.data);
|
||||
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
|
||||
|
||||
return {
|
||||
features,
|
||||
autoOptIn: autoOptInQuery.data?.autoOptIn ?? false,
|
||||
isLoading: featuresQuery.isLoading || autoOptInQuery.isLoading,
|
||||
setFeatureState,
|
||||
setAutoOptIn,
|
||||
isStateMutationPending: setStateMutation.isPending,
|
||||
isAutoOptInMutationPending: setAutoOptInMutation.isPending,
|
||||
toggleLabels: { enabled: t("allow"), disabled: t("block"), inherit: t("let_users_decide") },
|
||||
autoOptInDescription: t("auto_opt_in_experimental_description_org"),
|
||||
getBlockedWarning: getOrgBlockedWarning,
|
||||
isBlockedByHigherLevel: isOrgBlockedByHigherLevel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
type FeatureBlockingState = { orgState?: FeatureState };
|
||||
type TeamFeatureData = { featureId: string; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState };
|
||||
|
||||
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
|
||||
const { t } = useLocale();
|
||||
return useMemo(
|
||||
() => ({
|
||||
onSuccess: (): void => {
|
||||
onSuccessCallback();
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
},
|
||||
onError: (): void => {
|
||||
showToast(t("error_updating_settings"), "error");
|
||||
},
|
||||
}),
|
||||
[onSuccessCallback, t]
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTeamFeatures(data: TeamFeatureData[] | undefined): NormalizedFeature[] {
|
||||
return (data ?? []).map((feature) => ({
|
||||
slug: feature.featureId,
|
||||
globalEnabled: feature.globalEnabled,
|
||||
currentState: feature.teamState,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildBlockingStateMap(data: TeamFeatureData[] | undefined): Map<string, FeatureBlockingState> {
|
||||
const map = new Map<string, FeatureBlockingState>();
|
||||
for (const feature of data ?? []) {
|
||||
map.set(feature.featureId, { orgState: feature.orgState });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function createTeamBlockedWarningFn(
|
||||
blockingStateMap: Map<string, FeatureBlockingState>,
|
||||
t: TFunction
|
||||
): (feature: NormalizedFeature) => string | null {
|
||||
return (feature: NormalizedFeature): string | null => {
|
||||
const blockingState = blockingStateMap.get(feature.slug);
|
||||
if (blockingState?.orgState === "disabled") {
|
||||
return t("feature_blocked_by_org_warning");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
function createTeamIsBlockedByHigherLevelFn(
|
||||
blockingStateMap: Map<string, FeatureBlockingState>
|
||||
): (feature: NormalizedFeature) => boolean {
|
||||
return (feature: NormalizedFeature): boolean => {
|
||||
const blockingState = blockingStateMap.get(feature.slug);
|
||||
return blockingState?.orgState === "disabled";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing feature opt-in at the team level.
|
||||
*/
|
||||
export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery({ teamId }, { refetchOnWindowFocus: false });
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery({ teamId }, { refetchOnWindowFocus: false });
|
||||
|
||||
const invalidateFeatures = useCallback(
|
||||
() => utils.viewer.featureOptIn.listForTeam.invalidate({ teamId }),
|
||||
[utils, teamId]
|
||||
);
|
||||
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
|
||||
utils.viewer.featureOptIn.getTeamAutoOptIn.invalidate({ teamId });
|
||||
utils.viewer.featureOptIn.listForTeam.invalidate({ teamId });
|
||||
}, [utils, teamId]);
|
||||
|
||||
const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures);
|
||||
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
|
||||
|
||||
const setStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation(setStateMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
|
||||
|
||||
const featureBlockingState = useMemo(() => buildBlockingStateMap(featuresQuery.data), [featuresQuery.data]);
|
||||
const features = normalizeTeamFeatures(featuresQuery.data);
|
||||
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ teamId, slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ teamId, autoOptIn: checked });
|
||||
const getBlockedWarning = createTeamBlockedWarningFn(featureBlockingState, t);
|
||||
const isBlockedByHigherLevel = createTeamIsBlockedByHigherLevelFn(featureBlockingState);
|
||||
|
||||
return {
|
||||
features,
|
||||
autoOptIn: autoOptInQuery.data?.autoOptIn ?? false,
|
||||
isLoading: featuresQuery.isLoading || autoOptInQuery.isLoading,
|
||||
setFeatureState,
|
||||
setAutoOptIn,
|
||||
isStateMutationPending: setStateMutation.isPending,
|
||||
isAutoOptInMutationPending: setAutoOptInMutation.isPending,
|
||||
toggleLabels: { enabled: t("allow"), disabled: t("block"), inherit: t("let_users_decide") },
|
||||
autoOptInDescription: t("auto_opt_in_experimental_description_team"),
|
||||
getBlockedWarning,
|
||||
isBlockedByHigherLevel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { EffectiveStateReason } from "@calcom/features/feature-opt-in/lib/computeEffectiveState";
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
type UserFeatureData = {
|
||||
featureId: string;
|
||||
globalEnabled: boolean;
|
||||
userState: FeatureState | undefined;
|
||||
effectiveReason: EffectiveStateReason;
|
||||
};
|
||||
|
||||
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
|
||||
const { t } = useLocale();
|
||||
return useMemo(
|
||||
() => ({
|
||||
onSuccess: (): void => {
|
||||
onSuccessCallback();
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
},
|
||||
onError: (): void => {
|
||||
showToast(t("error_updating_settings"), "error");
|
||||
},
|
||||
}),
|
||||
[onSuccessCallback, t]
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeUserFeatures(data: UserFeatureData[] | undefined): NormalizedFeature[] {
|
||||
return (data ?? []).map((feature) => ({
|
||||
slug: feature.featureId,
|
||||
globalEnabled: feature.globalEnabled,
|
||||
currentState: feature.userState ?? "inherit",
|
||||
effectiveReason: feature.effectiveReason,
|
||||
}));
|
||||
}
|
||||
|
||||
function createUserBlockedWarningFn(t: TFunction): (feature: NormalizedFeature) => string | null {
|
||||
return (feature: NormalizedFeature): string | null => {
|
||||
if (!feature.effectiveReason) return null;
|
||||
switch (feature.effectiveReason) {
|
||||
case "feature_org_disabled":
|
||||
return t("feature_blocked_by_org_warning");
|
||||
case "feature_all_teams_disabled":
|
||||
case "feature_any_team_disabled":
|
||||
return t("feature_blocked_by_team_warning");
|
||||
case "feature_no_explicit_enablement":
|
||||
case "feature_user_only_not_allowed":
|
||||
return t("feature_no_explicit_enablement_warning");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isUserBlockedByHigherLevel(feature: NormalizedFeature): boolean {
|
||||
return (
|
||||
feature.effectiveReason === "feature_org_disabled" ||
|
||||
feature.effectiveReason === "feature_all_teams_disabled" ||
|
||||
feature.effectiveReason === "feature_any_team_disabled"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing feature opt-in at the user (personal) level.
|
||||
*/
|
||||
export function useUserFeatureOptIn(): UseFeatureOptInResult {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, { refetchOnWindowFocus: false });
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, { refetchOnWindowFocus: false });
|
||||
|
||||
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForUser.invalidate(), [utils]);
|
||||
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
|
||||
utils.viewer.featureOptIn.getUserAutoOptIn.invalidate();
|
||||
utils.viewer.featureOptIn.listForUser.invalidate();
|
||||
}, [utils]);
|
||||
|
||||
const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures);
|
||||
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
|
||||
|
||||
const setStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation(setStateMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
|
||||
|
||||
const features = useMemo(() => normalizeUserFeatures(featuresQuery.data), [featuresQuery.data]);
|
||||
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
|
||||
const getBlockedWarning = createUserBlockedWarningFn(t);
|
||||
|
||||
return {
|
||||
features,
|
||||
autoOptIn: autoOptInQuery.data?.autoOptIn ?? false,
|
||||
isLoading: featuresQuery.isLoading || autoOptInQuery.isLoading,
|
||||
setFeatureState,
|
||||
setAutoOptIn,
|
||||
isStateMutationPending: setStateMutation.isPending,
|
||||
isAutoOptInMutationPending: setAutoOptInMutation.isPending,
|
||||
toggleLabels: { enabled: t("feature_on"), disabled: t("feature_off"), inherit: t("use_default") },
|
||||
autoOptInDescription: t("auto_opt_in_experimental_description_personal"),
|
||||
getBlockedWarning,
|
||||
isBlockedByHigherLevel: isUserBlockedByHigherLevel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { useUserFeatureOptIn } from "~/feature-opt-in/hooks";
|
||||
|
||||
const FeaturesView = (): ReactElement => {
|
||||
const { t } = useLocale();
|
||||
const featureOptIn = useUserFeatureOptIn();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("features")}
|
||||
description={t("feature_opt_in_description")}
|
||||
borderInShellHeader={true}>
|
||||
<FeaturesSettings featureOptIn={featureOptIn} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesView;
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks";
|
||||
|
||||
interface TeamFeaturesViewProps {
|
||||
teamId: number;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
const TeamFeaturesView = ({ teamId, canEdit }: TeamFeaturesViewProps): ReactElement => {
|
||||
const { t } = useLocale();
|
||||
const featureOptIn = useTeamFeatureOptIn(teamId);
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("features")}
|
||||
description={t("feature_opt_in_team_description")}
|
||||
borderInShellHeader={true}>
|
||||
<FeaturesSettings featureOptIn={featureOptIn} canEdit={canEdit} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamFeaturesView;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "6.0.11",
|
||||
"version": "6.0.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -58,24 +58,7 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr
|
||||
},
|
||||
});
|
||||
|
||||
// const booking = await createBookingThroughFactory();
|
||||
return booking;
|
||||
|
||||
// To be added in the follow-up PR
|
||||
// async function createBookingThroughFactory() {
|
||||
// console.log("Creating booking through factory");
|
||||
// const regularBookingService = getRegularBookingService();
|
||||
|
||||
// const booking = await regularBookingService.createBooking({
|
||||
// bookingData: req.body,
|
||||
// bookingMeta: {
|
||||
// userId: session?.user?.id || -1,
|
||||
// hostname: req.headers.host || "",
|
||||
// forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
|
||||
// },
|
||||
// });
|
||||
// return booking;
|
||||
// }
|
||||
}
|
||||
|
||||
export default defaultResponder(handler, "/api/book/event");
|
||||
export default defaultResponder(handler, "/api/book/event");
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getRecurringBookingService } from "@calcom/features/bookings/di/RecurringBookingService.container";
|
||||
import type { BookingResponse } from "@calcom/features/bookings/types";
|
||||
@@ -55,6 +54,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
|
||||
platformBookingLocation: req.platformBookingLocation,
|
||||
noEmail: req.noEmail,
|
||||
},
|
||||
creationSource: "WEBAPP",
|
||||
});
|
||||
|
||||
return createdBookings;
|
||||
@@ -62,4 +62,4 @@ async function handler(req: NextApiRequest & RequestMeta) {
|
||||
|
||||
export const handleRecurringEventBooking = handler;
|
||||
|
||||
export default defaultResponder(handler);
|
||||
export default defaultResponder(handler);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { featureOptInRouter } from "@calcom/trpc/server/routers/viewer/featureOptIn/_router";
|
||||
|
||||
export default createNextApiHandler(featureOptInRouter);
|
||||
@@ -130,6 +130,8 @@
|
||||
"please_try_again_later_or_book_another_slot": "You've just tried connecting now. Please try again later in {{remaining}} minutes or book another slot from the booking page.",
|
||||
"unavailable_timeslot_title": "Unavailable timeslot",
|
||||
"booking_time_out_of_bounds_error": "The event type cannot be booked at this time. Could you try another time slot?",
|
||||
"bookings_v3_title": "Enhanced bookings",
|
||||
"bookings_v3_description": "Switch between list and calendar views with details in a slide-over.",
|
||||
"request_body_end_time_internal_error": "Internal Error. Request body does not contain end time",
|
||||
"create_calendar_event_error": "Unable to create calendar event in organizer's calendar",
|
||||
"update_calendar_event_error": "Unable to update calendar event.",
|
||||
@@ -1106,6 +1108,7 @@
|
||||
"add_input": "Add an input",
|
||||
"disable_notes": "Hide notes in calendar",
|
||||
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email. <0>Learn more</0>",
|
||||
"notes_hidden_by_organizer": "Notes have been hidden by the organizer",
|
||||
"requires_confirmation_description": "The booking needs to be manually confirmed before it is pushed to your calendar and a confirmation is sent. <0>Learn more</0>",
|
||||
"requires_confirmation_will_block_slot_description": "Unconfirmed bookings still block calendar slots.",
|
||||
"recurring_event": "Recurring event",
|
||||
@@ -3321,7 +3324,7 @@
|
||||
"only_if_field_is_empty": "Only if field is empty",
|
||||
"booking_start_date": "Booking start date",
|
||||
"booking_created_date": "Booking created date",
|
||||
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
|
||||
"booking_reassigned_to_host": "Reassigned to {{host}}",
|
||||
"no_contact_owner": "No contact owner",
|
||||
"routing_forms_created": "Routing forms created",
|
||||
"routing_forms_total_responses": "Total Responses",
|
||||
@@ -3718,13 +3721,15 @@
|
||||
"pbac_action_delete": "Delete",
|
||||
"pbac_action_manage": "Manage",
|
||||
"pbac_action_invite": "Invite",
|
||||
"pbac_action_remove": "Remove",
|
||||
"pbac_action_remove": "Remove",
|
||||
"pbac_action_change_member_role": "Change member role",
|
||||
"pbac_action_list_members": "List members",
|
||||
"pbac_action_manage_billing": "Manage billing",
|
||||
"pbac_action_read_team_bookings": "View team bookings",
|
||||
"pbac_action_read_org_bookings": "View organization bookings",
|
||||
"pbac_action_read_recordings": "View recordings",
|
||||
"pbac_action_read_team_booking_audit_logs": "View team booking audit logs",
|
||||
"pbac_action_read_org_booking_audit_logs": "View organization booking audit logs",
|
||||
"pbac_action_impersonate": "Impersonate",
|
||||
"pbac_action_edit_users": "Edit users",
|
||||
"role_created_successfully": "Role created successfully",
|
||||
@@ -3795,6 +3800,8 @@
|
||||
"pbac_desc_view_team_bookings": "View team bookings",
|
||||
"pbac_desc_view_organization_bookings": "View organization bookings",
|
||||
"pbac_desc_view_booking_recordings": "View booking recordings",
|
||||
"pbac_desc_read_team_booking_audit_logs": "View audit logs for team bookings",
|
||||
"pbac_desc_read_org_booking_audit_logs": "View audit logs for organization bookings",
|
||||
"pbac_desc_update_bookings": "Update bookings",
|
||||
"pbac_desc_manage_bookings": "All actions on bookings",
|
||||
"pbac_desc_view_team_insights": "View team insights",
|
||||
@@ -4004,6 +4011,7 @@
|
||||
"choose_a_voice_for_your_agent": "Choose a voice for your agent",
|
||||
"trait": "Trait",
|
||||
"voice_id": "Voice ID",
|
||||
"schedule_name_cannot_be_empty": "Schedule name cannot be empty",
|
||||
"use_voice": "Use voice",
|
||||
"current_voice": "Current voice",
|
||||
"setup_incoming_agent": "Set up incoming agent",
|
||||
@@ -4263,7 +4271,8 @@
|
||||
"booking_history": "Booking history",
|
||||
"booking_history_description": "View the history of actions performed on this booking",
|
||||
"booking_audit_action": {
|
||||
"created": "Created",
|
||||
"created": "Booked with {{host}}",
|
||||
"created_with_seat": "Seat Booked with {{host}}",
|
||||
"cancelled": "Cancelled",
|
||||
"rescheduled": "Rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
|
||||
"rescheduled_from": "Rescheduled <0>{{oldDate}}</0> -> {{newDate}}",
|
||||
@@ -4278,9 +4287,11 @@
|
||||
"location_changed": "Location changed",
|
||||
"location_changed_from_to": "Location changed from {{fromLocation}} to {{toLocation}}",
|
||||
"attendee_no_show_updated": "Attendee no-show updated",
|
||||
"type": "Assignment type",
|
||||
"assignmentType_manual": "Manual assignment",
|
||||
"assignmentType_roundRobin": "Round robin assignment",
|
||||
"seat_booked": "Seat booked",
|
||||
"seat_rescheduled": "Seat rescheduled {{oldDate}} -> <0>{{newDate}}</0>",
|
||||
"assignment_type": "Assignment",
|
||||
"assignment_type_manual": "Manual",
|
||||
"assignment_type_round_robin": "Round robin",
|
||||
"actor_impersonated_by": "Impersonator",
|
||||
"source": "Source"
|
||||
},
|
||||
@@ -4299,6 +4310,23 @@
|
||||
"audit_logs_owner_not_in_organization": "The booking owner is not a member of your organization.",
|
||||
"audit_logs_permission_denied": "You do not have permission to view audit logs for this booking.",
|
||||
"audit_logs_permission_check_error": "An error occurred while checking permissions.",
|
||||
"feature_opt_in": "Feature Opt-In",
|
||||
"feature_opt_in_description": "Manage experimental features for your account",
|
||||
"feature_opt_in_team_description": "Manage experimental features for your team",
|
||||
"feature_opt_in_org_description": "Manage experimental features for your organization",
|
||||
"let_users_decide": "Let users decide",
|
||||
"use_default": "Use default",
|
||||
"feature_on": "On",
|
||||
"feature_off": "Off",
|
||||
"auto_opt_in_experimental": "Automatically opt-in for future experimental features",
|
||||
"auto_opt_in_experimental_description_personal": "Automatically opt into new experimental features, unless disabled by your team or organization",
|
||||
"auto_opt_in_experimental_description_team": "Automatically opt team members into new experimental features, unless configured by the organization or opted out by members",
|
||||
"auto_opt_in_experimental_description_org": "Automatically opt organization members into new experimental features, unless configured at team or user level",
|
||||
"feature_blocked_by_team_warning": "This feature is disabled by your team.",
|
||||
"feature_blocked_by_org_warning": "This feature is disabled by your organization.",
|
||||
"feature_no_explicit_enablement_warning": "Turn it on for yourself, or ask your team or organization admin to enable it for everyone.",
|
||||
"no_opt_in_features_available": "No opt-in features are currently available",
|
||||
"enabled_via_auto_opt_in": "Enabled via auto opt-in",
|
||||
"account_already_exists_please_login": "An account with this email already exists. Please log in to accept the invitation.",
|
||||
"unverified_email_oauth_error": "Please verify your email address before signing in with Google or SAML. Sign in with your password to resend the verification email.",
|
||||
"pbac_resource_feature_opt_in": "Feature Opt-In",
|
||||
|
||||
+4
-11
@@ -164,6 +164,7 @@
|
||||
{
|
||||
"includes": [
|
||||
"apps/api/v2/src/config/app.ts",
|
||||
"apps/api/v2/src/main.ts",
|
||||
"apps/api/v2/src/bootstrap.ts",
|
||||
"apps/api/v2/src/config/env.ts",
|
||||
"apps/api/v2/src/env.ts",
|
||||
@@ -325,9 +326,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"packages/platform/atoms/**/*.{ts,tsx,js,jsx,mts,mjs,cjs,cts}"
|
||||
],
|
||||
"includes": ["packages/platform/atoms/**/*.{ts,tsx,js,jsx,mts,mjs,cjs,cts}"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
@@ -336,17 +335,11 @@
|
||||
"options": {
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"@calcom/trpc",
|
||||
"@calcom/trpc/**"
|
||||
],
|
||||
"group": ["@calcom/trpc", "@calcom/trpc/**"],
|
||||
"message": "atoms package should not import from @calcom/trpc."
|
||||
},
|
||||
{
|
||||
"group": [
|
||||
"../../trpc",
|
||||
"../../trpc/**"
|
||||
],
|
||||
"group": ["../../trpc", "../../trpc/**"],
|
||||
"message": "atoms package should not import from trpc."
|
||||
}
|
||||
]
|
||||
|
||||
@@ -138,8 +138,24 @@ export default function More() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Sign Out Button */}
|
||||
{/* Delete Account Link */}
|
||||
<View className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Delete Account")
|
||||
}
|
||||
className="flex-row items-center justify-between bg-white px-5 py-4 active:bg-red-50"
|
||||
>
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<Ionicons name="trash-outline" size={20} color="#991B1B" />
|
||||
<Text className="ml-3 text-base font-medium text-[#991B1B]">Delete Account</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Sign Out Button */}
|
||||
<View className="mt-4 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<TouchableOpacity
|
||||
onPress={handleSignOut}
|
||||
className="flex-row items-center justify-center bg-white px-5 py-4 active:bg-red-50"
|
||||
|
||||
@@ -112,8 +112,24 @@ export default function More() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Sign Out Button */}
|
||||
{/* Delete Account Link */}
|
||||
<View className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Delete Account")
|
||||
}
|
||||
className="flex-row items-center justify-between bg-white px-5 py-4 active:bg-red-50"
|
||||
>
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<Ionicons name="trash-outline" size={20} color="#991B1B" />
|
||||
<Text className="ml-3 text-base font-medium text-[#991B1B]">Delete Account</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Sign Out Button */}
|
||||
<View className="mt-4 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<TouchableOpacity
|
||||
onPress={handleSignOut}
|
||||
className="flex-row items-center justify-center bg-white px-5 py-4 active:bg-red-50"
|
||||
|
||||
@@ -242,6 +242,15 @@ function getBrowserDisplayName(): string {
|
||||
* These provide unified access to browser extension APIs across Chrome, Firefox, Safari, and Edge
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extended browser API interface that includes browserAction (Manifest V2)
|
||||
* Safari uses browserAction instead of action
|
||||
*/
|
||||
interface BrowserAPIWithLegacyAction {
|
||||
action?: typeof chrome.action;
|
||||
browserAction?: typeof chrome.action;
|
||||
}
|
||||
|
||||
// Get the appropriate browser API namespace
|
||||
function getBrowserAPI(): typeof chrome {
|
||||
if (typeof browser !== "undefined" && browser?.runtime) {
|
||||
@@ -278,8 +287,8 @@ function getTabsAPI(): typeof chrome.tabs | null {
|
||||
function getActionAPI(): typeof chrome.action | null {
|
||||
const api = getBrowserAPI();
|
||||
// Safari uses browserAction (Manifest V2), Chrome uses action (Manifest V3)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Safari's browserAction API is not in Chrome types
|
||||
return api?.action || (api as any)?.browserAction || null;
|
||||
const apiWithLegacyAction = api as unknown as BrowserAPIWithLegacyAction;
|
||||
return api?.action || apiWithLegacyAction?.browserAction || null;
|
||||
}
|
||||
|
||||
// Check if the URL is a restricted page where content scripts can't run
|
||||
@@ -363,16 +372,24 @@ export default defineBackground(() => {
|
||||
const authUrl = message.authUrl as string;
|
||||
const state = new URL(authUrl).searchParams.get("state");
|
||||
|
||||
if (state && storageAPI?.local) {
|
||||
storageAPI.local.set({ oauth_state: state }, () => {
|
||||
const runtime = getRuntimeAPI();
|
||||
if (runtime?.lastError) {
|
||||
devLog.warn("Failed to store OAuth state:", runtime.lastError.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Store state before starting OAuth to prevent race conditions
|
||||
const storeState = async () => {
|
||||
if (!state) {
|
||||
throw new Error("No state parameter in auth URL");
|
||||
}
|
||||
if (!storageAPI?.local) {
|
||||
throw new Error("Storage API not available");
|
||||
}
|
||||
try {
|
||||
await storageAPI.local.set({ oauth_state: state });
|
||||
} catch (error) {
|
||||
devLog.error("Failed to store OAuth state:", error);
|
||||
throw new Error("Failed to initialize OAuth flow - cannot store state");
|
||||
}
|
||||
};
|
||||
|
||||
handleExtensionOAuth(authUrl)
|
||||
storeState()
|
||||
.then(() => handleExtensionOAuth(authUrl))
|
||||
.then((responseUrl) => sendResponse({ success: true, responseUrl }))
|
||||
.catch((error) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
@@ -493,9 +510,15 @@ export default defineBackground(() => {
|
||||
});
|
||||
|
||||
async function handleExtensionOAuth(authUrl: string): Promise<string> {
|
||||
const identityAPI = getIdentityAPI();
|
||||
const browserType = detectBrowser();
|
||||
|
||||
// Safari uses tabs-based OAuth flow
|
||||
if (browserType === BrowserType.Safari) {
|
||||
return handleSafariTabsOAuth(authUrl);
|
||||
}
|
||||
|
||||
const identityAPI = getIdentityAPI();
|
||||
|
||||
if (!identityAPI) {
|
||||
throw new Error(`Identity API not available in ${getBrowserDisplayName()}`);
|
||||
}
|
||||
@@ -521,8 +544,8 @@ async function handleExtensionOAuth(authUrl: string): Promise<string> {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Firefox and Safari use Promise-based API
|
||||
if (browserType === BrowserType.Firefox || browserType === BrowserType.Safari) {
|
||||
// Firefox uses Promise-based API
|
||||
if (browserType === BrowserType.Firefox) {
|
||||
try {
|
||||
const result = identityAPI.launchWebAuthFlow({
|
||||
url: authUrl,
|
||||
@@ -568,6 +591,168 @@ async function handleExtensionOAuth(authUrl: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Safari OAuth using tabs-based flow
|
||||
* Opens OAuth in a new tab and captures the redirect callback
|
||||
*/
|
||||
async function handleSafariTabsOAuth(authUrl: string): Promise<string> {
|
||||
// Extract the redirect URI to know what to watch for
|
||||
const redirectUri = new URL(authUrl).searchParams.get("redirect_uri");
|
||||
if (!redirectUri) {
|
||||
throw new Error("redirect_uri not found in auth URL");
|
||||
}
|
||||
|
||||
const redirectUrlObj = new URL(redirectUri);
|
||||
const redirectOrigin = redirectUrlObj.origin; // scheme + host + port
|
||||
const redirectPath = redirectUrlObj.pathname;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tabsAPI = getTabsAPI();
|
||||
if (!tabsAPI) {
|
||||
reject(new Error("Tabs API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
let oauthTabId: number | null = null;
|
||||
let isResolved = false;
|
||||
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
if (!isResolved) {
|
||||
cleanup();
|
||||
reject(new Error("OAuth flow timed out after 5 minutes"));
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
|
||||
const tabUpdateListener = (tabId: number, _changeInfo: unknown, tab: chrome.tabs.Tab) => {
|
||||
// Only process updates for our OAuth tab
|
||||
if (tabId !== oauthTabId) return;
|
||||
|
||||
// Check if the tab navigated to the redirect URI
|
||||
if (tab.url) {
|
||||
try {
|
||||
const tabUrl = new URL(tab.url);
|
||||
|
||||
// Check if this is our OAuth callback URL - exact origin and path match
|
||||
if (tabUrl.origin === redirectOrigin && tabUrl.pathname === redirectPath) {
|
||||
if (!isResolved) {
|
||||
const error = tabUrl.searchParams.get("error");
|
||||
if (error) {
|
||||
isResolved = true;
|
||||
cleanup();
|
||||
const errorDescription = tabUrl.searchParams.get("error_description") || error;
|
||||
|
||||
// Close the tab after capturing error
|
||||
if (oauthTabId !== null) {
|
||||
tabsAPI.remove(oauthTabId).catch(() => {
|
||||
// Ignore errors when closing tab
|
||||
});
|
||||
}
|
||||
|
||||
reject(new Error(`OAuth error: ${errorDescription}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const code = tabUrl.searchParams.get("code");
|
||||
if (!code) {
|
||||
// No code yet, keep listening (might be an intermediate redirect)
|
||||
return;
|
||||
}
|
||||
|
||||
const state = tabUrl.searchParams.get("state");
|
||||
if (!state) {
|
||||
isResolved = true;
|
||||
cleanup();
|
||||
|
||||
// Close the tab
|
||||
if (oauthTabId !== null) {
|
||||
tabsAPI.remove(oauthTabId).catch(() => {
|
||||
// Ignore errors when closing tab
|
||||
});
|
||||
}
|
||||
|
||||
reject(new Error("No state parameter in OAuth callback"));
|
||||
return;
|
||||
}
|
||||
|
||||
// State validation - only close tab after validation succeeds
|
||||
isResolved = true;
|
||||
cleanup();
|
||||
|
||||
validateOAuthStateWithoutCleanup(state)
|
||||
.then(() => {
|
||||
// State is valid, close the tab and resolve
|
||||
// Note: State is NOT cleaned up here, token exchange will clean it up
|
||||
if (oauthTabId !== null) {
|
||||
tabsAPI.remove(oauthTabId).catch(() => {
|
||||
// Ignore errors when closing tab
|
||||
});
|
||||
}
|
||||
if (!tab.url) {
|
||||
reject(new Error("Tab URL is undefined"));
|
||||
return;
|
||||
}
|
||||
resolve(tab.url);
|
||||
})
|
||||
.catch((error) => {
|
||||
// State validation failed, close the tab and reject
|
||||
if (oauthTabId !== null) {
|
||||
tabsAPI.remove(oauthTabId).catch(() => {
|
||||
// Ignore errors when closing tab
|
||||
});
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Invalid URL, ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for tab removal (user closed the OAuth tab)
|
||||
const tabRemovedListener = (tabId: number) => {
|
||||
if (tabId === oauthTabId && !isResolved) {
|
||||
isResolved = true;
|
||||
cleanup();
|
||||
reject(new Error("OAuth cancelled by user"));
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
tabsAPI.onUpdated.removeListener(tabUpdateListener);
|
||||
tabsAPI.onRemoved.removeListener(tabRemovedListener);
|
||||
};
|
||||
|
||||
// Register listeners
|
||||
tabsAPI.onUpdated.addListener(tabUpdateListener);
|
||||
tabsAPI.onRemoved.addListener(tabRemovedListener);
|
||||
|
||||
// Open OAuth URL in a new tab
|
||||
tabsAPI
|
||||
.create({
|
||||
url: authUrl,
|
||||
active: true,
|
||||
})
|
||||
.then((tab) => {
|
||||
if (tab.id) {
|
||||
oauthTabId = tab.id;
|
||||
} else {
|
||||
cleanup();
|
||||
reject(new Error("Failed to create OAuth tab"));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
cleanup();
|
||||
reject(new Error(`Failed to open OAuth tab: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTokenExchange(
|
||||
tokenRequest: Record<string, string>,
|
||||
tokenEndpoint: string,
|
||||
@@ -617,31 +802,61 @@ async function handleTokenExchange(
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates OAuth state without cleaning it up (for Safari flow)
|
||||
* Token exchange will clean up the state after successful validation
|
||||
*/
|
||||
async function validateOAuthStateWithoutCleanup(state: string): Promise<void> {
|
||||
const storageAPI = getStorageAPI();
|
||||
if (!storageAPI?.local) {
|
||||
// Fail closed: if we can't access storage, we can't validate state
|
||||
throw new Error("Storage API not available - cannot validate OAuth state");
|
||||
}
|
||||
|
||||
const result = await storageAPI.local.get(["oauth_state"]);
|
||||
const storedState = result.oauth_state as string | undefined;
|
||||
|
||||
if (!storedState) {
|
||||
throw new Error("No stored OAuth state found - possible CSRF attack");
|
||||
}
|
||||
|
||||
if (storedState !== state) {
|
||||
devLog.error("State parameter mismatch - possible CSRF attack");
|
||||
throw new Error("Invalid state parameter - possible CSRF attack");
|
||||
}
|
||||
}
|
||||
|
||||
async function validateOAuthState(state: string): Promise<void> {
|
||||
const storageAPI = getStorageAPI();
|
||||
if (!storageAPI?.local) {
|
||||
devLog.warn("Storage API not available for state validation");
|
||||
return;
|
||||
// Fail closed: if we can't access storage, we can't validate state
|
||||
throw new Error("Storage API not available - cannot validate OAuth state");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await storageAPI.local.get(["oauth_state"]);
|
||||
const storedState = result.oauth_state as string | undefined;
|
||||
|
||||
if (storedState && storedState !== state) {
|
||||
// Fail closed: state must exist and match exactly
|
||||
if (!storedState) {
|
||||
throw new Error("No stored OAuth state found");
|
||||
}
|
||||
|
||||
if (storedState !== state) {
|
||||
await storageAPI.local.remove("oauth_state");
|
||||
devLog.error("State parameter mismatch - possible CSRF attack");
|
||||
throw new Error("Invalid state parameter - possible CSRF attack");
|
||||
}
|
||||
|
||||
if (storedState === state) {
|
||||
await storageAPI.local.remove("oauth_state");
|
||||
}
|
||||
await storageAPI.local.remove("oauth_state");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Invalid state parameter")) {
|
||||
throw error;
|
||||
// Clean up on any error
|
||||
try {
|
||||
await storageAPI.local.remove("oauth_state");
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
devLog.warn("State validation warning:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export default defineConfig({
|
||||
publicDir: "extension/public",
|
||||
outDir: ".output",
|
||||
manifest: {
|
||||
name: "Cal.com Companion",
|
||||
name: browserTarget === "safari" ? "Cal.com" : "Cal.com Companion",
|
||||
version: "1.7.5",
|
||||
description: "Your calendar companion for quick booking and scheduling",
|
||||
permissions: [
|
||||
|
||||
@@ -2936,7 +2936,9 @@
|
||||
"watchlist.create",
|
||||
"watchlist.read",
|
||||
"watchlist.update",
|
||||
"watchlist.delete"
|
||||
"watchlist.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6659,7 +6661,9 @@
|
||||
"webhook.create",
|
||||
"webhook.read",
|
||||
"webhook.update",
|
||||
"webhook.delete"
|
||||
"webhook.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -29645,7 +29649,9 @@
|
||||
"watchlist.create",
|
||||
"watchlist.read",
|
||||
"watchlist.update",
|
||||
"watchlist.delete"
|
||||
"watchlist.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -29758,7 +29764,9 @@
|
||||
"watchlist.create",
|
||||
"watchlist.read",
|
||||
"watchlist.update",
|
||||
"watchlist.delete"
|
||||
"watchlist.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -29920,7 +29928,9 @@
|
||||
"watchlist.create",
|
||||
"watchlist.read",
|
||||
"watchlist.update",
|
||||
"watchlist.delete"
|
||||
"watchlist.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -30039,7 +30049,9 @@
|
||||
"watchlist.create",
|
||||
"watchlist.read",
|
||||
"watchlist.update",
|
||||
"watchlist.delete"
|
||||
"watchlist.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -30123,7 +30135,9 @@
|
||||
"webhook.create",
|
||||
"webhook.read",
|
||||
"webhook.update",
|
||||
"webhook.delete"
|
||||
"webhook.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -30215,7 +30229,9 @@
|
||||
"webhook.create",
|
||||
"webhook.read",
|
||||
"webhook.update",
|
||||
"webhook.delete"
|
||||
"webhook.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -30356,7 +30372,9 @@
|
||||
"webhook.create",
|
||||
"webhook.read",
|
||||
"webhook.update",
|
||||
"webhook.delete"
|
||||
"webhook.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -30454,7 +30472,9 @@
|
||||
"webhook.create",
|
||||
"webhook.read",
|
||||
"webhook.update",
|
||||
"webhook.delete"
|
||||
"webhook.delete",
|
||||
"featureOptIn.read",
|
||||
"featureOptIn.update"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -33058,10 +33078,6 @@
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "This endpoint will require authentication in a future release."
|
||||
},
|
||||
"error": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -33098,10 +33114,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "This endpoint will require authentication in a future release."
|
||||
},
|
||||
"error": {
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
@@ -15,12 +15,15 @@ const isInWatchMode = process.argv[2] === "--watch";
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
|
||||
const formatFileWithBiome = (filePath: string) => {
|
||||
// Normalize to forward slashes for cross-platform Biome compatibility
|
||||
const normalizedPath = filePath.replace(/\\/g, "/");
|
||||
const { status } = spawnSync(
|
||||
"yarn",
|
||||
["biome", "format", "--write", "--no-errors-on-unmatched", filePath],
|
||||
["biome", "format", "--write", "--no-errors-on-unmatched", normalizedPath],
|
||||
{
|
||||
stdio: "inherit",
|
||||
cwd: repoRoot,
|
||||
shell: true,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -561,7 +564,7 @@ function generateFiles() {
|
||||
["video.adapters.generated.ts", videoOutput],
|
||||
];
|
||||
filesToGenerate.forEach(([fileName, output]) => {
|
||||
const filePath = `${APP_STORE_PATH}/${fileName}`;
|
||||
const filePath = path.join(APP_STORE_PATH, fileName);
|
||||
fs.writeFileSync(filePath, formatOutput(`${banner}${output.join("\n")}`));
|
||||
formatFileWithBiome(filePath);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
## Working With GraphQL
|
||||
# Creating a Salesforce test org
|
||||
You must have the [Salesforce CLI](https://developer.salesforce.com/tools/salesforcecli) installed to create a test org. Once installed, you can create a test org using the following command `yarn scratch-org:create`
|
||||
|
||||
This will create a scratch org with the configuration specified in the `project-scratch-def.json` file.
|
||||
|
||||
To open a browser tab to the org, run `yarn scratch-org:start`
|
||||
|
||||
# Working With GraphQL
|
||||
This package utilizes [`GraphQL Codegen`](https://the-guild.dev/graphql/codegen#graphql-codegen) to generate types and queries from the Salesforce schema.
|
||||
|
||||
### Generating GraphQL Schema
|
||||
Currently v63 of the Salesforce graphql endpoint throws an error when trying to generate files. This is due to the `Setup__JoinInput` type not generating any fields. To work around this, the `schema.json` file comes from the [Salesforce graphql introspection query](https://www.postman.com/salesforce-developers/salesforce-developers/request/sy8qaf9/introspection-query). This file is then converted to a SDL file using the [graphql-introspection-json-to-sdl](https://github.com/Calcom/graphql-introspection-json-to-sdl) package. You can generate the SDL file by running `yarn generate:schema`.
|
||||
|
||||
### Generating Queries
|
||||
When working with graphql files ensure that `yarn codegen:watch` is running in the background. This will generate the types and queries from the SDL file.
|
||||
When working with graphql files ensure that `yarn codegen:watch` is running in the background. This will generate the types and queries from the SDL file.
|
||||
|
||||
# Developing the SFDC package
|
||||
The SFDC package is written using Apex. To develop this package, you need to have the Salesforce CLI installed. Then you can run `yarn sfdc:deploy:preview` to see what changes will be deployed to the scratch org. Running `yarn sfdc:deploy` will deploy the changes to the scratch org.
|
||||
|
||||
Note that if you want to call your local development instances you need to change the "Named Credential" on the scratch org settings to point the `CalCom_Development` credential to the local instance.
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
||||
export { default as "user-sync" } from "./user-sync";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[salesforce/user-sync]"] });
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { instanceUrl, orgId, salesforceUserId, email, changedFields, timestamp } = req.body;
|
||||
|
||||
log.info("Received user sync request", {
|
||||
instanceUrl,
|
||||
orgId,
|
||||
salesforceUserId,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
// TODO: Validate instanceUrl + orgId against stored credentials
|
||||
// TODO: Sync changedFields to Cal.com user
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
@@ -7,7 +7,11 @@
|
||||
"scripts": {
|
||||
"generate:schema": "graphql-introspection-json-to-sdl ./schema.json > ./schema.graphql",
|
||||
"codegen": "graphql-codegen --config codegen.ts",
|
||||
"codegen:watch": "graphql-codegen --config codegen.ts --watch"
|
||||
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
|
||||
"scratch-org:create": "sf org create scratch --definition-file scratch-org-def.json --alias calcom-test --duration-days 7 --set-default",
|
||||
"scratch-org:start": "sf org open --target-org calcom-test",
|
||||
"sfdc:deploy": "cd sfdc-package && sf project deploy start",
|
||||
"sfdc:deploy:preview": "cd sfdc-package && sf project deploy preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/lib": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"orgName": "Cal.com Test Org",
|
||||
"edition": "Developer",
|
||||
"features": [],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
|
||||
# More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
|
||||
#
|
||||
|
||||
package.xml
|
||||
|
||||
# LWC configuration files
|
||||
**/jsconfig.json
|
||||
**/.eslintrc.json
|
||||
|
||||
# LWC Jest
|
||||
**/__tests__/**
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
public with sharing class CalComCalloutQueueable implements Queueable, Database.AllowsCallouts {
|
||||
|
||||
private static final String ENDPOINT_PATH = '/api/integrations/salesforce/user-sync';
|
||||
|
||||
private List<UserChangePayload> payloads;
|
||||
|
||||
public CalComCalloutQueueable(List<UserChangePayload> payloads) {
|
||||
this.payloads = payloads;
|
||||
}
|
||||
|
||||
public void execute(QueueableContext context) {
|
||||
String namedCredential = getNamedCredential();
|
||||
|
||||
for (UserChangePayload payload : payloads) {
|
||||
sendPayload(namedCredential, payload);
|
||||
}
|
||||
}
|
||||
|
||||
private String getNamedCredential() {
|
||||
Boolean isSandbox = [SELECT IsSandbox FROM Organization LIMIT 1].IsSandbox;
|
||||
return isSandbox ? 'callout:CalCom_Development' : 'callout:CalCom_Production';
|
||||
}
|
||||
|
||||
private void sendPayload(String namedCredential, UserChangePayload payload) {
|
||||
try {
|
||||
HttpRequest req = new HttpRequest();
|
||||
req.setEndpoint(namedCredential + ENDPOINT_PATH);
|
||||
req.setMethod('POST');
|
||||
req.setHeader('Content-Type', 'application/json');
|
||||
req.setBody(JSON.serialize(payload));
|
||||
req.setTimeout(30000);
|
||||
|
||||
Http http = new Http();
|
||||
HttpResponse res = http.send(req);
|
||||
|
||||
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
|
||||
System.debug(LoggingLevel.INFO, 'Successfully sent user update to Cal.com for user: ' + payload.salesforceUserId);
|
||||
} else {
|
||||
System.debug(LoggingLevel.ERROR, 'Failed to send user update to Cal.com. Status: ' + res.getStatusCode() + ' Body: ' + res.getBody());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.debug(LoggingLevel.ERROR, 'Exception sending user update to Cal.com: ' + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public class UserChangePayload {
|
||||
public Id salesforceUserId;
|
||||
public String email;
|
||||
public String instanceUrl;
|
||||
public String orgId;
|
||||
public Map<String, Object> changedFields;
|
||||
public String timestamp;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>64.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
public with sharing class UserUpdateHandler {
|
||||
|
||||
public static void handleAfterUpdate(Map<Id, User> oldMap, Map<Id, User> newMap) {
|
||||
List<CalComCalloutQueueable.UserChangePayload> payloads = new List<CalComCalloutQueueable.UserChangePayload>();
|
||||
|
||||
for (Id userId : newMap.keySet()) {
|
||||
User oldUser = oldMap.get(userId);
|
||||
User newUser = newMap.get(userId);
|
||||
|
||||
Map<String, Object> changedFields = getChangedFields(oldUser, newUser);
|
||||
|
||||
if (!changedFields.isEmpty()) {
|
||||
CalComCalloutQueueable.UserChangePayload payload = new CalComCalloutQueueable.UserChangePayload();
|
||||
payload.salesforceUserId = userId;
|
||||
payload.email = newUser.Email;
|
||||
payload.instanceUrl = URL.getOrgDomainUrl().toExternalForm();
|
||||
payload.orgId = UserInfo.getOrganizationId();
|
||||
payload.changedFields = changedFields;
|
||||
payload.timestamp = Datetime.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'');
|
||||
payloads.add(payload);
|
||||
}
|
||||
}
|
||||
|
||||
if (!payloads.isEmpty()) {
|
||||
System.enqueueJob(new CalComCalloutQueueable(payloads));
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> getChangedFields(User oldUser, User newUser) {
|
||||
Map<String, Object> changedFields = new Map<String, Object>();
|
||||
|
||||
Map<String, Object> oldFields = oldUser.getPopulatedFieldsAsMap();
|
||||
Map<String, Object> newFields = newUser.getPopulatedFieldsAsMap();
|
||||
|
||||
for (String fieldName : newFields.keySet()) {
|
||||
Object oldValue = oldFields.get(fieldName);
|
||||
Object newValue = newFields.get(fieldName);
|
||||
|
||||
if (oldValue != newValue) {
|
||||
changedFields.put(fieldName, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFields;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>64.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<fullName>CalCom_Development</fullName>
|
||||
<label>Cal.com Development</label>
|
||||
<endpoint>https://app.cal.com</endpoint>
|
||||
<principalType>Anonymous</principalType>
|
||||
<protocol>Custom</protocol>
|
||||
<allowMergeFieldsInBody>false</allowMergeFieldsInBody>
|
||||
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
|
||||
<generateAuthorizationHeader>false</generateAuthorizationHeader>
|
||||
<calloutStatus>Enabled</calloutStatus>
|
||||
</NamedCredential>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<fullName>CalCom_Production</fullName>
|
||||
<label>Cal.com Production</label>
|
||||
<endpoint>https://app.cal.com</endpoint>
|
||||
<principalType>Anonymous</principalType>
|
||||
<protocol>Custom</protocol>
|
||||
<allowMergeFieldsInBody>false</allowMergeFieldsInBody>
|
||||
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
|
||||
<generateAuthorizationHeader>false</generateAuthorizationHeader>
|
||||
<calloutStatus>Enabled</calloutStatus>
|
||||
</NamedCredential>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
trigger UserUpdateTrigger on User (after update) {
|
||||
UserUpdateHandler.handleAfterUpdate(Trigger.oldMap, Trigger.newMap);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexTrigger xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>64.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexTrigger>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"packageDirectories": [
|
||||
{
|
||||
"path": "force-app",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"name": "calcom-user-sync",
|
||||
"namespace": "",
|
||||
"sfdcLoginUrl": "https://login.salesforce.com",
|
||||
"sourceApiVersion": "64.0"
|
||||
}
|
||||
@@ -212,7 +212,18 @@ export const sendRoundRobinRescheduledEmailsAndSMS = async (
|
||||
if (
|
||||
!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED)
|
||||
) {
|
||||
emailsAndSMSToSend.push(sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, person)));
|
||||
emailsAndSMSToSend.push(
|
||||
sendEmail(
|
||||
() =>
|
||||
new AttendeeRescheduledEmail(
|
||||
{
|
||||
...calendarEvent,
|
||||
...(calendarEvent.hideCalendarNotes && { additionalNotes: undefined }),
|
||||
},
|
||||
person
|
||||
)
|
||||
)
|
||||
);
|
||||
if (person.phoneNumber) {
|
||||
emailsAndSMSToSend.push(successfullyReScheduledSMS.sendSMSToAttendee(person));
|
||||
}
|
||||
@@ -322,7 +333,16 @@ const _sendRescheduledEmailsAndSMS = async (
|
||||
if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED)) {
|
||||
emailsToSend.push(
|
||||
...calendarEvent.attendees.map((attendee) => {
|
||||
return sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, attendee));
|
||||
return sendEmail(
|
||||
() =>
|
||||
new AttendeeRescheduledEmail(
|
||||
{
|
||||
...calendarEvent,
|
||||
...(calendarEvent.hideCalendarNotes && { additionalNotes: undefined }),
|
||||
},
|
||||
attendee
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -350,7 +370,18 @@ export const sendRescheduledSeatEmailAndSMS = async (
|
||||
if (!eventTypeDisableHostEmail(eventTypeMetadata))
|
||||
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })));
|
||||
if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED))
|
||||
emailsToSend.push(sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)));
|
||||
emailsToSend.push(
|
||||
sendEmail(
|
||||
() =>
|
||||
new AttendeeRescheduledEmail(
|
||||
{
|
||||
...clonedCalEvent,
|
||||
...(clonedCalEvent.hideCalendarNotes && { additionalNotes: undefined }),
|
||||
},
|
||||
attendee
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const successfullyReScheduledSMS = new EventSuccessfullyReScheduledSMS(calEvent);
|
||||
await successfullyReScheduledSMS.sendSMSToAttendee(attendee);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams } from "./IAuditActionService";
|
||||
@@ -15,8 +16,14 @@ const fieldsSchemaV1 = z.object({
|
||||
startTime: z.number(),
|
||||
endTime: z.number(),
|
||||
status: z.nativeEnum(BookingStatus),
|
||||
hostUserUuid: z.string().nullable(),
|
||||
// Allowing it to be optional because most of the time(non-seated booking) it won't be there
|
||||
seatReferenceUid: z.string().nullish(),
|
||||
});
|
||||
|
||||
type Deps = {
|
||||
userRepository: UserRepository;
|
||||
};
|
||||
export class CreatedAuditActionService implements IAuditActionService {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "CREATED" as const;
|
||||
@@ -32,7 +39,7 @@ export class CreatedAuditActionService implements IAuditActionService {
|
||||
public static readonly storedFieldsSchema = CreatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<typeof CreatedAuditActionService.latestFieldsSchema, typeof CreatedAuditActionService.storedDataSchema>;
|
||||
|
||||
constructor() {
|
||||
constructor(private readonly deps: Deps) {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: CreatedAuditActionService.latestFieldsSchema,
|
||||
@@ -58,8 +65,14 @@ export class CreatedAuditActionService implements IAuditActionService {
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(_: GetDisplayTitleParams): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.created" };
|
||||
async getDisplayTitle({ storedData }: GetDisplayTitleParams): Promise<TranslationWithParams> {
|
||||
const { fields } = this.parseStored(storedData);
|
||||
const hostUser = fields.hostUserUuid ? await this.deps.userRepository.findByUuid({ uuid: fields.hostUserUuid }) : null;
|
||||
const hostName = hostUser?.name || "Unknown";
|
||||
if (fields.seatReferenceUid) {
|
||||
return { key: "booking_audit_action.created_with_seat", params: { host: hostName } };
|
||||
}
|
||||
return { key: "booking_audit_action.created", params: { host: hostName } };
|
||||
}
|
||||
|
||||
getDisplayJson({
|
||||
@@ -73,6 +86,7 @@ export class CreatedAuditActionService implements IAuditActionService {
|
||||
startTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.startTime, timeZone),
|
||||
endTime: AuditActionServiceHelper.formatDateTimeInTimeZone(fields.endTime, timeZone),
|
||||
status: fields.status,
|
||||
...(fields.seatReferenceUid ? { seatReferenceUid: fields.seatReferenceUid } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -83,5 +97,6 @@ export type CreatedAuditDisplayData = {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: BookingStatus;
|
||||
seatReferenceUid?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -88,12 +88,16 @@ export class ReassignmentAuditActionService implements IAuditActionService {
|
||||
labelKey: string;
|
||||
valueKey: string;
|
||||
}> {
|
||||
const { fields } = storedData;
|
||||
const typeTranslationKey = `booking_audit_action.assignmentType_${fields.reassignmentType}`;
|
||||
const { fields } = this.parseStored(storedData);
|
||||
const map = {
|
||||
manual: "manual",
|
||||
roundRobin: "round_robin",
|
||||
}
|
||||
const typeTranslationKey = `booking_audit_action.assignment_type_${map[fields.reassignmentType]}`;
|
||||
|
||||
return [
|
||||
{
|
||||
labelKey: "booking_audit_action.type",
|
||||
labelKey: "booking_audit_action.assignment_type",
|
||||
valueKey: typeTranslationKey,
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { NumberChangeSchema, StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams, GetDisplayJsonParams, BaseStoredAuditData } from "./IAuditActionService";
|
||||
|
||||
@@ -11,8 +11,8 @@ import type { IAuditActionService, TranslationWithParams, GetDisplayTitleParams,
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
startTime: StringChangeSchema,
|
||||
endTime: StringChangeSchema,
|
||||
startTime: NumberChangeSchema,
|
||||
endTime: NumberChangeSchema,
|
||||
rescheduledToUid: StringChangeSchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams, TranslationWithParams } from "./IAuditActionService";
|
||||
/**
|
||||
@@ -12,8 +12,8 @@ import type { IAuditActionService, GetDisplayTitleParams, GetDisplayJsonParams,
|
||||
const fieldsSchemaV1 = z.object({
|
||||
seatReferenceUid: z.string(),
|
||||
attendeeEmail: z.string(),
|
||||
startTime: StringChangeSchema,
|
||||
endTime: StringChangeSchema,
|
||||
startTime: NumberChangeSchema,
|
||||
endTime: NumberChangeSchema,
|
||||
rescheduledToBookingUid: StringChangeSchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { UserRepository } from "@calcom/features/users/repositories/UserRep
|
||||
import type { IAuditActionService } from "../actions/IAuditActionService";
|
||||
import type { BookingAuditAction } from "../repository/IBookingAuditRepository";
|
||||
|
||||
// Import all action services
|
||||
import { CreatedAuditActionService, type CreatedAuditData } from "../actions/CreatedAuditActionService";
|
||||
import { CancelledAuditActionService, type CancelledAuditData } from "../actions/CancelledAuditActionService";
|
||||
import { RescheduledAuditActionService, type RescheduledAuditData } from "../actions/RescheduledAuditActionService";
|
||||
@@ -54,7 +53,7 @@ export class BookingAuditActionServiceRegistry {
|
||||
|
||||
constructor(private deps: BookingAuditActionServiceRegistryDeps) {
|
||||
const services: Array<[BookingAuditAction, IAuditActionService]> = [
|
||||
["CREATED", new CreatedAuditActionService()],
|
||||
["CREATED", new CreatedAuditActionService(deps)],
|
||||
["CANCELLED", new CancelledAuditActionService()],
|
||||
["RESCHEDULED", new RescheduledAuditActionService()],
|
||||
["ACCEPTED", new AcceptedAuditActionService()],
|
||||
|
||||
@@ -201,6 +201,28 @@ export interface BookingAuditProducerService {
|
||||
context?: BookingAuditContext;
|
||||
}): Promise<void>;
|
||||
|
||||
queueBulkCreatedAudit(params: {
|
||||
bookings: Array<{
|
||||
bookingUid: string;
|
||||
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
|
||||
}>;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
}): Promise<void>;
|
||||
|
||||
queueBulkRescheduledAudit(params: {
|
||||
bookings: Array<{
|
||||
bookingUid: string;
|
||||
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
|
||||
}>;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
}): Promise<void>;
|
||||
|
||||
queueBulkRejectedAudit(params: {
|
||||
bookings: Array<{
|
||||
bookingUid: string;
|
||||
|
||||
@@ -271,7 +271,7 @@ export class BookingAuditTaskConsumer {
|
||||
const { organizationId, action, bookingUids } = params;
|
||||
|
||||
if (organizationId === null) {
|
||||
logger.info(`Skipping audit for non-organization booking: action=${action}, bookingUids=${bookingUids.join(",")}`);
|
||||
logger.debug(`Skipping audit for non-organization booking: action=${action}, bookingUids=${bookingUids.join(",")}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ export class BookingAuditTaskConsumer {
|
||||
);
|
||||
|
||||
if (!isFeatureEnabled) {
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`booking-audit feature is disabled for organization: action=${action}, bookingUids=${bookingUids.join(",")}, organizationId=${organizationId}`
|
||||
);
|
||||
return false;
|
||||
|
||||
@@ -402,6 +402,38 @@ export class BookingAuditTaskerProducerService implements BookingAuditProducerSe
|
||||
});
|
||||
}
|
||||
|
||||
async queueBulkCreatedAudit(params: {
|
||||
bookings: Array<{
|
||||
bookingUid: string;
|
||||
data: z.infer<typeof CreatedAuditActionService.latestFieldsSchema>;
|
||||
}>;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
}): Promise<void> {
|
||||
await this.queueBulkTask({
|
||||
...params,
|
||||
action: CreatedAuditActionService.TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
async queueBulkRescheduledAudit(params: {
|
||||
bookings: Array<{
|
||||
bookingUid: string;
|
||||
data: z.infer<typeof RescheduledAuditActionService.latestFieldsSchema>;
|
||||
}>;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
source: ActionSource;
|
||||
operationId?: string | null;
|
||||
}): Promise<void> {
|
||||
await this.queueBulkTask({
|
||||
...params,
|
||||
action: RescheduledAuditActionService.TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
async queueBulkRejectedAudit(params: {
|
||||
bookings: Array<{
|
||||
bookingUid: string;
|
||||
|
||||
@@ -125,9 +125,7 @@ export class BookingAuditViewerService {
|
||||
userTimeZone,
|
||||
});
|
||||
if (rescheduledFromLog) {
|
||||
// Add the rescheduled log from the previous booking as the first entry
|
||||
// (appears last chronologically since logs are ordered by timestamp DESC)
|
||||
enrichedAuditLogs.unshift(rescheduledFromLog);
|
||||
enrichedAuditLogs.push(rescheduledFromLog);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+302
-269
@@ -25,29 +25,74 @@ vi.mock("@calcom/features/bookings/repositories/BookingRepository");
|
||||
vi.mock("@calcom/features/membership/repositories/MembershipRepository");
|
||||
vi.mock("@calcom/features/credentials/repositories/CredentialRepository");
|
||||
|
||||
const createMockTeamBooking = (overrides?: {
|
||||
userId?: number;
|
||||
teamId?: number | null;
|
||||
parentTeamId?: number;
|
||||
}) => ({
|
||||
userId: overrides?.userId ?? 456,
|
||||
type MockBooking = {
|
||||
userId: number;
|
||||
user: {
|
||||
id: overrides?.userId ?? 456,
|
||||
email: "test@example.com",
|
||||
},
|
||||
id: number;
|
||||
email: string;
|
||||
};
|
||||
eventType: {
|
||||
teamId: (overrides && "teamId" in overrides ? overrides.teamId : (overrides?.teamId ?? 100)) ?? null,
|
||||
parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null,
|
||||
hosts: [],
|
||||
users: [],
|
||||
},
|
||||
attendees: [],
|
||||
});
|
||||
teamId: number | null;
|
||||
parent: { teamId: number } | null;
|
||||
hosts: unknown[];
|
||||
users: unknown[];
|
||||
} | null;
|
||||
attendees: unknown[];
|
||||
fromReschedule?: string | null;
|
||||
};
|
||||
|
||||
type MockAttendee = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type MockMembership = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const DB = {
|
||||
users: {} as Record<string, MockUser>,
|
||||
bookings: {} as Record<string, MockBooking>,
|
||||
auditLogs: {} as Record<string, BookingAuditWithActor[]>,
|
||||
attendees: {} as Record<number, MockAttendee>,
|
||||
memberships: {} as Record<string, boolean>, // key: "userId-teamId"
|
||||
};
|
||||
|
||||
const createMockTeamBooking = (
|
||||
bookingUid: string,
|
||||
overrides?: {
|
||||
userId?: number;
|
||||
teamId?: number | null;
|
||||
parentTeamId?: number;
|
||||
fromReschedule?: string | null;
|
||||
}
|
||||
) => {
|
||||
const booking: MockBooking = {
|
||||
userId: overrides?.userId ?? 456,
|
||||
user: {
|
||||
id: overrides?.userId ?? 456,
|
||||
email: "test@example.com",
|
||||
},
|
||||
eventType: {
|
||||
teamId: (overrides && "teamId" in overrides ? overrides.teamId : (overrides?.teamId ?? 100)) ?? null,
|
||||
parent: (overrides?.parentTeamId ? { teamId: overrides.parentTeamId } : undefined) ?? null,
|
||||
hosts: [],
|
||||
users: [],
|
||||
},
|
||||
attendees: [],
|
||||
fromReschedule: overrides?.fromReschedule,
|
||||
};
|
||||
|
||||
DB.bookings[bookingUid] = booking;
|
||||
return booking;
|
||||
};
|
||||
|
||||
const createMockAuditLog = (
|
||||
bookingUid: string,
|
||||
overrides?: Partial<{
|
||||
id: string;
|
||||
bookingUid: string;
|
||||
action: BookingAuditAction;
|
||||
type: BookingAuditType;
|
||||
timestamp: Date;
|
||||
@@ -58,45 +103,92 @@ const createMockAuditLog = (
|
||||
actorType: AuditActorType;
|
||||
actorUserUuid: string | null;
|
||||
actorName: string | null;
|
||||
actorAttendeeId: number | null;
|
||||
context: BookingAuditContext | null;
|
||||
}>
|
||||
): BookingAuditWithActor => ({
|
||||
id: overrides?.id ?? "audit-log-1",
|
||||
bookingUid: overrides?.bookingUid ?? "booking-uid-123",
|
||||
action: overrides?.action ?? "CREATED",
|
||||
type: overrides?.type ?? "RECORD_CREATED",
|
||||
timestamp: overrides?.timestamp ?? new Date("2024-01-15T10:00:00Z"),
|
||||
createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"),
|
||||
updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"),
|
||||
actorId: overrides?.actorId ?? "actor-1",
|
||||
data: overrides?.data ?? {
|
||||
version: 1,
|
||||
fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" },
|
||||
},
|
||||
source: "WEBAPP" as const,
|
||||
operationId: "operation-id-123",
|
||||
context: (overrides && "context" in overrides ? overrides.context : null) as BookingAuditContext | null,
|
||||
actor: {
|
||||
id: overrides?.actorId ?? "actor-1",
|
||||
type: overrides?.actorType ?? ("USER" as const),
|
||||
userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as
|
||||
| string
|
||||
| null,
|
||||
attendeeId: null,
|
||||
credentialId: null,
|
||||
name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
},
|
||||
});
|
||||
): BookingAuditWithActor => {
|
||||
const log: BookingAuditWithActor = {
|
||||
id: overrides?.id ?? "audit-log-1",
|
||||
bookingUid,
|
||||
action: overrides?.action ?? "CREATED",
|
||||
type: overrides?.type ?? "RECORD_CREATED",
|
||||
timestamp: overrides?.timestamp ?? new Date("2024-01-15T10:00:00Z"),
|
||||
createdAt: overrides?.createdAt ?? new Date("2024-01-15T10:00:00Z"),
|
||||
updatedAt: overrides?.updatedAt ?? new Date("2024-01-15T10:00:00Z"),
|
||||
actorId: overrides?.actorId ?? "actor-1",
|
||||
data: overrides?.data ?? {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: 1705315200000,
|
||||
endTime: 1705318800000,
|
||||
status: "ACCEPTED",
|
||||
hostUserUuid: "host-uuid",
|
||||
},
|
||||
},
|
||||
source: "WEBAPP" as const,
|
||||
operationId: "operation-id-123",
|
||||
context: (overrides && "context" in overrides ? overrides.context : null) as BookingAuditContext | null,
|
||||
actor: {
|
||||
id: overrides?.actorId ?? "actor-1",
|
||||
type: overrides?.actorType ?? ("USER" as const),
|
||||
userUuid: (overrides && "actorUserUuid" in overrides ? overrides.actorUserUuid : "user-uuid-123") as
|
||||
| string
|
||||
| null,
|
||||
attendeeId: (overrides && "actorAttendeeId" in overrides ? overrides.actorAttendeeId : null) as
|
||||
| number
|
||||
| null,
|
||||
credentialId: null,
|
||||
name: (overrides && "actorName" in overrides ? overrides.actorName : "John Doe") as string | null,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
},
|
||||
};
|
||||
|
||||
if (!DB.auditLogs[bookingUid]) {
|
||||
DB.auditLogs[bookingUid] = [];
|
||||
}
|
||||
DB.auditLogs[bookingUid].push(log);
|
||||
return log;
|
||||
};
|
||||
|
||||
type MockUser = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
const createMockUser = (
|
||||
uuid?: string,
|
||||
overrides?: Partial<{ id: number; name: string | null; email: string; avatarUrl: string | null }>
|
||||
) => ({
|
||||
id: overrides?.id ?? 123,
|
||||
name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null,
|
||||
email: overrides?.email ?? "john@example.com",
|
||||
avatarUrl: (overrides && "avatarUrl" in overrides ? overrides.avatarUrl : null) as string | null,
|
||||
});
|
||||
) => {
|
||||
const user: MockUser = {
|
||||
id: overrides?.id ?? 123,
|
||||
name: (overrides && "name" in overrides ? overrides.name : "John Doe") as string | null,
|
||||
email: overrides?.email ?? "john@example.com",
|
||||
avatarUrl: (overrides && "avatarUrl" in overrides ? overrides.avatarUrl : null) as string | null,
|
||||
};
|
||||
|
||||
if (uuid) {
|
||||
DB.users[uuid] = user;
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
const createMockAttendee = (id: number, overrides?: Partial<{ name: string | null; email: string }>) => {
|
||||
const attendee: MockAttendee = {
|
||||
id,
|
||||
name: overrides?.name ?? null,
|
||||
email: overrides?.email ?? `attendee-${id}@example.com`,
|
||||
};
|
||||
DB.attendees[id] = attendee;
|
||||
return attendee;
|
||||
};
|
||||
|
||||
const createMockMembership = ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const key = `${userId}-${teamId}`;
|
||||
DB.memberships[key] = true;
|
||||
};
|
||||
|
||||
describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
let service: BookingAuditViewerService;
|
||||
@@ -131,25 +223,45 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
DB.users = {};
|
||||
DB.bookings = {};
|
||||
DB.auditLogs = {};
|
||||
DB.attendees = {};
|
||||
DB.memberships = {};
|
||||
|
||||
mockBookingRepository = {
|
||||
findByUidIncludeEventType: vi.fn(),
|
||||
getFromRescheduleUid: vi.fn(),
|
||||
findByUidIncludeEventType: vi.fn().mockImplementation(({ bookingUid }: { bookingUid: string }) => {
|
||||
return Promise.resolve(DB.bookings[bookingUid] ?? null);
|
||||
}),
|
||||
getFromRescheduleUid: vi.fn().mockImplementation((bookingUid: string) => {
|
||||
const booking = DB.bookings[bookingUid];
|
||||
return Promise.resolve(booking?.fromReschedule ?? null);
|
||||
}),
|
||||
};
|
||||
|
||||
mockUserRepository = {
|
||||
getUserOrganizationAndTeams: vi.fn(),
|
||||
findByUuid: vi.fn(),
|
||||
findByUuid: vi.fn().mockImplementation(({ uuid }: { uuid: string }) => {
|
||||
return Promise.resolve(DB.users[uuid] ?? null);
|
||||
}),
|
||||
};
|
||||
|
||||
mockBookingAuditRepository = {
|
||||
create: vi.fn(),
|
||||
findAllForBooking: vi.fn().mockResolvedValue([]),
|
||||
findRescheduledLogsOfBooking: vi.fn(),
|
||||
findAllForBooking: vi.fn().mockImplementation((bookingUid: string) => {
|
||||
return Promise.resolve(DB.auditLogs[bookingUid] ?? []);
|
||||
}),
|
||||
findRescheduledLogsOfBooking: vi.fn().mockImplementation((bookingUid: string) => {
|
||||
const logs = DB.auditLogs[bookingUid] ?? [];
|
||||
return Promise.resolve(logs.filter((log) => log.action === "RESCHEDULED"));
|
||||
}),
|
||||
};
|
||||
|
||||
mockMembershipRepository = {
|
||||
hasMembership: vi.fn(),
|
||||
hasMembership: vi.fn().mockImplementation(({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const key = `${userId}-${teamId}`;
|
||||
return Promise.resolve(DB.memberships[key] ?? false);
|
||||
}),
|
||||
};
|
||||
|
||||
mockPermissionCheckService = {
|
||||
@@ -157,7 +269,9 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
};
|
||||
|
||||
mockAttendeeRepository = {
|
||||
findById: vi.fn(),
|
||||
findById: vi.fn().mockImplementation((id: number) => {
|
||||
return Promise.resolve(DB.attendees[id] ?? null);
|
||||
}),
|
||||
};
|
||||
|
||||
mockCredentialRepository = {
|
||||
@@ -198,18 +312,13 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
describe("getAuditLogsForBooking", () => {
|
||||
describe("when user has permission to view audit logs", () => {
|
||||
beforeEach(() => {
|
||||
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
|
||||
createMockTeamBooking({ teamId: 100 })
|
||||
);
|
||||
createMockTeamBooking("booking-uid-123", { teamId: 100 });
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should return enriched audit logs with actor information", async () => {
|
||||
const mockLog = createMockAuditLog();
|
||||
const mockUser = createMockUser();
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(mockUser);
|
||||
createMockAuditLog("booking-uid-123");
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -236,13 +345,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should format timestamps as ISO strings", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
timestamp: new Date("2024-01-15T10:00:00Z"),
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -257,10 +365,9 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should include action display title with translation key", async () => {
|
||||
const mockLog = createMockAuditLog({ action: "CREATED" });
|
||||
createMockAuditLog("booking-uid-123", { action: "CREATED" });
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -272,43 +379,48 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
|
||||
expect(result.auditLogs[0].actionDisplayTitle).toEqual({
|
||||
key: "booking_audit_action.created",
|
||||
params: {
|
||||
host: "Unknown",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple audit logs in correct order", async () => {
|
||||
const logs = [
|
||||
createMockAuditLog({
|
||||
id: "log-1",
|
||||
action: "CREATED",
|
||||
timestamp: new Date("2024-01-15T10:00:00Z"),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: { startTime: 1705315200000, endTime: 1705318800000, status: "ACCEPTED" },
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
id: "log-1",
|
||||
action: "CREATED",
|
||||
timestamp: new Date("2024-01-15T10:00:00Z"),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: 1705315200000,
|
||||
endTime: 1705318800000,
|
||||
status: "ACCEPTED",
|
||||
hostUserUuid: null,
|
||||
},
|
||||
}),
|
||||
createMockAuditLog({
|
||||
id: "log-2",
|
||||
action: "ACCEPTED",
|
||||
timestamp: new Date("2024-01-15T11:00:00Z"),
|
||||
data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } },
|
||||
}),
|
||||
createMockAuditLog({
|
||||
id: "log-3",
|
||||
action: "CANCELLED",
|
||||
timestamp: new Date("2024-01-15T12:00:00Z"),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
status: { old: "ACCEPTED", new: "CANCELLED" },
|
||||
cancellationReason: "User requested",
|
||||
cancelledBy: "user-123",
|
||||
},
|
||||
},
|
||||
});
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
id: "log-2",
|
||||
action: "ACCEPTED",
|
||||
timestamp: new Date("2024-01-15T11:00:00Z"),
|
||||
data: { version: 1, fields: { status: { old: "PENDING", new: "ACCEPTED" } } },
|
||||
});
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
id: "log-3",
|
||||
action: "CANCELLED",
|
||||
timestamp: new Date("2024-01-15T12:00:00Z"),
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
status: { old: "ACCEPTED", new: "CANCELLED" },
|
||||
cancellationReason: "User requested",
|
||||
cancelledBy: "user-123",
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue(logs);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -325,7 +437,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should return empty array when no audit logs exist", async () => {
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([]);
|
||||
// Don't create any audit logs in DB
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -342,26 +454,21 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
|
||||
describe("when enriching actor information", () => {
|
||||
beforeEach(() => {
|
||||
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
|
||||
createMockTeamBooking({ teamId: 100 })
|
||||
);
|
||||
createMockTeamBooking("booking-uid-123", { teamId: 100 });
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should enrich USER actor with user details from repository", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
actorType: "USER",
|
||||
actorUserUuid: "user-uuid-456",
|
||||
});
|
||||
const mockUser = createMockUser({
|
||||
createMockUser("user-uuid-456", {
|
||||
name: "Jane Smith",
|
||||
email: "jane@example.com",
|
||||
avatarUrl: "https://example.com/avatar.jpg",
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
userId: 123,
|
||||
@@ -379,14 +486,11 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should use email as display name when user name is null", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
actorType: "USER",
|
||||
actorName: null,
|
||||
});
|
||||
const mockUser = createMockUser({ name: null, email: "user@example.com" });
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(mockUser);
|
||||
createMockUser("user-uuid-123", { name: null, email: "user@example.com" });
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -400,10 +504,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should show 'Deleted User' when user not found in repository", async () => {
|
||||
const mockLog = createMockAuditLog({ actorType: "USER" });
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(null);
|
||||
createMockAuditLog("booking-uid-123", { actorType: "USER" });
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -421,13 +522,20 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should show 'Cal.com' for SYSTEM actor", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
actorType: "SYSTEM",
|
||||
actorUserUuid: null,
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: 1705315200000,
|
||||
endTime: 1705318800000,
|
||||
status: "ACCEPTED",
|
||||
hostUserUuid: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
userId: 123,
|
||||
@@ -446,14 +554,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should show 'Guest' for GUEST actor without name", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
actorType: "GUEST",
|
||||
actorUserUuid: null,
|
||||
actorName: null,
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
userId: 123,
|
||||
@@ -471,14 +577,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should use provided name for GUEST actor", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
actorType: "GUEST",
|
||||
actorUserUuid: null,
|
||||
actorName: "External Guest",
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
userId: 123,
|
||||
@@ -491,27 +595,14 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should show 'Attendee' for ATTENDEE actor without name", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
actorType: "ATTENDEE",
|
||||
actorUserUuid: null,
|
||||
actorName: null,
|
||||
actorAttendeeId: 999,
|
||||
});
|
||||
|
||||
// Update the mock to include attendeeId in the actor
|
||||
const logWithAttendeeId = {
|
||||
...mockLog,
|
||||
actor: {
|
||||
...mockLog.actor,
|
||||
attendeeId: 999,
|
||||
},
|
||||
};
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]);
|
||||
mockAttendeeRepository.findById.mockResolvedValue({
|
||||
id: 999,
|
||||
name: null,
|
||||
email: "attendee@example.com",
|
||||
});
|
||||
createMockAttendee(999, { name: null, email: "attendee@example.com" });
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -531,27 +622,14 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should use provided name for ATTENDEE actor", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
actorType: "ATTENDEE",
|
||||
actorUserUuid: null,
|
||||
actorName: "Meeting Participant",
|
||||
actorAttendeeId: 888,
|
||||
});
|
||||
|
||||
// Update the mock to include attendeeId in the actor
|
||||
const logWithAttendeeId = {
|
||||
...mockLog,
|
||||
actor: {
|
||||
...mockLog.actor,
|
||||
attendeeId: 888,
|
||||
},
|
||||
};
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]);
|
||||
mockAttendeeRepository.findById.mockResolvedValue({
|
||||
id: 888,
|
||||
name: "Meeting Participant",
|
||||
email: "participant@example.com",
|
||||
});
|
||||
createMockAttendee(888, { name: "Meeting Participant", email: "participant@example.com" });
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -567,23 +645,14 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should show 'Deleted Attendee' when attendee not found in repository", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
actorType: "ATTENDEE",
|
||||
actorUserUuid: null,
|
||||
actorName: null,
|
||||
actorAttendeeId: 777,
|
||||
});
|
||||
|
||||
// Update the mock to include attendeeId in the actor
|
||||
const logWithAttendeeId = {
|
||||
...mockLog,
|
||||
actor: {
|
||||
...mockLog.actor,
|
||||
attendeeId: 777,
|
||||
},
|
||||
};
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([logWithAttendeeId]);
|
||||
mockAttendeeRepository.findById.mockResolvedValue(null);
|
||||
// Don't create attendee in DB - should return null
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -605,37 +674,31 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
|
||||
describe("when handling rescheduled bookings", () => {
|
||||
beforeEach(() => {
|
||||
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
|
||||
createMockTeamBooking({ teamId: 100 })
|
||||
);
|
||||
createMockTeamBooking("new-booking-uid", { teamId: 100 });
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should include rescheduled from log when booking was created from reschedule", async () => {
|
||||
const currentBookingLog = createMockAuditLog({
|
||||
createMockAuditLog("new-booking-uid", {
|
||||
id: "current-log",
|
||||
bookingUid: "new-booking-uid",
|
||||
action: "CREATED",
|
||||
});
|
||||
|
||||
const rescheduledLog = createMockAuditLog({
|
||||
createMockAuditLog("old-booking-uid", {
|
||||
id: "rescheduled-log",
|
||||
bookingUid: "old-booking-uid",
|
||||
action: "RESCHEDULED",
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" },
|
||||
endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" },
|
||||
startTime: { old: 1705315200000, new: 1705401600000 },
|
||||
endTime: { old: 1705318800000, new: 1705405200000 },
|
||||
rescheduledToUid: { old: null, new: "new-booking-uid" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]);
|
||||
mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid");
|
||||
mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockTeamBooking("new-booking-uid", { teamId: 100, fromReschedule: "old-booking-uid" });
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "new-booking-uid",
|
||||
@@ -646,19 +709,18 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
expect(result.auditLogs).toHaveLength(2);
|
||||
expect(result.auditLogs[0].id).toBe("rescheduled-log");
|
||||
expect(result.auditLogs[0].bookingUid).toBe("new-booking-uid");
|
||||
expect(result.auditLogs[0].actionDisplayTitle.key).toBe("booking_audit_action.rescheduled_from");
|
||||
expect(result.auditLogs[0].displayJson).toHaveProperty("rescheduledFromUid", "old-booking-uid");
|
||||
expect(result.auditLogs[1].id).toBe("current-log");
|
||||
expect(result.auditLogs[1].id).toBe("rescheduled-log");
|
||||
expect(result.auditLogs[1].bookingUid).toBe("new-booking-uid");
|
||||
expect(result.auditLogs[1].actionDisplayTitle.key).toBe("booking_audit_action.rescheduled_from");
|
||||
expect(result.auditLogs[1].displayJson).toHaveProperty("rescheduledFromUid", "old-booking-uid");
|
||||
expect(result.auditLogs[0].id).toBe("current-log");
|
||||
});
|
||||
|
||||
it("should not include rescheduled from log when booking was not rescheduled", async () => {
|
||||
const mockLog = createMockAuditLog();
|
||||
createMockAuditLog("booking-uid-123");
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockBookingRepository.getFromRescheduleUid.mockResolvedValue(null);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockTeamBooking("booking-uid-123", { teamId: 100 });
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -673,28 +735,23 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should continue without rescheduled from log when matching log not found", async () => {
|
||||
const currentBookingLog = createMockAuditLog({
|
||||
bookingUid: "new-booking-uid",
|
||||
});
|
||||
createMockAuditLog("new-booking-uid");
|
||||
|
||||
const rescheduledLog = createMockAuditLog({
|
||||
createMockAuditLog("old-booking-uid", {
|
||||
id: "rescheduled-log",
|
||||
bookingUid: "old-booking-uid",
|
||||
action: "RESCHEDULED",
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" },
|
||||
endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" },
|
||||
startTime: { old: 1705315200000, new: 1705401600000 },
|
||||
endTime: { old: 1705318800000, new: 1705405200000 },
|
||||
rescheduledToUid: { old: null, new: "different-booking-uid" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([currentBookingLog]);
|
||||
mockBookingRepository.getFromRescheduleUid.mockResolvedValue("old-booking-uid");
|
||||
mockBookingAuditRepository.findRescheduledLogsOfBooking.mockResolvedValue([rescheduledLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockTeamBooking("new-booking-uid", { teamId: 100, fromReschedule: "old-booking-uid" });
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "new-booking-uid",
|
||||
@@ -714,11 +771,9 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
|
||||
describe("when permission check fails", () => {
|
||||
it("should throw error when user lacks permission", async () => {
|
||||
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
|
||||
createMockTeamBooking({ teamId: 100 })
|
||||
);
|
||||
createMockTeamBooking("booking-uid-123", { teamId: 100 });
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||
mockMembershipRepository.hasMembership.mockResolvedValue(true);
|
||||
createMockMembership({ userId: 123, teamId: 100 });
|
||||
|
||||
await expect(
|
||||
service.getAuditLogsForBooking({
|
||||
@@ -748,7 +803,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should throw error when booking not found", async () => {
|
||||
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(null);
|
||||
// Don't create booking in DB - should return null
|
||||
|
||||
await expect(
|
||||
service.getAuditLogsForBooking({
|
||||
@@ -766,27 +821,24 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
|
||||
describe("when handling different timezones", () => {
|
||||
beforeEach(() => {
|
||||
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
|
||||
createMockTeamBooking({ teamId: 100 })
|
||||
);
|
||||
createMockTeamBooking("booking-uid-123", { teamId: 100 });
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should pass user timezone to action service for display formatting", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
action: "RESCHEDULED",
|
||||
data: {
|
||||
version: 1,
|
||||
fields: {
|
||||
startTime: { old: "2024-01-15T10:00:00Z", new: "2024-01-16T10:00:00Z" },
|
||||
endTime: { old: "2024-01-15T11:00:00Z", new: "2024-01-16T11:00:00Z" },
|
||||
startTime: { old: 1705315200000, new: 1705401600000 },
|
||||
endTime: { old: 1705318800000, new: 1705405200000 },
|
||||
rescheduledToUid: { old: null, new: "new-booking-uid" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -803,14 +855,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
|
||||
describe("when handling seat audit actions", () => {
|
||||
beforeEach(() => {
|
||||
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
|
||||
createMockTeamBooking({ teamId: 100 })
|
||||
);
|
||||
createMockTeamBooking("booking-uid-123", { teamId: 100 });
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should handle SEAT_BOOKED action with seat information", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
id: "seat-booked-log",
|
||||
action: "SEAT_BOOKED",
|
||||
data: {
|
||||
@@ -825,8 +875,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -843,7 +892,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should handle SEAT_RESCHEDULED action with time changes", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
id: "seat-rescheduled-log",
|
||||
action: "SEAT_RESCHEDULED",
|
||||
data: {
|
||||
@@ -852,12 +901,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
seatReferenceUid: "seat-ref-123",
|
||||
attendeeEmail: "attendee@example.com",
|
||||
startTime: {
|
||||
old: "2024-01-15T10:00:00Z",
|
||||
new: "2024-01-16T10:00:00Z",
|
||||
old: new Date("2024-01-15T10:00:00Z").getTime(),
|
||||
new: new Date("2024-01-16T10:00:00Z").getTime(),
|
||||
},
|
||||
endTime: {
|
||||
old: "2024-01-15T11:00:00Z",
|
||||
new: "2024-01-16T11:00:00Z",
|
||||
old: new Date("2024-01-15T11:00:00Z").getTime(),
|
||||
new: new Date("2024-01-16T11:00:00Z").getTime(),
|
||||
},
|
||||
rescheduledToBookingUid: {
|
||||
old: null,
|
||||
@@ -867,8 +916,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -887,7 +935,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should handle SEAT_RESCHEDULED action without moving to different booking", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
id: "seat-rescheduled-same-booking",
|
||||
action: "SEAT_RESCHEDULED",
|
||||
data: {
|
||||
@@ -896,12 +944,12 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
seatReferenceUid: "seat-ref-123",
|
||||
attendeeEmail: "attendee@example.com",
|
||||
startTime: {
|
||||
old: "2024-01-15T10:00:00Z",
|
||||
new: "2024-01-15T11:00:00Z",
|
||||
old: new Date("2024-01-15T10:00:00Z").getTime(),
|
||||
new: new Date("2024-01-15T11:00:00Z").getTime(),
|
||||
},
|
||||
endTime: {
|
||||
old: "2024-01-15T11:00:00Z",
|
||||
new: "2024-01-15T12:00:00Z",
|
||||
old: new Date("2024-01-15T11:00:00Z").getTime(),
|
||||
new: new Date("2024-01-15T12:00:00Z").getTime(),
|
||||
},
|
||||
rescheduledToBookingUid: {
|
||||
old: null,
|
||||
@@ -911,8 +959,7 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -930,26 +977,22 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
|
||||
describe("when handling impersonatedBy context", () => {
|
||||
beforeEach(() => {
|
||||
mockBookingRepository.findByUidIncludeEventType.mockResolvedValue(
|
||||
createMockTeamBooking({ teamId: 100 })
|
||||
);
|
||||
createMockTeamBooking("booking-uid-123", { teamId: 100 });
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should enrich impersonatedBy when user exists", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
context: { impersonatedBy: "impersonator-uuid-456" },
|
||||
});
|
||||
const impersonatorUser = createMockUser({
|
||||
createMockUser("user-uuid-123");
|
||||
createMockUser("impersonator-uuid-456", {
|
||||
id: 456,
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
avatarUrl: "https://example.com/admin-avatar.jpg",
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor
|
||||
mockUserRepository.findByUuid.mockResolvedValueOnce(impersonatorUser); // For impersonator
|
||||
// Host lookup should return null - don't create host-uuid in DB
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -968,19 +1011,17 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should use email as displayName when impersonator name is null", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
context: { impersonatedBy: "impersonator-uuid-456" },
|
||||
});
|
||||
const impersonatorUser = createMockUser({
|
||||
createMockUser("user-uuid-123");
|
||||
createMockUser("impersonator-uuid-456", {
|
||||
id: 456,
|
||||
name: null,
|
||||
email: "admin@example.com",
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor
|
||||
mockUserRepository.findByUuid.mockResolvedValueOnce(impersonatorUser); // For impersonator
|
||||
// Host lookup should return null - don't create host-uuid in DB
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -998,13 +1039,11 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should show 'Deleted User' when impersonator user not found", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
context: { impersonatedBy: "impersonator-uuid-456" },
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValueOnce(createMockUser()); // For actor
|
||||
mockUserRepository.findByUuid.mockResolvedValueOnce(null); // For impersonator (not found)
|
||||
createMockUser("user-uuid-123");
|
||||
// Don't create impersonator-uuid-456 in DB - should return null
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -1023,12 +1062,11 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should return null when context is null", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
context: null,
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -1039,17 +1077,14 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
expect(result.auditLogs[0].impersonatedBy).toBeNull();
|
||||
// Should not call findByUuid for impersonator when context is null
|
||||
expect(mockUserRepository.findByUuid).toHaveBeenCalledTimes(1); // Only for actor
|
||||
});
|
||||
|
||||
it("should return null when impersonatedBy is not in context", async () => {
|
||||
const mockLog = createMockAuditLog({
|
||||
createMockAuditLog("booking-uid-123", {
|
||||
context: {},
|
||||
});
|
||||
|
||||
mockBookingAuditRepository.findAllForBooking.mockResolvedValue([mockLog]);
|
||||
mockUserRepository.findByUuid.mockResolvedValue(createMockUser());
|
||||
createMockUser("user-uuid-123");
|
||||
|
||||
const result = await service.getAuditLogsForBooking({
|
||||
bookingUid: "booking-uid-123",
|
||||
@@ -1060,8 +1095,6 @@ describe("BookingAuditViewerService - Integration Tests", () => {
|
||||
});
|
||||
|
||||
expect(result.auditLogs[0].impersonatedBy).toBeNull();
|
||||
// Should not call findByUuid for impersonator when impersonatedBy is not in context
|
||||
expect(mockUserRepository.findByUuid).toHaveBeenCalledTimes(1); // Only for actor
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+7
@@ -92,6 +92,7 @@ describe("Created Action Integration", () => {
|
||||
startTime: testData.booking.startTime.getTime(),
|
||||
endTime: testData.booking.endTime.getTime(),
|
||||
status: testData.booking.status,
|
||||
hostUserUuid: testData.owner.uuid,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -138,6 +139,7 @@ describe("Created Action Integration", () => {
|
||||
startTime: testData.booking.startTime.getTime(),
|
||||
endTime: testData.booking.endTime.getTime(),
|
||||
status: testData.booking.status,
|
||||
hostUserUuid: testData.owner.uuid,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -172,6 +174,7 @@ describe("Created Action Integration", () => {
|
||||
startTime: testData.booking.startTime.getTime(),
|
||||
endTime: testData.booking.endTime.getTime(),
|
||||
status: testData.booking.status,
|
||||
hostUserUuid: testData.owner.uuid,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
context: {
|
||||
@@ -211,6 +214,7 @@ describe("Created Action Integration", () => {
|
||||
startTime: testData.booking.startTime.getTime(),
|
||||
endTime: testData.booking.endTime.getTime(),
|
||||
status: testData.booking.status,
|
||||
hostUserUuid: testData.owner.uuid,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -275,6 +279,7 @@ describe("Created Action Integration", () => {
|
||||
startTime: testData.booking.startTime.getTime(),
|
||||
endTime: testData.booking.endTime.getTime(),
|
||||
status: testData.booking.status,
|
||||
hostUserUuid: testData.owner.uuid,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -283,6 +288,7 @@ describe("Created Action Integration", () => {
|
||||
startTime: booking2.startTime.getTime(),
|
||||
endTime: booking2.endTime.getTime(),
|
||||
status: booking2.status,
|
||||
hostUserUuid: testData.owner.uuid,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -291,6 +297,7 @@ describe("Created Action Integration", () => {
|
||||
startTime: booking3.startTime.getTime(),
|
||||
endTime: booking3.endTime.getTime(),
|
||||
status: booking3.status,
|
||||
hostUserUuid: testData.owner.uuid,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createModule, bindModuleToClassOnToken } from "@calcom/features/di/di";
|
||||
import { DI_TOKENS } from "@calcom/features/di/tokens";
|
||||
|
||||
import { moduleLoader as regularBookingServiceModuleLoader } from "./RegularBookingService.module";
|
||||
import { moduleLoader as bookingEventHandlerModuleLoader } from "./BookingEventHandlerService.module";
|
||||
|
||||
const token = DI_TOKENS.RECURRING_BOOKING_SERVICE;
|
||||
const moduleToken = DI_TOKENS.RECURRING_BOOKING_SERVICE_MODULE;
|
||||
@@ -15,6 +16,7 @@ const loadModule = bindModuleToClassOnToken({
|
||||
classs: RecurringBookingService,
|
||||
depsMap: {
|
||||
regularBookingService: regularBookingServiceModuleLoader,
|
||||
bookingEventHandler: bookingEventHandlerModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export type PlatformParams = {
|
||||
|
||||
export type CreateBookingMeta = {
|
||||
userId?: number;
|
||||
userUuid?: string;
|
||||
// These used to come from headers but now we're passing them as params
|
||||
hostname?: string;
|
||||
forcedSlug?: string;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
type BookingSelect = {
|
||||
description: true;
|
||||
@@ -24,7 +26,8 @@ function getResponsesFromOldBooking(
|
||||
) {
|
||||
const customInputs = rawBooking.customInputs || {};
|
||||
const responses = Object.keys(customInputs).reduce((acc, label) => {
|
||||
acc[slugify(label) as keyof typeof acc] = customInputs[label as keyof typeof customInputs];
|
||||
acc[slugify(label) as keyof typeof acc] =
|
||||
customInputs[label as keyof typeof customInputs];
|
||||
return acc;
|
||||
}, {});
|
||||
return {
|
||||
@@ -43,7 +46,11 @@ function getResponsesFromOldBooking(
|
||||
};
|
||||
}
|
||||
|
||||
async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boolean) {
|
||||
async function getBooking(
|
||||
prisma: PrismaClient,
|
||||
uid: string,
|
||||
isSeatedEvent?: boolean
|
||||
) {
|
||||
const rawBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
@@ -80,6 +87,7 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -94,8 +102,12 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo
|
||||
if (booking) {
|
||||
// @NOTE: had to do this because Server side cant return [Object objects]
|
||||
// probably fixable with json.stringify -> json.parse
|
||||
booking["startTime"] = (booking?.startTime as Date)?.toISOString() as unknown as Date;
|
||||
booking["endTime"] = (booking?.endTime as Date)?.toISOString() as unknown as Date;
|
||||
booking["startTime"] = (
|
||||
booking?.startTime as Date
|
||||
)?.toISOString() as unknown as Date;
|
||||
booking["endTime"] = (
|
||||
booking?.endTime as Date
|
||||
)?.toISOString() as unknown as Date;
|
||||
}
|
||||
|
||||
return booking;
|
||||
@@ -115,7 +127,9 @@ export const getBookingWithResponses = <
|
||||
) => {
|
||||
return {
|
||||
...booking,
|
||||
responses: isSeatedEvent ? booking.responses : booking.responses || getResponsesFromOldBooking(booking),
|
||||
responses: isSeatedEvent
|
||||
? booking.responses
|
||||
: booking.responses || getResponsesFromOldBooking(booking),
|
||||
} as Omit<T, "responses"> & { responses: Record<string, any> };
|
||||
};
|
||||
|
||||
@@ -130,9 +144,15 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
select: {
|
||||
seatsPerTimeSlot: true,
|
||||
teamId: true,
|
||||
hosts: {
|
||||
select: {
|
||||
userId: true,
|
||||
@@ -152,7 +172,10 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
// If no booking is found via the uid, it's probably a booking seat
|
||||
// that its being rescheduled, which we query next.
|
||||
let attendeeEmail: string | null = null;
|
||||
let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null;
|
||||
let bookingSeatData: {
|
||||
description?: string;
|
||||
responses: Prisma.JsonValue;
|
||||
} | null = null;
|
||||
if (!theBooking) {
|
||||
const bookingSeat = await prisma.bookingSeat.findFirst({
|
||||
where: {
|
||||
@@ -175,7 +198,10 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
},
|
||||
});
|
||||
if (bookingSeat) {
|
||||
bookingSeatData = bookingSeat.data as unknown as { description?: string; responses: Prisma.JsonValue };
|
||||
bookingSeatData = bookingSeat.data as unknown as {
|
||||
description?: string;
|
||||
responses: Prisma.JsonValue;
|
||||
};
|
||||
bookingSeatReferenceUid = bookingSeat.id;
|
||||
rescheduleUid = bookingSeat.booking.uid;
|
||||
attendeeEmail = bookingSeat.attendee.email;
|
||||
@@ -185,14 +211,37 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
// If we have the booking and not bookingSeat, we need to make sure the booking belongs to the userLoggedIn
|
||||
// Otherwise, we return null here.
|
||||
let hasOwnershipOnBooking = false;
|
||||
if (theBooking && theBooking?.eventType?.seatsPerTimeSlot && bookingSeatReferenceUid === null) {
|
||||
if (
|
||||
theBooking &&
|
||||
theBooking?.eventType?.seatsPerTimeSlot &&
|
||||
bookingSeatReferenceUid === null
|
||||
) {
|
||||
const isOwnerOfBooking = theBooking.userId === userId;
|
||||
|
||||
const isHostOfEventType = theBooking?.eventType?.hosts.some((host) => host.userId === userId);
|
||||
const isHostOfEventType = theBooking?.eventType?.hosts.some(
|
||||
(host) => host.userId === userId
|
||||
);
|
||||
|
||||
const isUserIdInBooking = theBooking.userId === userId;
|
||||
|
||||
if (!isOwnerOfBooking && !isHostOfEventType && !isUserIdInBooking) return null;
|
||||
let hasOrgAccess = false;
|
||||
if (userId && theBooking.user?.organizationId) {
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
hasOrgAccess = await permissionCheckService.checkPermission({
|
||||
userId,
|
||||
teamId: theBooking.user.organizationId,
|
||||
permission: "booking.readOrgBookings",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isOwnerOfBooking &&
|
||||
!isHostOfEventType &&
|
||||
!isUserIdInBooking &&
|
||||
!hasOrgAccess
|
||||
)
|
||||
return null;
|
||||
hasOwnershipOnBooking = true;
|
||||
}
|
||||
|
||||
@@ -200,13 +249,19 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
// and we return null here.
|
||||
if (!theBooking && !rescheduleUid) return null;
|
||||
|
||||
const booking = await getBooking(prisma, rescheduleUid || uid, bookingSeatReferenceUid ? true : false);
|
||||
const booking = await getBooking(
|
||||
prisma,
|
||||
rescheduleUid || uid,
|
||||
bookingSeatReferenceUid ? true : false
|
||||
);
|
||||
|
||||
if (!booking) return null;
|
||||
|
||||
if (bookingSeatReferenceUid) {
|
||||
booking["description"] = bookingSeatData?.description ?? null;
|
||||
booking["responses"] = bookingResponsesDbSchema.parse(bookingSeatData?.responses ?? {});
|
||||
booking["responses"] = bookingResponsesDbSchema.parse(
|
||||
bookingSeatData?.responses ?? {}
|
||||
);
|
||||
}
|
||||
return {
|
||||
...booking,
|
||||
@@ -244,6 +299,7 @@ export const getBookingForSeatedEvent = async (uid: string) => {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -293,6 +349,7 @@ export const getMultipleDurationValue = (
|
||||
defaultValue: number
|
||||
) => {
|
||||
if (!multipleDurationConfig) return null;
|
||||
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
|
||||
if (multipleDurationConfig.includes(Number(queryDuration)))
|
||||
return Number(queryDuration);
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
@@ -1536,6 +1536,126 @@ describe("excluded email/domain validation", () => {
|
||||
email: "blocked.com@allowed.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("should block full email match when exact email is excluded", async () => {
|
||||
const excludedEmails = "anik@cal.com";
|
||||
|
||||
const schema = getBookingResponsesSchema({
|
||||
bookingFields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "email",
|
||||
required: true,
|
||||
excludeEmails: excludedEmails,
|
||||
},
|
||||
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
|
||||
view: "ALL_VIEWS",
|
||||
});
|
||||
|
||||
// anik@cal.com should be blocked
|
||||
const parsedResponses = await schema.safeParseAsync({
|
||||
name: "test",
|
||||
email: "anik@cal.com",
|
||||
});
|
||||
|
||||
expect(parsedResponses.success).toBe(false);
|
||||
|
||||
if (parsedResponses.success) {
|
||||
throw new Error("Should not reach here");
|
||||
}
|
||||
|
||||
expect(parsedResponses.error.issues[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "custom",
|
||||
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should block emails with domain when domain starts with @", async () => {
|
||||
const excludedEmails = "@gmail.com";
|
||||
|
||||
const schema = getBookingResponsesSchema({
|
||||
bookingFields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "email",
|
||||
required: true,
|
||||
excludeEmails: excludedEmails,
|
||||
},
|
||||
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
|
||||
view: "ALL_VIEWS",
|
||||
});
|
||||
|
||||
// anik@gmail.com should be blocked when @gmail.com is excluded
|
||||
const parsedResponses = await schema.safeParseAsync({
|
||||
name: "test",
|
||||
email: "anik@gmail.com",
|
||||
});
|
||||
|
||||
expect(parsedResponses.success).toBe(false);
|
||||
|
||||
if (parsedResponses.success) {
|
||||
throw new Error("Should not reach here");
|
||||
}
|
||||
|
||||
expect(parsedResponses.error.issues[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "custom",
|
||||
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should block emails with domain when excluded email is just the domain without @", async () => {
|
||||
const excludedEmails = "gmail.com";
|
||||
|
||||
const schema = getBookingResponsesSchema({
|
||||
bookingFields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "email",
|
||||
required: true,
|
||||
excludeEmails: excludedEmails,
|
||||
},
|
||||
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
|
||||
view: "ALL_VIEWS",
|
||||
});
|
||||
|
||||
// test@gmail.com should be blocked when gmail.com is excluded
|
||||
const parsedResponses = await schema.safeParseAsync({
|
||||
name: "test",
|
||||
email: "test@gmail.com",
|
||||
});
|
||||
|
||||
expect(parsedResponses.success).toBe(false);
|
||||
|
||||
if (parsedResponses.success) {
|
||||
throw new Error("Should not reach here");
|
||||
}
|
||||
|
||||
expect(parsedResponses.error.issues[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "custom",
|
||||
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("require email/domain validation", () => {
|
||||
|
||||
@@ -24,6 +24,28 @@ const ensureValidPhoneNumber = (value: string) => {
|
||||
// Replace the space(s) in the beginning with + as it is supposed to be provided in the beginning only
|
||||
return value.replace(/^ +/, "+");
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a booker email matches an email/domain entry.
|
||||
* Supports three formats:
|
||||
* - Full email: "user@example.com" - matches exactly
|
||||
* - Domain with @ prefix: "@example.com" - matches any email ending with "@example.com"
|
||||
* - Domain without @ prefix: "example.com" - matches any email ending with "@example.com"
|
||||
*/
|
||||
const doesEmailMatchEntry = (bookerEmail: string, entry: string): boolean => {
|
||||
const bookerEmailLower = bookerEmail.toLowerCase();
|
||||
|
||||
if (entry.startsWith("@")) {
|
||||
const domain = entry.slice(1).toLowerCase();
|
||||
return bookerEmailLower.endsWith("@" + domain);
|
||||
}
|
||||
|
||||
if (entry.includes("@")) {
|
||||
return bookerEmailLower === entry.toLowerCase();
|
||||
}
|
||||
|
||||
return bookerEmailLower.endsWith("@" + entry.toLowerCase());
|
||||
};
|
||||
export const getBookingResponsesPartialSchema = ({ bookingFields, view, translateFn }: CommonParams) => {
|
||||
const schema = bookingResponses.unwrap().partial().and(catchAllSchema);
|
||||
|
||||
@@ -208,7 +230,7 @@ function preprocess<T extends z.ZodType>({
|
||||
const excludedEmails =
|
||||
bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || [];
|
||||
|
||||
const match = excludedEmails.find((email) => bookerEmail.endsWith("@" + email));
|
||||
const match = excludedEmails.find((excludedEntry) => doesEmailMatchEntry(bookerEmail, excludedEntry));
|
||||
if (match) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -220,7 +242,9 @@ function preprocess<T extends z.ZodType>({
|
||||
?.split(",")
|
||||
.map((domain) => domain.trim())
|
||||
.filter(Boolean) || [];
|
||||
const requiredEmailsMatch = requiredEmails.find((email) => bookerEmail.endsWith("@" + email));
|
||||
const requiredEmailsMatch = requiredEmails.find((requiredEntry) =>
|
||||
doesEmailMatchEntry(bookerEmail, requiredEntry)
|
||||
);
|
||||
if (requiredEmails.length > 0 && !requiredEmailsMatch) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export const buildBookingCreatedAuditData = ({
|
||||
booking,
|
||||
attendeeSeatId,
|
||||
}: {
|
||||
attendeeSeatId: string | null;
|
||||
booking: {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
status: BookingStatus;
|
||||
userUuid: string | null;
|
||||
};
|
||||
}) => {
|
||||
return {
|
||||
startTime: booking.startTime.getTime(),
|
||||
endTime: booking.endTime.getTime(),
|
||||
status: booking.status,
|
||||
hostUserUuid: booking.userUuid,
|
||||
seatReferenceUid: attendeeSeatId,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildBookingRescheduledAuditData = ({
|
||||
oldBooking,
|
||||
newBooking,
|
||||
}: {
|
||||
oldBooking: {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
};
|
||||
newBooking: {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
uid: string;
|
||||
};
|
||||
}) => {
|
||||
return {
|
||||
startTime: {
|
||||
old: oldBooking.startTime.getTime(),
|
||||
new: newBooking.startTime.getTime(),
|
||||
},
|
||||
endTime: {
|
||||
old: oldBooking.endTime.getTime(),
|
||||
new: newBooking.endTime.getTime(),
|
||||
},
|
||||
rescheduledToUid: {
|
||||
old: null,
|
||||
new: newBooking.uid,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -143,7 +143,7 @@ async function saveBooking(
|
||||
const createBookingObj = {
|
||||
include: {
|
||||
user: {
|
||||
select: { email: true, name: true, timeZone: true, username: true },
|
||||
select: { uuid: true, email: true, name: true, timeZone: true, username: true },
|
||||
},
|
||||
attendees: true,
|
||||
payment: true,
|
||||
@@ -182,7 +182,7 @@ async function saveBooking(
|
||||
await tx.app_RoutingForms_FormResponse.update(reroutingFormResponseUpdateData);
|
||||
}
|
||||
|
||||
return booking;
|
||||
return { ...booking, userUuid: booking.user?.uuid ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,8 +258,8 @@ function buildNewBookingData(params: CreateBookingParams) {
|
||||
destinationCalendar:
|
||||
evt.destinationCalendar && evt.destinationCalendar.length > 0
|
||||
? {
|
||||
connect: { id: evt.destinationCalendar[0].id },
|
||||
}
|
||||
connect: { id: evt.destinationCalendar[0].id },
|
||||
}
|
||||
: undefined,
|
||||
|
||||
routedFromRoutingFormReponse: routingFormResponseId
|
||||
|
||||
@@ -19,6 +19,7 @@ const _findBookingQuery = async (bookingId: number) => {
|
||||
metadata: true,
|
||||
user: {
|
||||
select: {
|
||||
uuid: true,
|
||||
name: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
|
||||
import { criticalLogger } from "@calcom/lib/logger.server";
|
||||
|
||||
export const getAuditActionSource = ({ creationSource, eventTypeId, rescheduleUid }: { creationSource: CreationSource | null | undefined, eventTypeId: number, rescheduleUid: string | null }): ActionSource => {
|
||||
if (creationSource === CreationSource.API_V1 || creationSource === CreationSource.API_V2 || creationSource === CreationSource.WEBAPP) {
|
||||
return creationSource;
|
||||
}
|
||||
// Unknown creationSource - log for tracking and fix
|
||||
criticalLogger.warn("Unknown booking creationSource detected", {
|
||||
eventTypeId,
|
||||
rescheduleUid,
|
||||
});
|
||||
return "UNKNOWN";
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
makeAttendeeActor,
|
||||
makeUserActor,
|
||||
makeGuestActor,
|
||||
} from "@calcom/features/booking-audit/lib/makeActor";
|
||||
import { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
/**
|
||||
* Used to create actor for new booking/reschedule booking scenarios
|
||||
*/
|
||||
export function getBookingAuditActorForNewBooking({
|
||||
bookerAttendeeId,
|
||||
actorUserUuid,
|
||||
rescheduledBy,
|
||||
bookerEmail,
|
||||
logger,
|
||||
bookerName,
|
||||
}: {
|
||||
bookerAttendeeId: number | null;
|
||||
actorUserUuid: string | null;
|
||||
bookerEmail: string;
|
||||
bookerName: string;
|
||||
logger: ISimpleLogger;
|
||||
rescheduledBy:
|
||||
| {
|
||||
attendeeId: number | null;
|
||||
email: string;
|
||||
}
|
||||
| {
|
||||
userUuid: string | null;
|
||||
email: string;
|
||||
}
|
||||
| null;
|
||||
}) {
|
||||
if (actorUserUuid) {
|
||||
return makeUserActor(actorUserUuid);
|
||||
}
|
||||
|
||||
if (rescheduledBy) {
|
||||
// If rescheduledBy is available we prefer that above considering booker as the actor
|
||||
if ("attendeeId" in rescheduledBy && rescheduledBy.attendeeId) {
|
||||
return makeAttendeeActor(rescheduledBy.attendeeId);
|
||||
}
|
||||
|
||||
if ("userUuid" in rescheduledBy && rescheduledBy.userUuid) {
|
||||
// We consider that a user actor did it without verifying the authorization, so whn taking the action we could record in the context that this action was taken through rescheduledBy contex, similar to how we would do impersonatedBy in context
|
||||
// as introduced in PR: https://github.com/calcom/cal.com/pull/26014
|
||||
return makeUserActor(rescheduledBy.userUuid);
|
||||
}
|
||||
|
||||
return makeGuestActor({ email: rescheduledBy.email, name: rescheduledBy.email });
|
||||
}
|
||||
|
||||
if (bookerAttendeeId) {
|
||||
return makeAttendeeActor(bookerAttendeeId);
|
||||
}
|
||||
|
||||
// Ideally we should have attendeeId atleast but for some unforeseen case we don't, we must create a guest actor.
|
||||
logger.warn(
|
||||
"Creating guest actor for booking audit",
|
||||
safeStringify({
|
||||
email: bookerEmail,
|
||||
})
|
||||
);
|
||||
|
||||
return makeGuestActor({ email: bookerEmail, name: bookerName });
|
||||
}
|
||||
@@ -7,11 +7,123 @@ import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { createLoggerWithEventDetails } from "../handleNewBooking/logger";
|
||||
import createNewSeat from "./create/createNewSeat";
|
||||
import rescheduleSeatedBooking from "./reschedule/rescheduleSeatedBooking";
|
||||
import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types";
|
||||
import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking";
|
||||
import type { BookingEventHandlerService } from "../onBookingEvents/BookingEventHandlerService";
|
||||
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
|
||||
import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service";
|
||||
|
||||
const fireBookingEvents = async ({
|
||||
previousSeatedBooking,
|
||||
newBooking,
|
||||
originalRescheduledBooking,
|
||||
rescheduleUid,
|
||||
organizationId,
|
||||
bookerEmail,
|
||||
bookerName,
|
||||
rescheduledBy,
|
||||
actionSource,
|
||||
actorUserUuid,
|
||||
deps,
|
||||
}: {
|
||||
previousSeatedBooking: SeatedBooking;
|
||||
newBooking: NonNullable<HandleSeatsResultBooking>;
|
||||
originalRescheduledBooking: NewSeatedBookingObject["originalRescheduledBooking"];
|
||||
rescheduleUid: string | undefined;
|
||||
organizationId: number | null | undefined;
|
||||
bookerEmail: string;
|
||||
bookerName: string;
|
||||
rescheduledBy: string | undefined;
|
||||
actionSource: ActionSource;
|
||||
actorUserUuid: string | null;
|
||||
deps: {
|
||||
bookingEventHandler: BookingEventHandlerService;
|
||||
logger: ISimpleLogger;
|
||||
};
|
||||
}) => {
|
||||
try {
|
||||
const bookerAttendeeId = newBooking.attendees?.find((attendee) => attendee.email === bookerEmail)?.id;
|
||||
const rescheduledByAttendeeId = newBooking.attendees?.find(
|
||||
(attendee) => attendee.email === rescheduledBy
|
||||
)?.id;
|
||||
const rescheduledByUserUuid = newBooking.user?.email === rescheduledBy ? newBooking.user?.uuid : null;
|
||||
|
||||
const auditActor = getBookingAuditActorForNewBooking({
|
||||
bookerAttendeeId: bookerAttendeeId ?? null,
|
||||
actorUserUuid,
|
||||
bookerEmail,
|
||||
bookerName,
|
||||
rescheduledBy: rescheduledBy
|
||||
? {
|
||||
attendeeId: rescheduledByAttendeeId ?? null,
|
||||
userUuid: rescheduledByUserUuid ?? null,
|
||||
email: rescheduledBy,
|
||||
}
|
||||
: null,
|
||||
logger: deps.logger,
|
||||
});
|
||||
|
||||
const seatReferenceUid = newBooking.seatReferenceUid;
|
||||
if (!seatReferenceUid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rescheduleUid && originalRescheduledBooking) {
|
||||
const movedToDifferentBooking = newBooking.uid && newBooking.uid !== previousSeatedBooking.uid;
|
||||
const newBookingStartTimeMs =
|
||||
movedToDifferentBooking && newBooking.startTime
|
||||
? newBooking.startTime.getTime()
|
||||
: previousSeatedBooking.startTime.getTime();
|
||||
const newBookingEndTimeMs =
|
||||
movedToDifferentBooking && newBooking.endTime
|
||||
? newBooking.endTime.getTime()
|
||||
: previousSeatedBooking.endTime.getTime();
|
||||
|
||||
await deps.bookingEventHandler.onSeatRescheduled({
|
||||
bookingUid: previousSeatedBooking.uid,
|
||||
actor: auditActor,
|
||||
organizationId: organizationId ?? null,
|
||||
auditData: {
|
||||
seatReferenceUid,
|
||||
attendeeEmail: bookerEmail,
|
||||
startTime: {
|
||||
old: originalRescheduledBooking.startTime.getTime(),
|
||||
new: newBookingStartTimeMs,
|
||||
},
|
||||
endTime: {
|
||||
old: originalRescheduledBooking.endTime.getTime(),
|
||||
new: newBookingEndTimeMs,
|
||||
},
|
||||
rescheduledToBookingUid: {
|
||||
old: null,
|
||||
new: movedToDifferentBooking ? newBooking.uid || null : null,
|
||||
},
|
||||
},
|
||||
source: actionSource,
|
||||
});
|
||||
} else {
|
||||
await deps.bookingEventHandler.onSeatBooked({
|
||||
bookingUid: previousSeatedBooking.uid,
|
||||
actor: auditActor,
|
||||
organizationId: organizationId ?? null,
|
||||
auditData: {
|
||||
seatReferenceUid,
|
||||
attendeeEmail: bookerEmail,
|
||||
attendeeName: bookerName,
|
||||
startTime: previousSeatedBooking.startTime.getTime(),
|
||||
endTime: previousSeatedBooking.endTime.getTime(),
|
||||
},
|
||||
source: actionSource,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logger.error("Error while firing booking events", safeStringify(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
const {
|
||||
@@ -23,6 +135,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
bookerEmail,
|
||||
smsReminderNumber,
|
||||
eventTypeInfo,
|
||||
reqUserUuid,
|
||||
uid,
|
||||
originalRescheduledBooking,
|
||||
reqBodyMetadata,
|
||||
@@ -34,7 +147,11 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
rescheduledBy,
|
||||
rescheduleReason,
|
||||
isDryRun = false,
|
||||
organizationId,
|
||||
fullName,
|
||||
traceContext,
|
||||
actionSource,
|
||||
deps: { bookingEventHandler },
|
||||
} = newSeatedBookingObject;
|
||||
// TODO: We could allow doing more things to support good dry run for seats
|
||||
if (isDryRun) return;
|
||||
@@ -104,6 +221,19 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
|
||||
// If the resultBooking is defined we should trigger workflows else, trigger in handleNewBooking
|
||||
if (resultBooking) {
|
||||
await fireBookingEvents({
|
||||
previousSeatedBooking: seatedBooking,
|
||||
newBooking: resultBooking,
|
||||
originalRescheduledBooking,
|
||||
rescheduleUid,
|
||||
organizationId,
|
||||
bookerEmail,
|
||||
bookerName: fullName,
|
||||
rescheduledBy,
|
||||
actionSource,
|
||||
actorUserUuid: reqUserUuid ?? null,
|
||||
deps: { bookingEventHandler, logger: loggerWithEventDetails },
|
||||
});
|
||||
const metadata = {
|
||||
...(typeof resultBooking.metadata === "object" && resultBooking.metadata),
|
||||
...reqBodyMetadata,
|
||||
|
||||
+22
-17
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
import { cloneDeep } from "lodash";
|
||||
|
||||
import { sendRescheduledEmailsAndSMS } from "@calcom/emails/email-manager";
|
||||
@@ -13,6 +13,26 @@ import { handleAppsStatus } from "../../../handleNewBooking/handleAppsStatus";
|
||||
import type { createLoggerWithEventDetails } from "../../../handleNewBooking/logger";
|
||||
import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types";
|
||||
|
||||
async function updateBooking({ bookingId, startTime, endTime, cancellationReason }: { bookingId: number, startTime: string, endTime: string, cancellationReason: string }): Promise<(Booking & { appsStatus?: AppsStatus[] })> {
|
||||
const booking = await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
data: {
|
||||
startTime,
|
||||
endTime,
|
||||
cancellationReason,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
references: true,
|
||||
payment: true,
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
return { ...booking, userUuid: booking.user?.uuid ?? null };
|
||||
}
|
||||
|
||||
const moveSeatedBookingToNewTimeSlot = async (
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking,
|
||||
@@ -31,22 +51,7 @@ const moveSeatedBookingToNewTimeSlot = async (
|
||||
} = rescheduleSeatedBookingObject;
|
||||
let { evt } = rescheduleSeatedBookingObject;
|
||||
|
||||
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
|
||||
where: {
|
||||
id: seatedBooking.id,
|
||||
},
|
||||
data: {
|
||||
startTime: evt.startTime,
|
||||
endTime: evt.endTime,
|
||||
cancellationReason: rescheduleReason,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
references: true,
|
||||
payment: true,
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
const newBooking = await updateBooking({ bookingId: seatedBooking.id, startTime: evt.startTime, endTime: evt.endTime, cancellationReason: rescheduleReason });
|
||||
|
||||
evt = { ...addVideoCallDataToEvent(newBooking.references, evt), bookerUrl: evt.bookerUrl };
|
||||
|
||||
|
||||
+14
-6
@@ -5,6 +5,8 @@ import type { TraceContext } from "@calcom/lib/tracing";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { AppsStatus, CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import type { BookingEventHandlerService } from "../../onBookingEvents/BookingEventHandlerService";
|
||||
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
|
||||
import type { Booking } from "../handleNewBooking/createBooking";
|
||||
import type { NewBookingEventType } from "../handleNewBooking/getEventTypesFromDB";
|
||||
import type { OriginalRescheduledBooking } from "../handleNewBooking/originalRescheduledBookingUtils";
|
||||
@@ -39,6 +41,7 @@ export type NewSeatedBookingObject = {
|
||||
tAttendees: TFunction;
|
||||
bookingSeat: BookingSeat;
|
||||
reqUserId: number | undefined;
|
||||
reqUserUuid?: string | null;
|
||||
rescheduleReason: RescheduleReason;
|
||||
reqBodyUser: string | string[] | undefined;
|
||||
noEmail: NoEmail;
|
||||
@@ -59,7 +62,12 @@ export type NewSeatedBookingObject = {
|
||||
rescheduledBy?: string;
|
||||
workflows: Workflow[];
|
||||
isDryRun?: boolean;
|
||||
organizationId?: number | null;
|
||||
actionSource: ActionSource;
|
||||
traceContext: TraceContext;
|
||||
deps: {
|
||||
bookingEventHandler: BookingEventHandlerService;
|
||||
}
|
||||
};
|
||||
|
||||
export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string };
|
||||
@@ -81,12 +89,12 @@ export type SeatedBooking = Prisma.BookingGetPayload<{
|
||||
|
||||
export type HandleSeatsResultBooking =
|
||||
| (Partial<Booking> & {
|
||||
appsStatus?: AppsStatus[];
|
||||
seatReferenceUid?: string;
|
||||
paymentUid?: string;
|
||||
message?: string;
|
||||
paymentId?: number;
|
||||
})
|
||||
appsStatus?: AppsStatus[];
|
||||
seatReferenceUid?: string;
|
||||
paymentUid?: string;
|
||||
message?: string;
|
||||
paymentId?: number;
|
||||
})
|
||||
| null;
|
||||
|
||||
export type NewTimeSlotBooking = Prisma.BookingGetPayload<{
|
||||
|
||||
+5
-4
@@ -61,9 +61,9 @@ const isWithinRRHostSubset = <T extends { isFixed: boolean; user: { id: number }
|
||||
rrHostSubsetEnabled,
|
||||
schedulingType,
|
||||
}: { rrHostSubsetEnabled: boolean; schedulingType?: SchedulingType } = {
|
||||
rrHostSubsetEnabled: false,
|
||||
schedulingType: undefined,
|
||||
}
|
||||
rrHostSubsetEnabled: false,
|
||||
schedulingType: undefined,
|
||||
}
|
||||
): host is T & { isFixed: false } => {
|
||||
if (rrHostSubsetIds.length === 0 || !rrHostSubsetEnabled || schedulingType !== SchedulingType.ROUND_ROBIN) {
|
||||
return true;
|
||||
@@ -72,12 +72,13 @@ const isWithinRRHostSubset = <T extends { isFixed: boolean; user: { id: number }
|
||||
};
|
||||
|
||||
export class QualifiedHostsService {
|
||||
constructor(public readonly dependencies: IQualifiedHostsService) {}
|
||||
constructor(public readonly dependencies: IQualifiedHostsService) { }
|
||||
|
||||
async _findQualifiedHostsWithDelegationCredentials<
|
||||
T extends {
|
||||
email: string;
|
||||
id: number;
|
||||
uuid: string;
|
||||
credentials: CredentialPayload[];
|
||||
userLevelSelectedCalendars: SelectedCalendar[];
|
||||
} & Record<string, unknown>
|
||||
|
||||
@@ -357,6 +357,52 @@ export class BookingEventHandlerService {
|
||||
});
|
||||
}
|
||||
|
||||
async onBulkBookingsCreated(params: {
|
||||
bookings: Array<{
|
||||
bookingUid: string;
|
||||
auditData: CreatedAuditData;
|
||||
}>;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
operationId?: string | null;
|
||||
source: ActionSource;
|
||||
}) {
|
||||
const { bookings, actor, organizationId, operationId, source } = params;
|
||||
await this.bookingAuditProducerService.queueBulkCreatedAudit({
|
||||
bookings: bookings.map((booking) => ({
|
||||
bookingUid: booking.bookingUid,
|
||||
data: booking.auditData,
|
||||
})),
|
||||
actor,
|
||||
organizationId,
|
||||
source,
|
||||
operationId,
|
||||
});
|
||||
}
|
||||
|
||||
async onBulkBookingsRescheduled(params: {
|
||||
bookings: Array<{
|
||||
bookingUid: string;
|
||||
auditData: RescheduledAuditData;
|
||||
}>;
|
||||
actor: Actor;
|
||||
organizationId: number | null;
|
||||
operationId?: string | null;
|
||||
source: ActionSource;
|
||||
}) {
|
||||
const { bookings, actor, organizationId, operationId, source } = params;
|
||||
await this.bookingAuditProducerService.queueBulkRescheduledAudit({
|
||||
bookings: bookings.map((booking) => ({
|
||||
bookingUid: booking.bookingUid,
|
||||
data: booking.auditData,
|
||||
})),
|
||||
actor,
|
||||
organizationId,
|
||||
source,
|
||||
operationId,
|
||||
});
|
||||
}
|
||||
|
||||
async onBulkBookingsRejected(params: {
|
||||
bookings: Array<{
|
||||
bookingUid: string;
|
||||
|
||||
@@ -158,6 +158,7 @@ describe("handleNewRecurringBooking", () => {
|
||||
bookingMeta: {
|
||||
userId: -1, // Simulating anonymous user like in the API test
|
||||
},
|
||||
creationSource: "WEBAPP",
|
||||
});
|
||||
|
||||
expect(createdBookings.length).toBe(numOfSlotsToBeBooked);
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import type { CreateBookingMeta, CreateRecurringBookingData } from "@calcom/features/bookings/lib/dto/types";
|
||||
import type { BookingResponse } from "@calcom/features/bookings/types";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { CreationSource, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { IBookingService } from "../interfaces/IBookingService";
|
||||
import type { RegularBookingService } from "./RegularBookingService";
|
||||
|
||||
import type { BookingEventHandlerService } from "../onBookingEvents/BookingEventHandlerService";
|
||||
import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking";
|
||||
import { criticalLogger } from "@calcom/lib/logger.server";
|
||||
import { getAuditActionSource } from "../handleNewBooking/getAuditActionSource";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import {
|
||||
buildBookingCreatedAuditData,
|
||||
buildBookingRescheduledAuditData,
|
||||
} from "../handleNewBooking/buildBookingEventAuditData";
|
||||
export type BookingHandlerInput = {
|
||||
bookingData: CreateRecurringBookingData;
|
||||
} & CreateBookingMeta;
|
||||
|
||||
export const handleNewRecurringBooking = async (
|
||||
input: BookingHandlerInput,
|
||||
deps: IRecurringBookingServiceDependencies
|
||||
): Promise<BookingResponse[]> => {
|
||||
export const handleNewRecurringBooking = async function (
|
||||
this: RecurringBookingService,
|
||||
{
|
||||
input,
|
||||
deps,
|
||||
creationSource,
|
||||
}: {
|
||||
input: BookingHandlerInput;
|
||||
deps: IRecurringBookingServiceDependencies;
|
||||
creationSource: CreationSource;
|
||||
}
|
||||
): Promise<BookingResponse[]> {
|
||||
const data = input.bookingData;
|
||||
const { regularBookingService } = deps;
|
||||
const createdBookings: BookingResponse[] = [];
|
||||
@@ -121,11 +138,24 @@ export const handleNewRecurringBooking = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (createdBookings.length > 0) {
|
||||
await this.fireBookingEvents({
|
||||
createdBookings,
|
||||
eventTypeId: firstBooking.eventTypeId,
|
||||
rescheduleUid: firstBooking.rescheduleUid ?? null,
|
||||
actorUserUuid: input.userUuid ?? null,
|
||||
rescheduledBy: firstBooking.rescheduledBy ?? null,
|
||||
creationSource,
|
||||
});
|
||||
}
|
||||
|
||||
return createdBookings;
|
||||
};
|
||||
|
||||
export interface IRecurringBookingServiceDependencies {
|
||||
regularBookingService: RegularBookingService;
|
||||
bookingEventHandler: BookingEventHandlerService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,19 +164,144 @@ export interface IRecurringBookingServiceDependencies {
|
||||
export class RecurringBookingService implements IBookingService {
|
||||
constructor(private readonly deps: IRecurringBookingServiceDependencies) {}
|
||||
|
||||
async fireBookingEvents({
|
||||
createdBookings,
|
||||
eventTypeId,
|
||||
rescheduleUid,
|
||||
actorUserUuid,
|
||||
rescheduledBy,
|
||||
creationSource,
|
||||
}: {
|
||||
createdBookings: BookingResponse[];
|
||||
eventTypeId: number;
|
||||
rescheduleUid: string | null;
|
||||
actorUserUuid: string | null;
|
||||
rescheduledBy: string | null;
|
||||
creationSource: CreationSource | undefined;
|
||||
}) {
|
||||
try {
|
||||
type ValidBooking = BookingResponse & {
|
||||
uid: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
status: BookingStatus;
|
||||
userUuid: string | null;
|
||||
};
|
||||
type ValidRescheduledBooking = ValidBooking & {
|
||||
previousBooking: ValidBooking & { status: BookingStatus };
|
||||
};
|
||||
|
||||
const isReschedule = !!rescheduleUid;
|
||||
const firstCreatedBooking = createdBookings[0];
|
||||
const eventOrganizationId = firstCreatedBooking.organizationId;
|
||||
const bookerAttendee = firstCreatedBooking.attendees?.[0];
|
||||
const bookerAttendeeId = bookerAttendee?.id;
|
||||
const bookerName = bookerAttendee?.name || "";
|
||||
const bookerEmail = bookerAttendee?.email || "";
|
||||
|
||||
const rescheduledByAttendeeId = firstCreatedBooking.attendees?.find(
|
||||
(attendee) => attendee.email === rescheduledBy
|
||||
)?.id;
|
||||
// TODO: Note that user.email is always null here as RegularBookingService intentionally sets it to null. To fix, we need to separate out external facing .createBooking and one that is used by RecurringBookingService, so that if we expose something there it doesn't get exposed elsewhere
|
||||
const rescheduledByUserUuid =
|
||||
firstCreatedBooking.user?.email === rescheduledBy ? firstCreatedBooking.userUuid : null;
|
||||
|
||||
const auditActor = getBookingAuditActorForNewBooking({
|
||||
bookerAttendeeId: bookerAttendeeId ?? null,
|
||||
actorUserUuid,
|
||||
bookerEmail,
|
||||
bookerName,
|
||||
rescheduledBy: rescheduledBy
|
||||
? {
|
||||
attendeeId: rescheduledByAttendeeId ?? null,
|
||||
userUuid: rescheduledByUserUuid ?? null,
|
||||
email: rescheduledBy,
|
||||
}
|
||||
: null,
|
||||
logger: criticalLogger,
|
||||
});
|
||||
|
||||
const actionSource = getAuditActionSource({ creationSource, eventTypeId, rescheduleUid });
|
||||
|
||||
const operationId = uuidv4();
|
||||
|
||||
const isValidBooking = (booking: BookingResponse): booking is ValidBooking => {
|
||||
return !!(booking.uid && booking.startTime && booking.endTime && booking.status);
|
||||
};
|
||||
|
||||
const isValidRescheduledBooking = (booking: BookingResponse): booking is ValidRescheduledBooking => {
|
||||
return !!(
|
||||
isValidBooking(booking) &&
|
||||
booking.previousBooking &&
|
||||
booking.previousBooking.uid &&
|
||||
booking.previousBooking.startTime &&
|
||||
booking.previousBooking.endTime
|
||||
);
|
||||
};
|
||||
|
||||
if (isReschedule) {
|
||||
const bulkRescheduledBookings = createdBookings.filter(isValidRescheduledBooking).map((booking) => ({
|
||||
bookingUid: booking.previousBooking.uid,
|
||||
auditData: buildBookingRescheduledAuditData({
|
||||
oldBooking: booking.previousBooking,
|
||||
newBooking: booking,
|
||||
}),
|
||||
}));
|
||||
|
||||
if (bulkRescheduledBookings.length > 0) {
|
||||
await this.deps.bookingEventHandler.onBulkBookingsRescheduled({
|
||||
bookings: bulkRescheduledBookings,
|
||||
actor: auditActor,
|
||||
organizationId: eventOrganizationId,
|
||||
operationId,
|
||||
source: actionSource,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For new bookings
|
||||
const bulkCreatedBookings = createdBookings.filter(isValidBooking).map((booking) => ({
|
||||
bookingUid: booking.uid,
|
||||
auditData: buildBookingCreatedAuditData({ booking, attendeeSeatId: null }),
|
||||
}));
|
||||
|
||||
if (bulkCreatedBookings.length > 0) {
|
||||
await this.deps.bookingEventHandler.onBulkBookingsCreated({
|
||||
bookings: bulkCreatedBookings,
|
||||
actor: auditActor,
|
||||
organizationId: eventOrganizationId,
|
||||
operationId,
|
||||
source: actionSource,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
criticalLogger.error("Error while firing booking events", safeStringify(error));
|
||||
}
|
||||
}
|
||||
|
||||
async createBooking(input: {
|
||||
bookingData: CreateRecurringBookingData;
|
||||
bookingMeta?: CreateBookingMeta;
|
||||
creationSource: CreationSource;
|
||||
}): Promise<BookingResponse[]> {
|
||||
const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) };
|
||||
return handleNewRecurringBooking(handlerInput, this.deps);
|
||||
return handleNewRecurringBooking.bind(this)({
|
||||
input: handlerInput,
|
||||
deps: this.deps,
|
||||
creationSource: input.creationSource,
|
||||
});
|
||||
}
|
||||
|
||||
async rescheduleBooking(input: {
|
||||
bookingData: CreateRecurringBookingData;
|
||||
bookingMeta?: CreateBookingMeta;
|
||||
creationSource: CreationSource;
|
||||
}): Promise<BookingResponse[]> {
|
||||
const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) };
|
||||
return handleNewRecurringBooking(handlerInput, this.deps);
|
||||
return handleNewRecurringBooking.bind(this)({
|
||||
input: handlerInput,
|
||||
deps: this.deps,
|
||||
creationSource: input.creationSource,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import short, { uuid } from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import { getAuditActionSource } from "../handleNewBooking/getAuditActionSource";
|
||||
import {
|
||||
buildBookingCreatedAuditData,
|
||||
buildBookingRescheduledAuditData,
|
||||
} from "../handleNewBooking/buildBookingEventAuditData";
|
||||
import type { ActionSource } from "@calcom/features/booking-audit/lib/types/actionSource";
|
||||
import processExternalId from "@calcom/app-store/_utils/calendars/processExternalId";
|
||||
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
|
||||
import {
|
||||
@@ -127,8 +132,8 @@ import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validat
|
||||
import { validateEventLength } from "../handleNewBooking/validateEventLength";
|
||||
import handleSeats from "../handleSeats/handleSeats";
|
||||
import type { IBookingService } from "../interfaces/IBookingService";
|
||||
import { getBookingAuditActorForNewBooking } from "../handleNewBooking/getBookingAuditActorForNewBooking";
|
||||
import { isWithinMinimumRescheduleNotice } from "../reschedule/isWithinMinimumRescheduleNotice";
|
||||
import { makeGuestActor } from "@calcom/features/booking-audit/lib/makeActor";
|
||||
|
||||
const translator = short();
|
||||
|
||||
@@ -175,6 +180,7 @@ export const buildDryRunBooking = ({
|
||||
eventTypeId: number;
|
||||
organizerUser: {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string | null;
|
||||
username: string | null;
|
||||
email: string;
|
||||
@@ -190,6 +196,7 @@ export const buildDryRunBooking = ({
|
||||
}) => {
|
||||
const sanitizedOrganizerUser = {
|
||||
id: organizerUser.id,
|
||||
uuid: organizerUser.uuid,
|
||||
name: organizerUser.name,
|
||||
username: organizerUser.username,
|
||||
email: organizerUser.email,
|
||||
@@ -203,6 +210,7 @@ export const buildDryRunBooking = ({
|
||||
eventTypeId: eventTypeId,
|
||||
user: sanitizedOrganizerUser,
|
||||
userId: sanitizedOrganizerUser.id,
|
||||
userUuid: sanitizedOrganizerUser.uuid,
|
||||
title: eventName,
|
||||
startTime: new Date(startTime),
|
||||
endTime: new Date(endTime),
|
||||
@@ -418,6 +426,7 @@ function formatAvailabilitySnapshot(data: {
|
||||
function buildBookingCreatedPayload({
|
||||
booking,
|
||||
organizerUserId,
|
||||
organizerUserUuid,
|
||||
hashedLink,
|
||||
isDryRun,
|
||||
organizationId,
|
||||
@@ -431,6 +440,7 @@ function buildBookingCreatedPayload({
|
||||
userId: number | null;
|
||||
};
|
||||
organizerUserId: number;
|
||||
organizerUserUuid: string | null;
|
||||
hashedLink: string | null;
|
||||
isDryRun: boolean;
|
||||
organizationId: number | null;
|
||||
@@ -449,6 +459,7 @@ function buildBookingCreatedPayload({
|
||||
endTime: booking.endTime,
|
||||
status: booking.status,
|
||||
userId: booking.userId,
|
||||
userUuid: organizerUserUuid,
|
||||
user: {
|
||||
id: organizerUserId,
|
||||
},
|
||||
@@ -560,6 +571,7 @@ async function getEventOrganizationId({
|
||||
}
|
||||
|
||||
async function handler(
|
||||
this: RegularBookingService,
|
||||
input: BookingHandlerInput,
|
||||
deps: IBookingServiceDependencies,
|
||||
bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema
|
||||
@@ -567,6 +579,7 @@ async function handler(
|
||||
const {
|
||||
bookingData: rawBookingData,
|
||||
userId,
|
||||
userUuid,
|
||||
platformClientId,
|
||||
platformCancelUrl,
|
||||
platformBookingUrl,
|
||||
@@ -605,9 +618,9 @@ async function handler(
|
||||
userId: userId ?? null,
|
||||
eventType: eventType
|
||||
? {
|
||||
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
|
||||
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
|
||||
}
|
||||
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
|
||||
minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
@@ -788,6 +801,14 @@ async function handler(
|
||||
...(isDryRun ? { troubleshooterData } : {}),
|
||||
paymentUid: firstPayment?.uid,
|
||||
paymentId: firstPayment?.id,
|
||||
organizationId: eventOrganizationId,
|
||||
previousBooking: originalRescheduledBooking
|
||||
? {
|
||||
uid: originalRescheduledBooking.uid,
|
||||
startTime: originalRescheduledBooking.startTime,
|
||||
endTime: originalRescheduledBooking.endTime,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1259,7 +1280,7 @@ async function handler(
|
||||
|
||||
// If the team member is requested then they should be the organizer
|
||||
const organizerUser = reqBody.teamMemberEmail
|
||||
? users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0]
|
||||
? (users.find((user) => user.email === reqBody.teamMemberEmail) ?? users[0])
|
||||
: users[0];
|
||||
|
||||
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
|
||||
@@ -1377,9 +1398,9 @@ async function handler(
|
||||
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
|
||||
const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
|
||||
? {
|
||||
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
|
||||
conferenceCredentialId: undefined,
|
||||
}
|
||||
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
|
||||
conferenceCredentialId: undefined,
|
||||
}
|
||||
: getLocationValueForDB(locationBodyString, eventType.locations);
|
||||
|
||||
tracingLogger.info("locationBodyString", locationBodyString);
|
||||
@@ -1502,7 +1523,7 @@ async function handler(
|
||||
platformBookingUrl,
|
||||
})
|
||||
.withOrganization(organizerOrganizationId)
|
||||
.withHashedLink(hasHashedBookingLink ? reqBody.hashedLink ?? null : null)
|
||||
.withHashedLink(hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null)
|
||||
.build();
|
||||
|
||||
if (!builtEvt) {
|
||||
@@ -1627,6 +1648,7 @@ async function handler(
|
||||
email: null,
|
||||
},
|
||||
userId: null,
|
||||
userUuid: null,
|
||||
title: eventName,
|
||||
startTime: new Date(reqBody.start),
|
||||
endTime: new Date(reqBody.end),
|
||||
@@ -1681,10 +1703,24 @@ async function handler(
|
||||
luckyUsers: [],
|
||||
paymentId: undefined,
|
||||
seatReferenceUid: undefined,
|
||||
isShortCircuitedBooking: true, // Renamed from isSpamDecoy to avoid exposing spam detection to blocked users
|
||||
isShortCircuitedBooking: true,
|
||||
organizationId: eventOrganizationId,
|
||||
previousBooking: originalRescheduledBooking
|
||||
? {
|
||||
uid: originalRescheduledBooking.uid,
|
||||
startTime: originalRescheduledBooking.startTime,
|
||||
endTime: originalRescheduledBooking.endTime,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const actionSource = getAuditActionSource({
|
||||
creationSource: input.bookingData.creationSource,
|
||||
eventTypeId,
|
||||
rescheduleUid: originalRescheduledBooking?.uid ?? null,
|
||||
});
|
||||
|
||||
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
const newBooking = await handleSeats({
|
||||
@@ -1701,6 +1737,7 @@ async function handler(
|
||||
tAttendees,
|
||||
bookingSeat,
|
||||
reqUserId: input.userId,
|
||||
reqUserUuid: userUuid,
|
||||
rescheduleReason,
|
||||
reqBodyUser: reqBody.user,
|
||||
noEmail,
|
||||
@@ -1721,7 +1758,10 @@ async function handler(
|
||||
workflows,
|
||||
rescheduledBy: reqBody.rescheduledBy,
|
||||
isDryRun,
|
||||
organizationId: eventOrganizationId,
|
||||
actionSource,
|
||||
traceContext,
|
||||
deps,
|
||||
});
|
||||
|
||||
if (newBooking) {
|
||||
@@ -1738,6 +1778,14 @@ async function handler(
|
||||
return {
|
||||
...bookingResponse,
|
||||
...luckyUserResponse,
|
||||
organizationId: eventOrganizationId,
|
||||
previousBooking: originalRescheduledBooking
|
||||
? {
|
||||
uid: originalRescheduledBooking.uid,
|
||||
startTime: originalRescheduledBooking.startTime,
|
||||
endTime: originalRescheduledBooking.endTime,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
} else {
|
||||
// Rescheduling logic for the original seated event was handled in handleSeats
|
||||
@@ -2046,20 +2094,21 @@ async function handler(
|
||||
});
|
||||
}
|
||||
}
|
||||
const updateManager = !skipCalendarSyncTaskCreation
|
||||
? await eventManager.reschedule(
|
||||
evt,
|
||||
originalRescheduledBooking.uid,
|
||||
undefined,
|
||||
changedOrganizer,
|
||||
previousHostDestinationCalendar,
|
||||
isBookingRequestedReschedule,
|
||||
skipDeleteEventsAndMeetings
|
||||
)
|
||||
: placeholderCreatedEvent;
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description ?? evt.description;
|
||||
evt.description = eventType.description;
|
||||
|
||||
const updateManager = !skipCalendarSyncTaskCreation
|
||||
? await eventManager.reschedule(
|
||||
evt,
|
||||
originalRescheduledBooking.uid,
|
||||
undefined,
|
||||
changedOrganizer,
|
||||
previousHostDestinationCalendar,
|
||||
isBookingRequestedReschedule,
|
||||
skipDeleteEventsAndMeetings
|
||||
)
|
||||
: placeholderCreatedEvent;
|
||||
|
||||
results = updateManager.results;
|
||||
referencesToCreate = updateManager.referencesToCreate;
|
||||
@@ -2119,7 +2168,7 @@ async function handler(
|
||||
|
||||
const googleHangoutLink = Array.isArray(googleCalResult?.updatedEvent)
|
||||
? googleCalResult.updatedEvent[0]?.hangoutLink
|
||||
: googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink;
|
||||
: (googleCalResult?.updatedEvent?.hangoutLink ?? googleCalResult?.createdEvent?.hangoutLink);
|
||||
|
||||
if (googleHangoutLink) {
|
||||
results.push({
|
||||
@@ -2149,7 +2198,7 @@ async function handler(
|
||||
}
|
||||
const createdOrUpdatedEvent = Array.isArray(results[0]?.updatedEvent)
|
||||
? results[0]?.updatedEvent[0]
|
||||
: results[0]?.updatedEvent ?? results[0]?.createdEvent;
|
||||
: (results[0]?.updatedEvent ?? results[0]?.createdEvent);
|
||||
metadata.hangoutLink = createdOrUpdatedEvent?.hangoutLink;
|
||||
metadata.conferenceData = createdOrUpdatedEvent?.conferenceData;
|
||||
metadata.entryPoints = createdOrUpdatedEvent?.entryPoints;
|
||||
@@ -2356,68 +2405,31 @@ async function handler(
|
||||
|
||||
const metadata = videoCallUrl
|
||||
? {
|
||||
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
|
||||
}
|
||||
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const bookingCreatedPayload = buildBookingCreatedPayload({
|
||||
booking,
|
||||
organizerUserId: organizerUser.id,
|
||||
await this.fireBookingEvents({
|
||||
booking: {
|
||||
...booking,
|
||||
userEmail: booking.user?.email ?? null,
|
||||
},
|
||||
organizerUser,
|
||||
// FIXME: It looks like hasHashedBookingLink is set to true based on the value of hashedLink when sending the request. So, technically we could remove hasHashedBookingLink usage completely
|
||||
hashedLink: hasHashedBookingLink ? reqBody.hashedLink ?? null : null,
|
||||
hashedLink: hasHashedBookingLink ? (reqBody.hashedLink ?? null) : null,
|
||||
isDryRun,
|
||||
organizationId: eventOrganizationId,
|
||||
eventOrganizationId,
|
||||
bookerEmail,
|
||||
bookerName: fullName,
|
||||
actorUserUuid: userUuid ?? null,
|
||||
originalRescheduledBooking,
|
||||
rescheduledBy: reqBody.rescheduledBy ?? null,
|
||||
actionSource,
|
||||
isRecurringBooking: !!input.bookingData.allRecurringDates,
|
||||
attendeeSeatId: evt.attendeeSeatId ?? null,
|
||||
tracingLogger,
|
||||
});
|
||||
|
||||
const bookingEventHandler = deps.bookingEventHandler;
|
||||
// TODO: Identify action source correctly
|
||||
const actionSource = 'WEBAPP';
|
||||
// TODO: We need to check session in booking flow and accordingly create USER actor if applicable.
|
||||
const auditActor = makeGuestActor({ email: bookerEmail, name: fullName });
|
||||
|
||||
if (originalRescheduledBooking) {
|
||||
const bookingRescheduledPayload: BookingRescheduledPayload = {
|
||||
...bookingCreatedPayload,
|
||||
oldBooking: {
|
||||
uid: originalRescheduledBooking.uid,
|
||||
startTime: originalRescheduledBooking.startTime,
|
||||
endTime: originalRescheduledBooking.endTime,
|
||||
},
|
||||
};
|
||||
await bookingEventHandler.onBookingRescheduled({
|
||||
payload: bookingRescheduledPayload,
|
||||
actor: auditActor,
|
||||
auditData: {
|
||||
startTime: {
|
||||
old: bookingRescheduledPayload.oldBooking?.startTime.toISOString() ?? null,
|
||||
new: bookingRescheduledPayload.booking.startTime.toISOString(),
|
||||
},
|
||||
endTime: {
|
||||
old: bookingRescheduledPayload.oldBooking?.endTime.toISOString() ?? null,
|
||||
new: bookingRescheduledPayload.booking.endTime.toISOString(),
|
||||
},
|
||||
rescheduledToUid: {
|
||||
old: null,
|
||||
new: bookingRescheduledPayload.booking.uid,
|
||||
},
|
||||
},
|
||||
source: actionSource,
|
||||
operationId: null,
|
||||
});
|
||||
} else {
|
||||
await bookingEventHandler.onBookingCreated({
|
||||
payload: bookingCreatedPayload,
|
||||
actor: auditActor,
|
||||
auditData: {
|
||||
startTime: bookingCreatedPayload.booking.startTime.getTime(),
|
||||
endTime: bookingCreatedPayload.booking.endTime.getTime(),
|
||||
status: bookingCreatedPayload.booking.status,
|
||||
},
|
||||
source: actionSource,
|
||||
operationId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const webhookData: EventPayloadType = {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
@@ -2480,9 +2492,9 @@ async function handler(
|
||||
...eventType,
|
||||
metadata: eventType.metadata
|
||||
? {
|
||||
...eventType.metadata,
|
||||
apps: eventType.metadata?.apps as Prisma.JsonValue,
|
||||
}
|
||||
...eventType.metadata,
|
||||
apps: eventType.metadata?.apps as Prisma.JsonValue,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
@@ -2569,6 +2581,14 @@ async function handler(
|
||||
paymentId: payment?.id,
|
||||
isDryRun,
|
||||
...(isDryRun ? { troubleshooterData } : {}),
|
||||
organizationId: eventOrganizationId,
|
||||
previousBooking: originalRescheduledBooking
|
||||
? {
|
||||
uid: originalRescheduledBooking.uid,
|
||||
startTime: originalRescheduledBooking.startTime,
|
||||
endTime: originalRescheduledBooking.endTime,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2811,6 +2831,14 @@ async function handler(
|
||||
references: referencesToCreate,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
videoCallUrl: metadata?.videoCallUrl,
|
||||
organizationId: eventOrganizationId,
|
||||
previousBooking: originalRescheduledBooking
|
||||
? {
|
||||
uid: originalRescheduledBooking.uid,
|
||||
startTime: originalRescheduledBooking.startTime,
|
||||
endTime: originalRescheduledBooking.endTime,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2820,14 +2848,122 @@ async function handler(
|
||||
* We are open to renaming it to something more descriptive.
|
||||
*/
|
||||
export class RegularBookingService implements IBookingService {
|
||||
constructor(private readonly deps: IBookingServiceDependencies) { }
|
||||
constructor(private readonly deps: IBookingServiceDependencies) {}
|
||||
|
||||
async fireBookingEvents({
|
||||
booking,
|
||||
organizerUser,
|
||||
hashedLink,
|
||||
isDryRun,
|
||||
eventOrganizationId,
|
||||
bookerEmail,
|
||||
bookerName,
|
||||
actorUserUuid,
|
||||
originalRescheduledBooking,
|
||||
rescheduledBy,
|
||||
actionSource,
|
||||
isRecurringBooking,
|
||||
attendeeSeatId,
|
||||
tracingLogger,
|
||||
}: {
|
||||
booking: {
|
||||
id: number;
|
||||
uid: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
status: BookingStatus;
|
||||
userId: number | null;
|
||||
attendees?: Array<{ id: number; email: string }>;
|
||||
userUuid: string | null;
|
||||
userEmail: string | null;
|
||||
};
|
||||
organizerUser: { id: number; uuid: string };
|
||||
hashedLink: string | null;
|
||||
isDryRun: boolean;
|
||||
eventOrganizationId: number | null;
|
||||
bookerEmail: string;
|
||||
bookerName: string;
|
||||
rescheduledBy: string | null;
|
||||
actorUserUuid: string | null;
|
||||
originalRescheduledBooking: BookingType | null;
|
||||
actionSource: ActionSource;
|
||||
isRecurringBooking: boolean;
|
||||
tracingLogger: ReturnType<typeof distributedTracing.getTracingLogger>;
|
||||
attendeeSeatId: string | null;
|
||||
}) {
|
||||
try {
|
||||
const bookingCreatedPayload = buildBookingCreatedPayload({
|
||||
booking,
|
||||
organizerUserId: organizerUser.id,
|
||||
organizerUserUuid: organizerUser.uuid,
|
||||
hashedLink,
|
||||
isDryRun,
|
||||
organizationId: eventOrganizationId,
|
||||
});
|
||||
|
||||
const bookingEventHandler = this.deps.bookingEventHandler;
|
||||
const bookerAttendeeId = booking.attendees?.find((attendee) => attendee.email === bookerEmail)?.id;
|
||||
const rescheduledByAttendeeId = booking.attendees?.find(
|
||||
(attendee) => attendee.email === rescheduledBy
|
||||
)?.id;
|
||||
const rescheduledByUserUuid = booking.userEmail === rescheduledBy ? booking.userUuid : null;
|
||||
const auditActor = getBookingAuditActorForNewBooking({
|
||||
bookerAttendeeId: bookerAttendeeId ?? null,
|
||||
actorUserUuid,
|
||||
bookerEmail,
|
||||
bookerName,
|
||||
rescheduledBy: rescheduledBy
|
||||
? {
|
||||
attendeeId: rescheduledByAttendeeId ?? null,
|
||||
userUuid: rescheduledByUserUuid ?? null,
|
||||
email: rescheduledBy,
|
||||
}
|
||||
: null,
|
||||
logger: tracingLogger,
|
||||
});
|
||||
|
||||
// For recurring bookings we fire the events in the RecurringBookingService
|
||||
if (!isRecurringBooking) {
|
||||
if (originalRescheduledBooking) {
|
||||
const bookingRescheduledPayload: BookingRescheduledPayload = {
|
||||
...bookingCreatedPayload,
|
||||
oldBooking: {
|
||||
uid: originalRescheduledBooking.uid,
|
||||
startTime: originalRescheduledBooking.startTime,
|
||||
endTime: originalRescheduledBooking.endTime,
|
||||
},
|
||||
};
|
||||
await bookingEventHandler.onBookingRescheduled({
|
||||
payload: bookingRescheduledPayload,
|
||||
actor: auditActor,
|
||||
auditData: buildBookingRescheduledAuditData({
|
||||
oldBooking: originalRescheduledBooking,
|
||||
newBooking: booking,
|
||||
}),
|
||||
source: actionSource,
|
||||
operationId: null,
|
||||
});
|
||||
} else {
|
||||
await bookingEventHandler.onBookingCreated({
|
||||
payload: bookingCreatedPayload,
|
||||
actor: auditActor,
|
||||
auditData: buildBookingCreatedAuditData({ booking, attendeeSeatId }),
|
||||
source: actionSource,
|
||||
operationId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
tracingLogger.error("Error while firing booking events", safeStringify(error));
|
||||
}
|
||||
}
|
||||
|
||||
async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
|
||||
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
|
||||
return handler.bind(this)({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
|
||||
}
|
||||
|
||||
async rescheduleBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
|
||||
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
|
||||
return handler.bind(this)({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2839,7 +2975,7 @@ export class RegularBookingService implements IBookingService {
|
||||
bookingDataSchemaGetter: BookingDataSchemaGetter;
|
||||
}) {
|
||||
const bookingMeta = input.bookingMeta ?? {};
|
||||
return handler(
|
||||
return handler.bind(this)(
|
||||
{
|
||||
bookingData: input.bookingData,
|
||||
...bookingMeta,
|
||||
|
||||
@@ -388,6 +388,11 @@ export const updateEvent = async (
|
||||
externalCalendarId: string | null
|
||||
): Promise<EventResult<NewCalendarEventType>> => {
|
||||
const formattedEvent = formatCalEvent(rawCalEvent);
|
||||
|
||||
if (formattedEvent.hideCalendarNotes) {
|
||||
formattedEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string?
|
||||
}
|
||||
|
||||
const calEvent = processEvent(formattedEvent);
|
||||
const uid = getUid(calEvent);
|
||||
const calendar = await getCalendar(credential, "booking");
|
||||
@@ -500,7 +505,7 @@ export const deleteEvent = async ({
|
||||
* Process the calendar event by generating description and removing attendees if needed
|
||||
*/
|
||||
const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => {
|
||||
if (calEvent.seatsPerTimeSlot){
|
||||
if (calEvent.seatsPerTimeSlot) {
|
||||
calEvent.responses = null;
|
||||
calEvent.userFieldsResponses = null;
|
||||
calEvent.additionalNotes = null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import classNames from "@calcom/ui/classNames";
|
||||
const CUSTOM_PHONE_MASKS = {
|
||||
ci: ".. .. .. .. ..",
|
||||
bj: ".. .. .. .. ..",
|
||||
at: "... ..........",
|
||||
};
|
||||
|
||||
export type PhoneInputProps = {
|
||||
@@ -80,7 +81,7 @@ function BasePhoneInput({
|
||||
autoComplete: "tel",
|
||||
}}
|
||||
onChange={(val: string) => {
|
||||
onChange(`+${val}`);
|
||||
onChange(val.startsWith("+") ? val : `+${val}`);
|
||||
}}
|
||||
containerClass={classNames(
|
||||
"hover:border-emphasis focus-within:border-emphasis border-default !bg-default rounded-md border focus-within:outline-none focus-within:ring-0 focus-within:ring-brand-default disabled:cursor-not-allowed",
|
||||
@@ -132,7 +133,7 @@ function BasePhoneInputWeb({
|
||||
autoComplete: "tel",
|
||||
}}
|
||||
onChange={(val: string) => {
|
||||
onChange(`+${val}`);
|
||||
onChange(val.startsWith("+") ? val : `+${val}`);
|
||||
}}
|
||||
containerClass={classNames(
|
||||
"hover:border-emphasis focus-within:border-emphasis border-default !bg-default rounded-md border focus-within:outline-none focus-within:ring-0 focus-within:ring-brand-default disabled:cursor-not-allowed",
|
||||
|
||||
@@ -145,6 +145,29 @@ export function getTemplateBodyForAction({
|
||||
return templateFunction({ isEditingMode: true, locale, t, action, timeFormat }).emailBody;
|
||||
}
|
||||
|
||||
export function getTemplateSubjectForAction({
|
||||
action,
|
||||
locale,
|
||||
t,
|
||||
template,
|
||||
timeFormat,
|
||||
}: {
|
||||
action: WorkflowActions;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
template: WorkflowTemplates;
|
||||
timeFormat: TimeFormat;
|
||||
}): string | null {
|
||||
// SMS and WhatsApp don't have subjects
|
||||
if (isSMSAction(action) || isWhatsappAction(action)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For email actions, get the subject from the template
|
||||
const templateFunction = getEmailTemplateFunction(template);
|
||||
return templateFunction({ isEditingMode: true, locale, t, action, timeFormat }).emailSubject;
|
||||
}
|
||||
|
||||
export function isFormTrigger(trigger: WorkflowTriggerEvents) {
|
||||
return FORM_TRIGGER_WORKFLOW_EVENTS.includes(trigger);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { WorkflowTemplates } from "@calcom/prisma/enums";
|
||||
|
||||
import compareReminderBodyToTemplate from "./compareReminderBodyToTemplate";
|
||||
|
||||
export type DefaultTemplates = {
|
||||
reminder: { body: string | null; subject: string | null };
|
||||
rating: { body: string | null; subject: string | null };
|
||||
};
|
||||
|
||||
export type DetectMatchedTemplateParams = {
|
||||
emailBody: string;
|
||||
emailSubject: string;
|
||||
template?: WorkflowTemplates;
|
||||
defaultTemplates: DefaultTemplates;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects if the email body and subject match a default template (REMINDER or RATING).
|
||||
*
|
||||
* Logic:
|
||||
* 1. If emailBody is empty but template is specified as REMINDER or RATING, return that template
|
||||
* 2. If emailBody exists, check if both body AND subject match the REMINDER default template
|
||||
* 3. If not matched, check if both body AND subject match the RATING default template
|
||||
* 4. Return null if no match (indicating custom content that should be preserved)
|
||||
*
|
||||
* This is used to determine whether to regenerate the template with the recipient's locale
|
||||
* for proper translation, or to preserve user customizations.
|
||||
*/
|
||||
export function detectMatchedTemplate({
|
||||
emailBody,
|
||||
emailSubject,
|
||||
template,
|
||||
defaultTemplates,
|
||||
}: DetectMatchedTemplateParams): WorkflowTemplates | null {
|
||||
if (!emailBody && template === WorkflowTemplates.REMINDER) {
|
||||
return WorkflowTemplates.REMINDER;
|
||||
}
|
||||
|
||||
if (!emailBody && template === WorkflowTemplates.RATING) {
|
||||
return WorkflowTemplates.RATING;
|
||||
}
|
||||
|
||||
if (emailBody) {
|
||||
const { reminder, rating } = defaultTemplates;
|
||||
|
||||
const bodyMatchesReminder =
|
||||
reminder.body && compareReminderBodyToTemplate({ reminderBody: emailBody, template: reminder.body });
|
||||
const subjectMatchesReminder = reminder.subject && emailSubject === reminder.subject;
|
||||
|
||||
if (bodyMatchesReminder && subjectMatchesReminder) {
|
||||
return WorkflowTemplates.REMINDER;
|
||||
}
|
||||
|
||||
const bodyMatchesRating =
|
||||
rating.body && compareReminderBodyToTemplate({ reminderBody: emailBody, template: rating.body });
|
||||
const subjectMatchesRating = rating.subject && emailSubject === rating.subject;
|
||||
|
||||
if (bodyMatchesRating && subjectMatchesRating) {
|
||||
return WorkflowTemplates.RATING;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -71,9 +71,9 @@ const emailReminderTemplate = ({
|
||||
"date_and_time"
|
||||
)}: </strong></div>${eventDate} - ${endTime} (${timeZone})<br><br>`;
|
||||
|
||||
const attendeeHtml = `<div><strong class="editor-text-bold">${t("attendees")}: </strong></div>${t(
|
||||
"you_and_conjunction"
|
||||
)} ${otherPerson}<br><br>`;
|
||||
const attendeeHtml = `<div><strong class="editor-text-bold">${t(
|
||||
"attendees"
|
||||
)}: </strong></div>${t("you_and_conjunction")} ${otherPerson}<br><br>`;
|
||||
|
||||
const locationHtml = `<div><strong class="editor-text-bold">${t(
|
||||
"location"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito
|
||||
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||
import { SENDER_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums";
|
||||
@@ -20,7 +21,8 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import type { WorkflowReminderRepository } from "../../repositories/WorkflowReminderRepository";
|
||||
import { isEmailAction } from "../actionHelperFunctions";
|
||||
import { isEmailAction, getTemplateBodyForAction, getTemplateSubjectForAction } from "../actionHelperFunctions";
|
||||
import { detectMatchedTemplate } from "../detectMatchedTemplate";
|
||||
import { getWorkflowRecipientEmail } from "../getWorkflowReminders";
|
||||
import type { VariablesType } from "../reminders/templates/customTemplate";
|
||||
import customTemplate, {
|
||||
@@ -51,9 +53,8 @@ export class EmailWorkflowService {
|
||||
evt: CalendarEvent;
|
||||
workflowReminderId: number;
|
||||
}) {
|
||||
const workflowReminder = await this.workflowReminderRepository.findByIdIncludeStepAndWorkflow(
|
||||
workflowReminderId
|
||||
);
|
||||
const workflowReminder =
|
||||
await this.workflowReminderRepository.findByIdIncludeStepAndWorkflow(workflowReminderId);
|
||||
|
||||
if (!workflowReminder) {
|
||||
throw new Error(`Workflow reminder not found with id ${workflowReminderId}`);
|
||||
@@ -94,7 +95,9 @@ export class EmailWorkflowService {
|
||||
|
||||
const emailWorkflowContentParams = await this.generateParametersToBuildEmailWorkflowContent({
|
||||
evt,
|
||||
workflowStep: workflowReminder.workflowStep as WorkflowStep & { action: ScheduleEmailReminderAction },
|
||||
workflowStep: workflowReminder.workflowStep as WorkflowStep & {
|
||||
action: ScheduleEmailReminderAction;
|
||||
},
|
||||
workflow: workflowReminder.workflowStep.workflow,
|
||||
emailAttendeeSendToOverride,
|
||||
commonScheduleFunctionParams,
|
||||
@@ -286,9 +289,8 @@ export class EmailWorkflowService {
|
||||
case WorkflowActions.EMAIL_ATTENDEE: {
|
||||
// For seated events, get the correct attendee based on seatReferenceUid
|
||||
if (seatReferenceUid) {
|
||||
const seatAttendeeData = await this.bookingSeatRepository.getByReferenceUidWithAttendeeDetails(
|
||||
seatReferenceUid
|
||||
);
|
||||
const seatAttendeeData =
|
||||
await this.bookingSeatRepository.getByReferenceUidWithAttendeeDetails(seatReferenceUid);
|
||||
if (seatAttendeeData?.attendee) {
|
||||
const nameParts = seatAttendeeData.attendee.name.split(" ").map((part: string) => part.trim());
|
||||
const firstName = nameParts[0];
|
||||
@@ -326,14 +328,105 @@ export class EmailWorkflowService {
|
||||
throw new Error("Failed to determine attendee email");
|
||||
}
|
||||
|
||||
const isEmailAttendeeAction = action === WorkflowActions.EMAIL_ATTENDEE;
|
||||
const locale = isEmailAttendeeAction
|
||||
? attendeeToBeUsedInMail.language?.locale || "en"
|
||||
: evt.organizer.language.locale || "en";
|
||||
|
||||
let emailContent = {
|
||||
emailSubject,
|
||||
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
|
||||
};
|
||||
const bookerUrl = evt.bookerUrl ?? WEBSITE_URL;
|
||||
|
||||
// Detect if the email content matches a default template for locale-based regeneration
|
||||
const timeFormat = evt.organizer.timeFormat || TimeFormat.TWELVE_HOUR;
|
||||
let defaultTemplates = {
|
||||
reminder: { body: null as string | null, subject: null as string | null },
|
||||
rating: { body: null as string | null, subject: null as string | null },
|
||||
};
|
||||
|
||||
if (emailBody) {
|
||||
const isEmailAttendeeAction = action === WorkflowActions.EMAIL_ATTENDEE;
|
||||
const tEn = await getTranslation("en", "common");
|
||||
defaultTemplates = {
|
||||
reminder: {
|
||||
body: getTemplateBodyForAction({
|
||||
action,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
locale: "en",
|
||||
t: tEn,
|
||||
timeFormat,
|
||||
}),
|
||||
subject: getTemplateSubjectForAction({
|
||||
action,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
locale: "en",
|
||||
t: tEn,
|
||||
timeFormat,
|
||||
}),
|
||||
},
|
||||
rating: {
|
||||
body: getTemplateBodyForAction({
|
||||
action,
|
||||
template: WorkflowTemplates.RATING,
|
||||
locale: "en",
|
||||
t: tEn,
|
||||
timeFormat,
|
||||
}),
|
||||
subject: getTemplateSubjectForAction({
|
||||
action,
|
||||
template: WorkflowTemplates.RATING,
|
||||
locale: "en",
|
||||
t: tEn,
|
||||
timeFormat,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const matchedTemplate = detectMatchedTemplate({
|
||||
emailBody,
|
||||
emailSubject,
|
||||
template,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
if (matchedTemplate === WorkflowTemplates.REMINDER) {
|
||||
const t = await getTranslation(locale, "common");
|
||||
|
||||
emailContent = emailReminderTemplate({
|
||||
isEditingMode: false,
|
||||
locale,
|
||||
t,
|
||||
action,
|
||||
timeFormat: evt.organizer.timeFormat,
|
||||
startTime,
|
||||
endTime,
|
||||
eventName: evt.title,
|
||||
timeZone,
|
||||
location: evt.location || "",
|
||||
meetingUrl:
|
||||
evt.videoCallData?.url || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "",
|
||||
otherPerson: attendeeName,
|
||||
name,
|
||||
});
|
||||
} else if (matchedTemplate === WorkflowTemplates.RATING) {
|
||||
emailContent = emailRatingTemplate({
|
||||
isEditingMode: false,
|
||||
locale,
|
||||
action,
|
||||
t: await getTranslation(locale, "common"),
|
||||
timeFormat: evt.organizer.timeFormat,
|
||||
startTime,
|
||||
endTime,
|
||||
eventName: evt.title,
|
||||
timeZone,
|
||||
organizer: evt.organizer.name,
|
||||
name,
|
||||
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
|
||||
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
|
||||
});
|
||||
} else if (emailBody) {
|
||||
const recipientEmail = getWorkflowRecipientEmail({
|
||||
action,
|
||||
attendeeEmail: attendeeToBeUsedInMail.email,
|
||||
@@ -368,8 +461,8 @@ export class EmailWorkflowService {
|
||||
: ""
|
||||
}`
|
||||
: isEmailAttendeeAction && seatReferenceUid
|
||||
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
|
||||
: ""
|
||||
? `?seatReferenceUid=${encodeURIComponent(seatReferenceUid)}`
|
||||
: ""
|
||||
}`,
|
||||
|
||||
rescheduleReason: evt.rescheduleReason,
|
||||
@@ -380,10 +473,6 @@ export class EmailWorkflowService {
|
||||
eventEndTimeInAttendeeTimezone: dayjs(endTime).tz(attendeeToBeUsedInMail.timeZone),
|
||||
};
|
||||
|
||||
const locale = isEmailAttendeeAction
|
||||
? attendeeToBeUsedInMail.language?.locale
|
||||
: evt.organizer.language.locale;
|
||||
|
||||
const emailSubjectTemplate = customTemplate(emailSubject, variables, locale, evt.organizer.timeFormat);
|
||||
emailContent.emailSubject = emailSubjectTemplate.text;
|
||||
emailContent.emailBody = customTemplate(
|
||||
@@ -393,39 +482,6 @@ export class EmailWorkflowService {
|
||||
evt.organizer.timeFormat,
|
||||
hideBranding
|
||||
).html;
|
||||
} else if (template === WorkflowTemplates.REMINDER) {
|
||||
emailContent = emailReminderTemplate({
|
||||
isEditingMode: false,
|
||||
locale: evt.organizer.language.locale,
|
||||
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
|
||||
action,
|
||||
timeFormat: evt.organizer.timeFormat,
|
||||
startTime,
|
||||
endTime,
|
||||
eventName: evt.title,
|
||||
timeZone,
|
||||
location: evt.location || "",
|
||||
meetingUrl:
|
||||
evt.videoCallData?.url || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || "",
|
||||
otherPerson: attendeeName,
|
||||
name,
|
||||
});
|
||||
} else if (template === WorkflowTemplates.RATING) {
|
||||
emailContent = emailRatingTemplate({
|
||||
isEditingMode: true,
|
||||
locale: evt.organizer.language.locale,
|
||||
action,
|
||||
t: await getTranslation(evt.organizer.language.locale || "en", "common"),
|
||||
timeFormat: evt.organizer.timeFormat,
|
||||
startTime,
|
||||
endTime,
|
||||
eventName: evt.title,
|
||||
timeZone,
|
||||
organizer: evt.organizer.name,
|
||||
name,
|
||||
ratingUrl: `${bookerUrl}/booking/${evt.uid}?rating`,
|
||||
noShowUrl: `${bookerUrl}/booking/${evt.uid}?noShow=true`,
|
||||
});
|
||||
}
|
||||
|
||||
// Allows debugging generated email content without waiting for sendgrid to send emails
|
||||
@@ -450,7 +506,10 @@ export class EmailWorkflowService {
|
||||
const emailEvent = {
|
||||
...evt,
|
||||
type: evt.eventType?.slug || "",
|
||||
organizer: { ...evt.organizer, language: { ...evt.organizer.language, translate: organizerT } },
|
||||
organizer: {
|
||||
...evt.organizer,
|
||||
language: { ...evt.organizer.language, translate: organizerT },
|
||||
},
|
||||
attendees: processedAttendees,
|
||||
location: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl || evt.location,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { WorkflowTemplates } from "@calcom/prisma/enums";
|
||||
|
||||
import { detectMatchedTemplate, type DefaultTemplates } from "../detectMatchedTemplate";
|
||||
|
||||
const createDefaultTemplates = (overrides?: Partial<DefaultTemplates>): DefaultTemplates => ({
|
||||
reminder: {
|
||||
body: "Default reminder body content",
|
||||
subject: "Default reminder subject",
|
||||
},
|
||||
rating: {
|
||||
body: "Default rating body content",
|
||||
subject: "Default rating subject",
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("detectMatchedTemplate", () => {
|
||||
describe("when emailBody is empty", () => {
|
||||
test("returns REMINDER when template is REMINDER", () => {
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "",
|
||||
emailSubject: "Any subject",
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates: createDefaultTemplates(),
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.REMINDER);
|
||||
});
|
||||
|
||||
test("returns RATING when template is RATING", () => {
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "",
|
||||
emailSubject: "Any subject",
|
||||
template: WorkflowTemplates.RATING,
|
||||
defaultTemplates: createDefaultTemplates(),
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.RATING);
|
||||
});
|
||||
|
||||
test("returns null when template is CUSTOM", () => {
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "",
|
||||
emailSubject: "Any subject",
|
||||
template: WorkflowTemplates.CUSTOM,
|
||||
defaultTemplates: createDefaultTemplates(),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when template is undefined", () => {
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "",
|
||||
emailSubject: "Any subject",
|
||||
template: undefined,
|
||||
defaultTemplates: createDefaultTemplates(),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when emailBody exists", () => {
|
||||
describe("REMINDER template matching", () => {
|
||||
test("returns REMINDER when both body AND subject match default", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: defaultTemplates.reminder.body!,
|
||||
emailSubject: defaultTemplates.reminder.subject!,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.REMINDER);
|
||||
});
|
||||
|
||||
test("returns null when body matches but subject is customized", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: defaultTemplates.reminder.body!,
|
||||
emailSubject: "Custom subject",
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when subject matches but body is customized", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "Custom body content",
|
||||
emailSubject: defaultTemplates.reminder.subject!,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when both body and subject are customized", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "Custom body content",
|
||||
emailSubject: "Custom subject",
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("matches body with HTML stripped (body has HTML tags)", () => {
|
||||
const defaultTemplates = createDefaultTemplates({
|
||||
reminder: {
|
||||
body: "Plain text reminder body",
|
||||
subject: "Default reminder subject",
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "<p>Plain text reminder body</p>",
|
||||
emailSubject: defaultTemplates.reminder.subject!,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.REMINDER);
|
||||
});
|
||||
|
||||
test("matches body with HTML stripped (template has HTML tags)", () => {
|
||||
const defaultTemplates = createDefaultTemplates({
|
||||
reminder: {
|
||||
body: "<div>Plain text reminder body</div>",
|
||||
subject: "Default reminder subject",
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "Plain text reminder body",
|
||||
emailSubject: defaultTemplates.reminder.subject!,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.REMINDER);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RATING template matching", () => {
|
||||
test("returns RATING when both body AND subject match default", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: defaultTemplates.rating.body!,
|
||||
emailSubject: defaultTemplates.rating.subject!,
|
||||
template: WorkflowTemplates.RATING,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.RATING);
|
||||
});
|
||||
|
||||
test("returns null when body matches but subject is customized", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: defaultTemplates.rating.body!,
|
||||
emailSubject: "Custom rating subject",
|
||||
template: WorkflowTemplates.RATING,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when subject matches but body is customized", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "Custom rating body",
|
||||
emailSubject: defaultTemplates.rating.subject!,
|
||||
template: WorkflowTemplates.RATING,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("template detection regardless of template field value", () => {
|
||||
test("detects REMINDER even when template field is CUSTOM", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: defaultTemplates.reminder.body!,
|
||||
emailSubject: defaultTemplates.reminder.subject!,
|
||||
template: WorkflowTemplates.CUSTOM,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.REMINDER);
|
||||
});
|
||||
|
||||
test("detects RATING even when template field is CUSTOM", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: defaultTemplates.rating.body!,
|
||||
emailSubject: defaultTemplates.rating.subject!,
|
||||
template: WorkflowTemplates.CUSTOM,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.RATING);
|
||||
});
|
||||
|
||||
test("detects REMINDER even when template field is RATING", () => {
|
||||
const defaultTemplates = createDefaultTemplates();
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: defaultTemplates.reminder.body!,
|
||||
emailSubject: defaultTemplates.reminder.subject!,
|
||||
template: WorkflowTemplates.RATING,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.REMINDER);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("returns null when default body is null", () => {
|
||||
const defaultTemplates = createDefaultTemplates({
|
||||
reminder: { body: null, subject: "Default reminder subject" },
|
||||
rating: { body: null, subject: "Default rating subject" },
|
||||
});
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "Some body content",
|
||||
emailSubject: "Default reminder subject",
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when default subject is null", () => {
|
||||
const defaultTemplates = createDefaultTemplates({
|
||||
reminder: { body: "Default reminder body", subject: null },
|
||||
rating: { body: "Default rating body", subject: null },
|
||||
});
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "Default reminder body",
|
||||
emailSubject: "Some subject",
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("handles & entity in body comparison", () => {
|
||||
const defaultTemplates = createDefaultTemplates({
|
||||
reminder: {
|
||||
body: "Date & Time",
|
||||
subject: "Default reminder subject",
|
||||
},
|
||||
});
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "Date & Time",
|
||||
emailSubject: defaultTemplates.reminder.subject!,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.REMINDER);
|
||||
});
|
||||
|
||||
test("prioritizes REMINDER over RATING when both could match", () => {
|
||||
const defaultTemplates = createDefaultTemplates({
|
||||
reminder: { body: "Same body", subject: "Same subject" },
|
||||
rating: { body: "Same body", subject: "Same subject" },
|
||||
});
|
||||
|
||||
const result = detectMatchedTemplate({
|
||||
emailBody: "Same body",
|
||||
emailSubject: "Same subject",
|
||||
template: WorkflowTemplates.RATING,
|
||||
defaultTemplates,
|
||||
});
|
||||
|
||||
expect(result).toBe(WorkflowTemplates.REMINDER);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ const user: User & { credentials: CredentialPayload[] } = {
|
||||
bufferTime: 0,
|
||||
availability: [],
|
||||
id: 0,
|
||||
uuid: "00000000-0000-0000-0000-000000000000",
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
allSelectedCalendars: [],
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { SettingsToggle, ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import type { ReactElement } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getOptInFeatureConfig } from "../config";
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "../types";
|
||||
|
||||
interface FeaturesSettingsProps {
|
||||
/** The hook result - can be from useUserFeatureOptIn, useTeamFeatureOptIn, or useOrganizationFeatureOptIn */
|
||||
featureOptIn: UseFeatureOptInResult;
|
||||
/** Whether the user can edit the feature settings. If false, all toggles will be disabled. */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
interface ToggleOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function isEnabledViaAutoOptIn(feature: NormalizedFeature, autoOptIn: boolean): boolean {
|
||||
return feature.currentState === "inherit" && autoOptIn && feature.globalEnabled;
|
||||
}
|
||||
|
||||
function handleValueChange(
|
||||
val: string | undefined,
|
||||
slug: string,
|
||||
setFeatureState: (slug: string, state: FeatureState) => void
|
||||
): void {
|
||||
if (!val) return;
|
||||
setFeatureState(slug, val as FeatureState);
|
||||
}
|
||||
|
||||
function LoadingSkeleton(): ReactElement {
|
||||
return (
|
||||
<div className="border-subtle rounded-b-lg border-x border-b px-4 py-8 sm:px-6">
|
||||
<div className="space-y-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }): ReactElement {
|
||||
return <Alert severity="neutral" title={message} />;
|
||||
}
|
||||
|
||||
function FeatureItem({
|
||||
feature,
|
||||
toggleOptions,
|
||||
getBlockedWarning,
|
||||
isBlockedByHigherLevel,
|
||||
autoOptIn,
|
||||
setFeatureState,
|
||||
canEdit,
|
||||
t,
|
||||
}: {
|
||||
feature: NormalizedFeature;
|
||||
toggleOptions: ToggleOption[];
|
||||
getBlockedWarning: (feature: NormalizedFeature) => string | null;
|
||||
isBlockedByHigherLevel: (feature: NormalizedFeature) => boolean;
|
||||
autoOptIn: boolean;
|
||||
setFeatureState: (slug: string, state: FeatureState) => void;
|
||||
canEdit: boolean;
|
||||
t: (key: string) => string;
|
||||
}): ReactElement | null {
|
||||
const config = getOptInFeatureConfig(feature.slug);
|
||||
|
||||
const blockedWarning = getBlockedWarning(feature);
|
||||
const enabledViaAutoOptInFlag = isEnabledViaAutoOptIn(feature, autoOptIn);
|
||||
const blockedByHigherLevel = isBlockedByHigherLevel(feature);
|
||||
const isDisabled = blockedByHigherLevel || !canEdit;
|
||||
|
||||
const finalToggleOptions = useMemo(() => {
|
||||
if (!isDisabled) {
|
||||
return toggleOptions;
|
||||
}
|
||||
return toggleOptions.map((option) => ({
|
||||
...option,
|
||||
disabled: true,
|
||||
}));
|
||||
}, [toggleOptions, isDisabled]);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div key={feature.slug}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3
|
||||
className={classNames(
|
||||
"text-emphasis flex items-center text-sm font-medium",
|
||||
blockedWarning && "text-subtle line-through"
|
||||
)}>
|
||||
{t(config.titleI18nKey)}
|
||||
{blockedWarning && (
|
||||
<Tooltip side="top" content={blockedWarning}>
|
||||
<span>
|
||||
<Icon name="circle-alert" className="text-error ml-1 h-4 w-4" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{enabledViaAutoOptInFlag && (
|
||||
<Tooltip side="top" content={t("enabled_via_auto_opt_in")}>
|
||||
<span>
|
||||
<Icon name="circle-check" className="text-success ml-1 h-4 w-4" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-subtle text-sm">{t(config.descriptionI18nKey)}</p>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
value={feature.currentState}
|
||||
onValueChange={(val: string | undefined): void =>
|
||||
handleValueChange(val, feature.slug, setFeatureState)
|
||||
}
|
||||
options={finalToggleOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturesSettings({ featureOptIn, canEdit = true }: FeaturesSettingsProps): ReactElement {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
features,
|
||||
autoOptIn,
|
||||
isLoading,
|
||||
setFeatureState,
|
||||
setAutoOptIn,
|
||||
isAutoOptInMutationPending,
|
||||
toggleLabels,
|
||||
autoOptInDescription,
|
||||
getBlockedWarning,
|
||||
isBlockedByHigherLevel,
|
||||
} = featureOptIn;
|
||||
|
||||
if (isLoading) return <LoadingSkeleton />;
|
||||
|
||||
const toggleOptions: ToggleOption[] = [
|
||||
{ value: "disabled", label: toggleLabels.disabled },
|
||||
{ value: "enabled", label: toggleLabels.enabled },
|
||||
{ value: "inherit", label: toggleLabels.inherit },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-subtle rounded-b-lg border-x border-b px-4 py-8 sm:px-6">
|
||||
{features.length === 0 && <EmptyState message={t("no_opt_in_features_available")} />}
|
||||
{features.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{features.map((feature) => (
|
||||
<FeatureItem
|
||||
key={feature.slug}
|
||||
feature={feature}
|
||||
toggleOptions={toggleOptions}
|
||||
getBlockedWarning={getBlockedWarning}
|
||||
isBlockedByHigherLevel={isBlockedByHigherLevel}
|
||||
autoOptIn={autoOptIn}
|
||||
setFeatureState={setFeatureState}
|
||||
canEdit={canEdit}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SettingsToggle
|
||||
toggleSwitchAtTheEnd={true}
|
||||
title={t("auto_opt_in_experimental")}
|
||||
description={autoOptInDescription}
|
||||
disabled={isAutoOptInMutationPending || !canEdit}
|
||||
checked={autoOptIn}
|
||||
onCheckedChange={setAutoOptIn}
|
||||
switchContainerClassName="mt-6"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeaturesSettings;
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { FeatureId } from "@calcom/features/flags/config";
|
||||
import type { OptInFeaturePolicy, OptInFeatureScope } from "./types";
|
||||
|
||||
export interface OptInFeatureConfig {
|
||||
slug: FeatureId;
|
||||
titleI18nKey: string;
|
||||
descriptionI18nKey: string;
|
||||
policy: OptInFeaturePolicy;
|
||||
/** Scopes where this feature can be configured. Defaults to all scopes if not specified. */
|
||||
scope?: OptInFeatureScope[];
|
||||
}
|
||||
|
||||
/** All available scopes for feature opt-in configuration */
|
||||
export const ALL_SCOPES: OptInFeatureScope[] = ["org", "team", "user"];
|
||||
|
||||
/**
|
||||
* Features that appear in opt-in settings.
|
||||
* Add new features here to make them available for user/team opt-in.
|
||||
@@ -16,6 +23,8 @@ export const OPT_IN_FEATURES: OptInFeatureConfig[] = [
|
||||
// slug: "bookings-v3",
|
||||
// titleI18nKey: "bookings_v3_title",
|
||||
// descriptionI18nKey: "bookings_v3_description",
|
||||
// policy: "permissive",
|
||||
// scope: ["org", "team", "user"], // Optional: defaults to all scopes if not specified
|
||||
// },
|
||||
];
|
||||
|
||||
@@ -27,9 +36,42 @@ export function getOptInFeatureConfig(slug: string): OptInFeatureConfig | undefi
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature slug is in the opt-in allowlist.
|
||||
* Check if a slug is in the opt-in allowlist.
|
||||
* Acts as a type guard, narrowing the slug to FeatureId when true.
|
||||
*/
|
||||
export function isOptInFeature(slug: string): slug is FeatureId {
|
||||
return OPT_IN_FEATURES.some((f) => f.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any opt-in features available.
|
||||
*/
|
||||
export const HAS_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.length > 0;
|
||||
|
||||
/** Whether there are opt-in features available for the user scope */
|
||||
export const HAS_USER_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("user"));
|
||||
|
||||
/** Whether there are opt-in features available for the team scope */
|
||||
export const HAS_TEAM_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("team"));
|
||||
|
||||
/** Whether there are opt-in features available for the org scope */
|
||||
export const HAS_ORG_OPT_IN_FEATURES: boolean = OPT_IN_FEATURES.some((f) => !f.scope || f.scope.includes("org"));
|
||||
|
||||
/**
|
||||
* Get opt-in features that are available for a specific scope.
|
||||
* Features without a scope field are available for all scopes.
|
||||
*/
|
||||
export function getOptInFeaturesForScope(scope: OptInFeatureScope): OptInFeatureConfig[] {
|
||||
return OPT_IN_FEATURES.filter((f) => !f.scope || f.scope.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is allowed for a specific scope.
|
||||
* Features without a scope field are allowed for all scopes.
|
||||
* Features not in the config are NOT allowed (must be explicitly configured).
|
||||
*/
|
||||
export function isFeatureAllowedForScope(slug: string, scope: OptInFeatureScope): boolean {
|
||||
const config = getOptInFeatureConfig(slug);
|
||||
if (!config) return false;
|
||||
return !config.scope || config.scope.includes(scope);
|
||||
}
|
||||
|
||||
@@ -1,352 +1,467 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { computeEffectiveStateAcrossTeams } from "./computeEffectiveState";
|
||||
import type { OptInFeaturePolicy } from "../types";
|
||||
|
||||
/**
|
||||
* Feature Opt-In Effective State Computation
|
||||
* ==========================================
|
||||
*
|
||||
* This module computes whether a feature is effectively enabled for a user
|
||||
* based on global, organization, team, and user-level settings.
|
||||
*
|
||||
* Two policies are supported:
|
||||
* - `permissive` (default): User opt-in can activate the feature; disables only win if ALL teams disable
|
||||
* - `strict`: User opt-in alone is NOT enough; requires explicit enable from org/team; ANY disable blocks
|
||||
*
|
||||
*
|
||||
* SCENARIO TABLE - PERMISSIVE POLICY
|
||||
* ============================================
|
||||
*
|
||||
* | # | Global | Org | Teams | User | Result | Reason |
|
||||
* |---|--------|----------|--------------------------|----------|---------|-------------------------------|
|
||||
* | 1 | true | inherit | [inherit, inherit] | enabled | ALLOWED | User opt-in activates |
|
||||
* | 2 | true | inherit | [enabled, inherit] | inherit | ALLOWED | Team enablement propagates |
|
||||
* | 3 | true | inherit | [inherit, inherit] | inherit | BLOCKED | No explicit enablement |
|
||||
* | 4 | true | inherit | [enabled, disabled] | inherit | ALLOWED | At least one team enabled |
|
||||
* | 5 | true | enabled | [inherit] | inherit | ALLOWED | Org enablement propagates |
|
||||
* | 6 | true | enabled | [disabled, disabled] | enabled | BLOCKED | All teams disabled |
|
||||
* | 7 | true | disabled | [enabled] | enabled | BLOCKED | Org disabled blocks all |
|
||||
* | 8 | true | inherit | [disabled] | enabled | BLOCKED | All teams disabled |
|
||||
* | 9 | true | inherit | [enabled] | disabled | BLOCKED | User disabled |
|
||||
* |10 | false | enabled | [enabled] | enabled | BLOCKED | Global disabled |
|
||||
*
|
||||
*
|
||||
* SCENARIO TABLE - STRICT POLICY
|
||||
* ==============================
|
||||
*
|
||||
* | # | Global | Org | Teams | User | Result | Reason |
|
||||
* |---|--------|----------|--------------------------|----------|---------|-------------------------------|
|
||||
* | 1 | true | inherit | [inherit, inherit] | inherit | BLOCKED | No explicit enablement |
|
||||
* | 2 | true | inherit | [inherit, inherit] | enabled | BLOCKED | User-only not allowed |
|
||||
* | 3 | true | inherit | [enabled, inherit] | inherit | ALLOWED | Team enablement works |
|
||||
* | 4 | true | inherit | [enabled, inherit] | enabled | ALLOWED | Team + user enabled |
|
||||
* | 5 | true | inherit | [enabled, disabled] | enabled | BLOCKED | Any team disabled blocks |
|
||||
* | 6 | true | enabled | [inherit] | inherit | ALLOWED | Org enablement works |
|
||||
* | 7 | true | enabled | [disabled] | enabled | BLOCKED | Any team disabled blocks |
|
||||
* | 8 | true | disabled | [enabled] | enabled | BLOCKED | Org disabled blocks all |
|
||||
* | 9 | true | inherit | [enabled] | disabled | BLOCKED | User disabled |
|
||||
* |10 | false | enabled | [enabled] | enabled | BLOCKED | Global disabled |
|
||||
*
|
||||
*
|
||||
* KEY DIFFERENCES BETWEEN POLICIES
|
||||
* ================================
|
||||
*
|
||||
* | Aspect | Permissive | Strict |
|
||||
* |-----------------------------|-------------------------------|----------------------------------|
|
||||
* | User opt-in alone | Can activate feature | NOT enough, needs org/team |
|
||||
* | Team disable behavior | Only blocks if ALL disabled | ANY disable blocks |
|
||||
* | Org/team enablement needed | No (user can self-enable) | Yes (required for activation) |
|
||||
*
|
||||
*/
|
||||
|
||||
describe("computeEffectiveStateAcrossTeams", () => {
|
||||
describe("when global is disabled", () => {
|
||||
it("returns false regardless of other states", () => {
|
||||
describe("Global Kill Switch", () => {
|
||||
it.each([
|
||||
{ policy: "permissive" as OptInFeaturePolicy, orgState: "enabled" as FeatureState, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
|
||||
{ policy: "strict" as OptInFeaturePolicy, orgState: "enabled" as FeatureState, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
|
||||
])("blocks feature when global is disabled (policy: $policy)", ({ policy, orgState, teamStates, userState }) => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: false,
|
||||
orgState: "enabled",
|
||||
teamStates: ["enabled"],
|
||||
userState: "enabled",
|
||||
orgState,
|
||||
teamStates,
|
||||
userState,
|
||||
policy,
|
||||
})
|
||||
).toBe(false);
|
||||
).toEqual({ enabled: false, reason: "feature_global_disabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("when org is disabled", () => {
|
||||
it("returns false regardless of team and user state", () => {
|
||||
describe("Organization Level Blocking", () => {
|
||||
it.each([
|
||||
{ policy: "permissive" as OptInFeaturePolicy, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
|
||||
{ policy: "strict" as OptInFeaturePolicy, teamStates: ["enabled"] as FeatureState[], userState: "enabled" as FeatureState },
|
||||
])("blocks feature when org is disabled (policy: $policy)", ({ policy, teamStates, userState }) => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "disabled",
|
||||
teamStates: ["enabled"],
|
||||
userState: "enabled",
|
||||
teamStates,
|
||||
userState,
|
||||
policy,
|
||||
})
|
||||
).toBe(false);
|
||||
).toEqual({ enabled: false, reason: "feature_org_disabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("when org is enabled", () => {
|
||||
describe("when all teams are disabled", () => {
|
||||
it("returns false regardless of user state", () => {
|
||||
describe("Permissive Policy", () => {
|
||||
describe("User Opt-In Behavior", () => {
|
||||
it("allows user to self-enable even when org and teams only inherit", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
orgState: "inherit",
|
||||
teamStates: ["inherit", "inherit"],
|
||||
userState: "enabled",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
|
||||
it("blocks when no explicit enablement exists anywhere", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["inherit", "inherit"],
|
||||
userState: "inherit",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Team Enablement Propagation", () => {
|
||||
it("allows feature when at least one team is enabled", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled", "inherit", "inherit"],
|
||||
userState: "inherit",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
|
||||
it("allows feature when one team enabled and another disabled (mixed)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled", "disabled", "inherit"],
|
||||
userState: "inherit",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Team Disable Behavior (ALL must disable)", () => {
|
||||
it("blocks only when ALL teams have explicitly disabled", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["disabled", "disabled"],
|
||||
userState: "enabled",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when at least one team is enabled", () => {
|
||||
it("returns true when user is enabled", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["enabled", "disabled"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(true);
|
||||
).toEqual({ enabled: false, reason: "feature_all_teams_disabled" });
|
||||
});
|
||||
|
||||
it("returns false when user is disabled", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["enabled", "disabled"],
|
||||
userState: "disabled",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when user inherits", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["enabled", "disabled"],
|
||||
userState: "inherit",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when teams inherit from enabled org", () => {
|
||||
it("returns true when user is enabled or inherits", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["inherit"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["inherit"],
|
||||
userState: "inherit",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when org inherits (or no org)", () => {
|
||||
describe("when all teams are disabled", () => {
|
||||
it("returns false regardless of user state", () => {
|
||||
it("blocks when single team is disabled (user in one team)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["disabled"],
|
||||
userState: "enabled",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toBe(false);
|
||||
).toEqual({ enabled: false, reason: "feature_all_teams_disabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("when at least one team is enabled", () => {
|
||||
it("returns true when user is enabled or inherits", () => {
|
||||
describe("Organization Enablement", () => {
|
||||
it("allows feature when org is enabled and teams inherit", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled", "disabled"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled"],
|
||||
orgState: "enabled",
|
||||
teamStates: ["inherit"],
|
||||
userState: "inherit",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toBe(true);
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
|
||||
it("returns false when user is disabled", () => {
|
||||
it("blocks when org enabled but all teams disabled", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["disabled", "disabled"],
|
||||
userState: "enabled",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_all_teams_disabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Disable Behavior", () => {
|
||||
it("blocks when user explicitly disables (even with team enabled)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled"],
|
||||
userState: "disabled",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toBe(false);
|
||||
).toEqual({ enabled: false, reason: "feature_user_disabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("when teams only inherit (no org enabled)", () => {
|
||||
it("returns true when user explicitly opts in", () => {
|
||||
describe("No Teams Scenario", () => {
|
||||
it("allows when org enabled and user inherits (no teams)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["inherit"],
|
||||
userState: "enabled",
|
||||
orgState: "enabled",
|
||||
teamStates: [],
|
||||
userState: "inherit",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toBe(true);
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
|
||||
it("returns false when user inherits because no explicit enablement above", () => {
|
||||
it("allows when user self-enables (no teams, org inherits)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: [],
|
||||
userState: "enabled",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
|
||||
it("blocks when no enablement anywhere (no teams)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: [],
|
||||
userState: "inherit",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Strict Policy", () => {
|
||||
describe("User Opt-In Alone NOT Sufficient", () => {
|
||||
it("blocks when user enables but no org/team enablement exists", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["inherit", "inherit"],
|
||||
userState: "enabled",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_user_only_not_allowed" });
|
||||
});
|
||||
|
||||
it("blocks when no explicit enablement exists anywhere", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["inherit", "inherit"],
|
||||
userState: "inherit",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Team Enablement Required", () => {
|
||||
it("allows when team is enabled and user inherits", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled", "inherit", "inherit"],
|
||||
userState: "inherit",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
|
||||
it("allows when team is enabled and user also enables", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled", "inherit", "inherit"],
|
||||
userState: "enabled",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ANY Team Disable Blocks", () => {
|
||||
it("blocks when any team is disabled (even if another is enabled)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled", "disabled", "inherit"],
|
||||
userState: "enabled",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_any_team_disabled" });
|
||||
});
|
||||
|
||||
it("blocks when single team is disabled", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["disabled"],
|
||||
userState: "enabled",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_any_team_disabled" });
|
||||
});
|
||||
|
||||
it("blocks when org enabled but any team disabled", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["disabled"],
|
||||
userState: "enabled",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_any_team_disabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Enablement", () => {
|
||||
it("allows when org is enabled and teams inherit", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["inherit"],
|
||||
userState: "inherit",
|
||||
policy: "strict",
|
||||
})
|
||||
).toBe(false);
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Disable Behavior", () => {
|
||||
it("blocks when user explicitly disables (even with team enabled)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled"],
|
||||
userState: "disabled",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_user_disabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("No Teams Scenario", () => {
|
||||
it("allows when org enabled (no teams)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: [],
|
||||
userState: "inherit",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
|
||||
it("blocks when user self-enables but no org enablement (no teams)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: [],
|
||||
userState: "enabled",
|
||||
policy: "strict",
|
||||
})
|
||||
).toEqual({ enabled: false, reason: "feature_user_only_not_allowed" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when user has no teams", () => {
|
||||
it("returns true when org is enabled and user is enabled/inherits", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: [],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: [],
|
||||
userState: "inherit",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when org inherits and user has no explicit enablement", () => {
|
||||
describe("Permissive Policy Additional Cases", () => {
|
||||
it("allows user to self-enable with single inheriting team", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: [],
|
||||
userState: "inherit",
|
||||
teamStates: ["inherit"],
|
||||
userState: "enabled",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toBe(false); // No explicit enablement in chain, feature should be disabled
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
|
||||
it("returns true when org inherits but user explicitly enables", () => {
|
||||
it("allows feature with mixed team states (one enabled, one disabled)", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: [],
|
||||
userState: "enabled",
|
||||
teamStates: ["enabled", "disabled"],
|
||||
userState: "inherit",
|
||||
policy: "permissive",
|
||||
})
|
||||
).toBe(true); // User explicit enablement is sufficient
|
||||
).toEqual({ enabled: true, reason: "feature_enabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("user opt-in behavior", () => {
|
||||
it("allows user to opt-in regardless of org/team inheritance state", () => {
|
||||
// User can opt-in even when org and all teams are just inheriting
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["inherit", "inherit"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(true);
|
||||
describe("Policy Comparison (same inputs, different outcomes)", () => {
|
||||
it("user-only enablement: allowed in permissive, blocked in strict", () => {
|
||||
const input = {
|
||||
globalEnabled: true,
|
||||
orgState: "inherit" as FeatureState,
|
||||
teamStates: ["inherit", "inherit"] as FeatureState[],
|
||||
userState: "enabled" as FeatureState,
|
||||
};
|
||||
|
||||
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "permissive" })).toEqual({
|
||||
enabled: true,
|
||||
reason: "feature_enabled",
|
||||
});
|
||||
|
||||
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "strict" })).toEqual({
|
||||
enabled: false,
|
||||
reason: "feature_user_only_not_allowed",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks user opt-in when all teams have explicitly disabled", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["disabled", "disabled"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
it("mixed team states: allowed in permissive, blocked in strict", () => {
|
||||
const input = {
|
||||
globalEnabled: true,
|
||||
orgState: "inherit" as FeatureState,
|
||||
teamStates: ["enabled", "disabled"] as FeatureState[],
|
||||
userState: "enabled" as FeatureState,
|
||||
};
|
||||
|
||||
it("blocks user opt-in when org has explicitly disabled", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "disabled",
|
||||
teamStates: ["inherit"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "permissive" })).toEqual({
|
||||
enabled: true,
|
||||
reason: "feature_enabled",
|
||||
});
|
||||
|
||||
describe("truth table from design doc", () => {
|
||||
it("org disabled, any teams, any user → false", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "disabled",
|
||||
teamStates: ["enabled"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("org enabled, all teams disabled, any user → false", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["disabled", "disabled"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("org enabled, at least one team enabled/inherit, user disabled → false", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["enabled"],
|
||||
userState: "disabled",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("org enabled, at least one team enabled/inherit, user enabled/inherit → true", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["enabled"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "enabled",
|
||||
teamStates: ["inherit"],
|
||||
userState: "inherit",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("org inherit/null, all teams disabled, any user → false", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["disabled"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("org inherit/null, at least one team enabled, user disabled → false", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled"],
|
||||
userState: "disabled",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("org inherit/null, at least one team enabled, user enabled/inherit → true", () => {
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled"],
|
||||
userState: "enabled",
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
computeEffectiveStateAcrossTeams({
|
||||
globalEnabled: true,
|
||||
orgState: "inherit",
|
||||
teamStates: ["enabled"],
|
||||
userState: "inherit",
|
||||
})
|
||||
).toBe(true);
|
||||
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "strict" })).toEqual({
|
||||
enabled: false,
|
||||
reason: "feature_any_team_disabled",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
|
||||
import type { OptInFeaturePolicy } from "../types";
|
||||
|
||||
export type EffectiveStateReason =
|
||||
| "feature_global_disabled"
|
||||
| "feature_org_disabled"
|
||||
| "feature_all_teams_disabled"
|
||||
| "feature_any_team_disabled"
|
||||
| "feature_user_disabled"
|
||||
| "feature_no_explicit_enablement"
|
||||
| "feature_user_only_not_allowed"
|
||||
| "feature_enabled";
|
||||
|
||||
export type EffectiveStateResult = {
|
||||
enabled: boolean;
|
||||
reason: EffectiveStateReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the effective enabled state based on global, org, teams, and user settings.
|
||||
*
|
||||
* The logic follows these rules:
|
||||
* - Any level set to "disabled" blocks the feature (org blocks all, team blocks that team's users, user blocks self)
|
||||
* - User can explicitly opt-in to enable the feature for themselves
|
||||
* - "enabled" at org/team level provides enablement that users can inherit from
|
||||
* - "inherit" passes through to the level above
|
||||
* The logic depends on the policy:
|
||||
*
|
||||
* **Permissive policy (default):**
|
||||
* - User opt-in can activate the feature
|
||||
* - Any explicit enable above is sufficient
|
||||
* - Disables only win if ALL teams disable
|
||||
*
|
||||
* **Strict policy:**
|
||||
* - User opt-in alone is NOT enough - requires explicit enable from org/team
|
||||
* - ANY explicit disable blocks
|
||||
*
|
||||
* Returns both the enabled state and the reason for that state.
|
||||
*/
|
||||
export function computeEffectiveStateAcrossTeams({
|
||||
globalEnabled,
|
||||
orgState,
|
||||
teamStates,
|
||||
userState,
|
||||
policy,
|
||||
}: {
|
||||
/**
|
||||
* Acts as a global kill switch. When false, the feature is disabled for everyone.
|
||||
@@ -24,11 +49,16 @@ export function computeEffectiveStateAcrossTeams({
|
||||
orgState: FeatureState;
|
||||
teamStates: FeatureState[];
|
||||
userState: FeatureState;
|
||||
}): boolean {
|
||||
/**
|
||||
* Policy that determines how feature opt-in states are evaluated.
|
||||
*/
|
||||
policy: OptInFeaturePolicy;
|
||||
}): EffectiveStateResult {
|
||||
// Derive all conditions upfront
|
||||
const orgEnabled = orgState === "enabled";
|
||||
const orgDisabled = orgState === "disabled";
|
||||
const anyTeamEnabled = teamStates.some((s) => s === "enabled");
|
||||
const anyTeamDisabled = teamStates.some((s) => s === "disabled");
|
||||
const allTeamsDisabled = teamStates.length > 0 && teamStates.every((s) => s === "disabled");
|
||||
const userEnabled = userState === "enabled";
|
||||
const userDisabled = userState === "disabled";
|
||||
@@ -36,13 +66,49 @@ export function computeEffectiveStateAcrossTeams({
|
||||
// Explicit enablement exists above user level
|
||||
const hasExplicitEnablementAboveUser = orgEnabled || anyTeamEnabled;
|
||||
|
||||
// Define when feature is enabled (whitelist approach)
|
||||
const isEnabled =
|
||||
globalEnabled &&
|
||||
!userDisabled &&
|
||||
!orgDisabled &&
|
||||
!allTeamsDisabled &&
|
||||
(userEnabled || hasExplicitEnablementAboveUser); // User can opt-in directly, or inherit from org/team
|
||||
// Check conditions in order of precedence and return with reason
|
||||
if (!globalEnabled) {
|
||||
return { enabled: false, reason: "feature_global_disabled" };
|
||||
}
|
||||
|
||||
return isEnabled;
|
||||
if (orgDisabled) {
|
||||
return { enabled: false, reason: "feature_org_disabled" };
|
||||
}
|
||||
|
||||
if (policy === "strict") {
|
||||
// Strict policy: ANY explicit disable blocks
|
||||
if (anyTeamDisabled) {
|
||||
return { enabled: false, reason: "feature_any_team_disabled" };
|
||||
}
|
||||
|
||||
// Strict policy: User opt-in alone is NOT enough - requires explicit enable from org/team
|
||||
if (userEnabled && !hasExplicitEnablementAboveUser) {
|
||||
return { enabled: false, reason: "feature_user_only_not_allowed" };
|
||||
}
|
||||
|
||||
if (userDisabled) {
|
||||
return { enabled: false, reason: "feature_user_disabled" };
|
||||
}
|
||||
|
||||
if (!hasExplicitEnablementAboveUser) {
|
||||
return { enabled: false, reason: "feature_no_explicit_enablement" };
|
||||
}
|
||||
|
||||
return { enabled: true, reason: "feature_enabled" };
|
||||
}
|
||||
|
||||
// Permissive policy (default): disables only win if ALL teams disable
|
||||
if (allTeamsDisabled) {
|
||||
return { enabled: false, reason: "feature_all_teams_disabled" };
|
||||
}
|
||||
|
||||
if (userDisabled) {
|
||||
return { enabled: false, reason: "feature_user_disabled" };
|
||||
}
|
||||
|
||||
if (!userEnabled && !hasExplicitEnablementAboveUser) {
|
||||
return { enabled: false, reason: "feature_no_explicit_enablement" };
|
||||
}
|
||||
|
||||
return { enabled: true, reason: "feature_enabled" };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user