Compare commits

...
Author SHA1 Message Date
emrysal 87cb8b170a chore: release v5.5.13
Post release / release (push) Canceled after 0s
2025-08-06 07:33:08 +00:00
Omar LópezandDevin AI badd0de33c fix: resolve circular dependency in getUserAvailability by extracting getPeriodStartDatesBetween utility (#22913)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-06 06:10:38 +00:00
Benny Joo 124fd8ebca fix: OG image error from missing height field (#22914) 2025-08-06 14:12:28 +09:00
emrysal 5d0dc3f546 chore: release v5.5.12
Post release / release (push) Canceled after 0s
2025-08-06 01:00:26 +00:00
MorganandAlex van Andel e4baf15b53 feat: Sync timezone for users having delegation credentials for google/outlook (#22904)
* feat: sync timezone with google/outlook for delegated credentials users

* chore: dynamic sync timezone in get availble slots

* redis cache for get delegated timezone

* Update packages/lib/getUserAvailability.ts

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-06 01:55:47 +01:00
Alex van Andel a1bd8b6259 fix: invalid workflow (apiv2 e2e yaml) preventing checks running 2025-08-06 01:17:27 +01:00
Eunjae Lee 7987c9f45d revert: eslint-config-next to v13.2.1 (#22903) 2025-08-06 00:29:55 +01:00
Andreas Thomas 781d0d92df perf: upgrade unkey to v2 (#22902)
Unkey's v2 api runs on AWS (including us-east-2, where cal runs) and has
significantly lower latency than v1.
2025-08-05 10:49:51 -07:00
Alex van Andelandcal.com b71d8baccc chore: Implement short-lived redis cache for slots (#22787)
* chore: Implement short-lived redis cache for slots

* chore: adapt apiv2 redis service to match with upstash redis

* chore: safer redis service and ms ttl

* fixup! chore: safer redis service and ms ttl

* Wrap with timeout, currently doesn't work yet

* Updated @upstash/redis for better signal support

* Fix type errors, remove ts value

* Inject NoopRedisService for NODE_ENV test

* chore: bump platform libs

* chore: bump platform libs

* Upstash Redis upgrade no longer resulted in expected hard crash on init, so updated factory and our Upstash Redis Adapter to mimick old behaviour

* Add SLOTS_CACHE_TTL variable for configurable ttl on slots cache

* Update parseInt to use right types

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* update e2e api v2 action

* set SLOTS_CACHE_TTL env var api v2 e2e

---------

Co-authored-by: cal.com <morgan@cal.com>
2025-08-05 17:32:02 +00:00
a71d949a0c feat: add cron job to cleanup old queued form responses (#22035)
* feat: add cron job to cleanup old queued form responses

- Add cleanup cron job for App_RoutingForms_QueuedFormResponse table
- Delete records with null actualResponseId older than 1 hour
- Schedule to run twice daily (every 12 hours) via vercel.json
- Follow existing cron job patterns for authentication and structure

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

* fix: correct date comparison logic to delete older records

- Change from lte to gte in createdAt comparison
- Now properly deletes records older than 1 hour

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

* refactor: move cleanup logic to routing forms lib and import in API endpoint

- Create cleanupExpiredQueuedFormResponses function in routing forms lib
- Update API endpoint to import and use the new cleanup function
- Follow established patterns for code organization in Cal.com
- Maintain same functionality and authentication logic

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

* refactor: move entire cron logic to routing-forms cron folder

- Create routing-forms/cron/queuedFormResponseCleanup.ts with complete handler
- Move authentication, HTTP handling, and database operations to routing-forms
- Update API endpoint to simply import and call the cron handler
- Remove old lib cleanup function
- Follow user's preferred organizational pattern

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

* fix: correct date comparison logic to delete records older than 1 hour

- Change from gte (greater than or equal) to lt (less than) cutoffTime
- Now properly deletes records where createdAt < cutoffTime (older than 1 hour)
- Previous logic was deleting newer records instead of older ones

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

* fixes

* refactor: reorganize queuedFormResponse into domain-driven structure

- Move repository and service files to routingForm/queuedFormResponse subdirectory
- Clean up unused methods and tests for better maintainability
- Fix import paths and type definitions
- Ensure all tests pass and maintain backward compatibility

* fix: align QueuedFormResponse interface types with Prisma schema

- Update actualResponseId type from string to number to match Prisma Int type
- Make updatedAt nullable (Date | null) to match Prisma DateTime? type
- Fix import path for QueuedFormResponseRepository interface

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

* refactor: reorganize queuedFormResponse into domain-driven structure

- Move repository and service files to routingForm/queuedFormResponse subdirectory
- Clean up unused methods and tests for better maintainability
- Fix import paths and type definitions
- Ensure all tests pass and maintain backward compatibility

* test: add comprehensive where clause validation tests and missing test coverage

* chore: update cron schedule to weekly for 7-day retention period

* revert: restore 12-hour cron schedule for better batch management

* Add integration tests

* refactor: apply test data builder pattern to queuedFormResponse integration tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-08-05 22:17:50 +05:30
emrysal e55cee443b chore: release v5.5.11
Post release / release (push) Canceled after 0s
2025-08-05 12:20:27 +00:00
MorganandAlex van Andel c3634b3aba refactor: getUserAvailability into service with DI (#22881)
* refactor: getUserAvailability into service with DI

* chore: bump platform libs

* disable bull queue in e2e for bookings

* chore: bump platform libs

* chore: bump platform libs

* fix: should update event type bookingFields test

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-05 13:13:42 +01:00
Eunjae LeeandDevin AI cd576f5378 refactor: use dependency injection for InsightsBookingService (#22790)
* refactor: use dependency injection for InsightsBookingService

- Add DI tokens for InsightsBookingService and module
- Rename InsightsBookingService to InsightsBookingBaseService
- Create InsightsBookingService DI interface with create method
- Add DI module and container for InsightsBookingService
- Update tRPC router to use getInsightsBookingService DI container
- Update test file to use InsightsBookingBaseService
- Update documentation to reflect new DI pattern

Follows the same dependency injection pattern established for InsightsRoutingService in PR #22677

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: restore createInsightsBookingService helper using DI internally

- Keep createInsightsBookingService helper function for cleaner API
- Use getInsightsBookingService DI container internally
- Maintain same function signature and behavior
- All existing calls continue to work unchanged

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: rename service files with proper capitalization

- Rename insightsBookingBase.ts to InsightsBookingBaseService.ts
- Rename insightsBookingDI.ts to InsightsBookingDIService.ts
- Update all import statements to use new file names
- Maintain existing handler functionality using createInsightsBookingService(ctx, input)

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* revert some changes

* rename

* update doc

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-05 11:07:07 +02:00
60 changed files with 2138 additions and 718 deletions
+1
View File
@@ -22,6 +22,7 @@ env:
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
jobs:
e2e:
timeout-minutes: 20
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+4 -3
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { z } from "zod";
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -191,8 +191,9 @@ const availabilitySchema = z
async function handler(req: NextApiRequest) {
const { isSystemWideAdmin, userId: reqUserId } = req;
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
const userAvailabilityService = getUserAvailabilityService()
if (!teamId)
return getUserAvailability({
return userAvailabilityService.getUserAvailability({
username,
dateFrom,
dateTo,
@@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) {
const availabilities = members.map(async (user) => {
return {
userId: user.id,
availability: await getUserAvailability({
availability: await userAvailabilityService.getUserAvailability({
userId: user.id,
dateFrom,
dateTo,
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.287",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -104,10 +104,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -157,13 +158,12 @@ export class OutputBookingsService_2024_08_13 {
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy,
};
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -279,9 +279,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -290,7 +290,10 @@ describe("Event types Endpoints", () => {
expect(responseBookingFields).toBeDefined();
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
expect(responseBookingField).toEqual(bookingFields[0]);
const fields = responseBookingField
//@ts-ignore
delete fields.labelAsSafeHtml
expect(fields).toEqual(bookingFields[0]);
eventType.bookingFields = responseBookingFields;
});
});
@@ -11,7 +11,9 @@ import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { Module } from "@nestjs/common";
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
@Module({
imports: [PrismaModule],
@@ -24,10 +26,12 @@ import { Module } from "@nestjs/common";
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
RedisService,
PrismaFeaturesRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
UserAvailabilityService
],
exports: [AvailableSlotsService],
})
@@ -9,10 +9,13 @@ import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
import { UserAvailabilityService } from "./user-availability.service";
@Injectable()
export class AvailableSlotsService extends BaseAvailableSlotsService {
constructor(
@@ -24,6 +27,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepository: PrismaSelectedSlotRepository,
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository,
redisService: RedisService,
featuresRepository: PrismaFeaturesRepository
) {
super({
@@ -35,8 +39,15 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepo: selectedSlotRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
redisClient: redisService,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
cacheService: new CacheService(featuresRepository),
userAvailabilityService: new UserAvailabilityService(
oooRepoDependency,
bookingRepository,
eventTypeRepository,
redisService
),
});
}
}
@@ -0,0 +1,24 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
@Injectable()
export class UserAvailabilityService extends BaseUserAvailabilityService {
constructor(
oooRepoDependency: PrismaOOORepository,
bookingRepository: PrismaBookingRepository,
eventTypeRepository: PrismaEventTypeRepository,
redisService: RedisService
) {
super({
oooRepo: oooRepoDependency,
bookingRepo: bookingRepository,
eventTypeRepo: eventTypeRepository,
redisClient: redisService,
});
}
}
@@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy {
fromReschedule?: string | null;
}
) {
if (this.configService.get("e2e")) {
return true;
}
const { uid, startTime, fromReschedule } = booking;
const delay = startTime.getTime() - Date.now();
@@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy {
* Removing an attendee from a booking does not cancel the usage increment job.
*/
async cancelUsageByBookingUid(bookingUid: string) {
if (this.configService.get("e2e")) {
return true;
}
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
if (job) {
await job.remove();
@@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy {
async onModuleDestroy() {
try {
await this.billingQueue.close();
} catch (err) {
this.logger.error(err);
@@ -7,12 +7,32 @@ import { Redis } from "ioredis";
export class RedisService implements OnModuleDestroy {
public redis: Redis;
private readonly logger = new Logger("RedisService");
private isReady = false; // Track connection status
constructor(readonly configService: ConfigService<AppConfig>) {
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
this.redis = new Redis(dbUrl);
this.redis.on("error", (err) => {
this.logger.error(`IoRedis connection error: ${err.message}`);
this.isReady = false;
});
this.redis.on("connect", () => {
this.logger.log("IoRedis connected!");
this.isReady = true;
});
this.redis.on("reconnecting", (delay: string) => {
this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`);
});
this.redis.on("end", () => {
this.logger.warn("IoRedis connection ended.");
this.isReady = false;
});
}
async onModuleDestroy() {
@@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy {
this.logger.error(err);
}
}
async get<TData>(key: string): Promise<TData | null> {
let data = null;
if (!this.isReady) {
return null;
}
try {
data = await this.redis.get(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`);
}
if (data === null) {
return null;
}
try {
return JSON.parse(data) as TData;
} catch (e) {
return data as TData;
}
}
async del(key: string): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.del(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`);
return 0;
}
}
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
if (!this.isReady) {
return null;
}
try {
const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value);
if (opts?.ttl) {
await this.redis.set(key, stringifiedValue, "PX", opts.ttl);
} else {
await this.redis.set(key, stringifiedValue);
}
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`);
return null;
}
return "OK";
}
async expire(key: string, seconds: number): Promise<0 | 1> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.expire(key, seconds) as Promise<0 | 1>;
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`);
return 0;
}
}
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
if (!this.isReady) {
return [];
}
try {
const results = await this.redis.lrange(key, start, end);
return results.map((item) => JSON.parse(item) as TResult);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`);
return [];
}
}
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
const stringifiedElements = elements.map((element) =>
typeof element === "object" ? JSON.stringify(element) : String(element)
);
return this.redis.lpush(key, ...stringifiedElements);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`);
return 0;
}
}
}
+1
View File
@@ -36,4 +36,5 @@ process.env = {
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
INTEGRATION_TEST_MODE: "true",
e2e: "true",
SLOTS_CACHE_TTL: "1"
};
@@ -0,0 +1,5 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { handleQueuedFormResponseCleanup } from "@calcom/app-store/routing-forms/cron/queuedFormResponseCleanup";
export const POST = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.11",
"version": "5.5.13",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -78,7 +78,7 @@
"@team-plain/typescript-sdk": "^5.9.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.21.0",
"@upstash/redis": "^1.35.2",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.6.3",
+4
View File
@@ -4,6 +4,10 @@
"path": "/api/cron/calendar-cache-cleanup",
"schedule": "0 5 * * *"
},
{
"path": "/api/cron/queuedFormResponseCleanup",
"schedule": "0 */12 * * *"
},
{
"path": "/api/tasks/cron",
"schedule": "* * * * *"
+1
View File
@@ -118,6 +118,7 @@
"@daily-co/daily-js": "^0.76.0",
"@evyweb/ioctopus": "^1.2.0",
"@next/third-parties": "^14.2.5",
"@unkey/ratelimit": "^2.1.2",
"@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1",
"date-fns-tz": "^3.2.0",
@@ -1114,4 +1114,18 @@ export default class GoogleCalendarService implements Calendar {
throw error;
}
}
async getMainTimeZone(): Promise<string> {
try {
const primaryCalendar = await this.getPrimaryCalendar();
if (!primaryCalendar?.timeZone) {
this.log.warn("No timezone found in primary calendar, defaulting to UTC");
return "UTC";
}
return primaryCalendar.timeZone;
} catch (error) {
this.log.error("Error getting main timezone from Google Calendar", { error });
throw error;
}
}
}
@@ -625,4 +625,21 @@ export default class Office365CalendarService implements Calendar {
return response.json();
};
async getMainTimeZone(): Promise<string> {
try {
const response = await this.fetcher(`${await this.getUserEndpoint()}/mailboxSettings/timeZone`);
const timezone = await handleErrorsJson<string>(response);
if (!timezone) {
this.log.warn("No timezone found in mailbox settings, defaulting to Europe/London");
return "Europe/London";
}
return timezone;
} catch (error) {
this.log.error("Error getting main timezone from Office365 Calendar", { error });
throw error;
}
}
}
@@ -0,0 +1,47 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import logger from "@calcom/lib/logger";
import { PrismaQueuedFormResponseRepository } from "@calcom/lib/server/repository/routingForm/queuedFormResponse/PrismaQueuedFormResponseRepository";
import { QueuedFormResponseService } from "@calcom/lib/server/service/routingForm/queuedFormResponse/QueuedFormResponseService";
import prisma from "@calcom/prisma";
function validateRequest(request: NextRequest) {
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
return null;
}
export async function handleQueuedFormResponseCleanup(request: NextRequest) {
const authError = validateRequest(request);
if (authError) {
return authError;
}
try {
const queuedFormResponseRepo = new PrismaQueuedFormResponseRepository(prisma);
const queuedFormResponseService = new QueuedFormResponseService({
logger,
queuedFormResponseRepo,
});
const result = await queuedFormResponseService.cleanupExpiredResponses({
batchSize: 1000,
});
return NextResponse.json({
status: "success",
data: result,
});
} catch (error) {
console.error("Error during queued form response cleanup:", error);
return NextResponse.json(
{ status: "error", message: "Failed to cleanup queued form responses" },
{ status: 500 }
);
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@calcom/eslint-plugin-eslint": "*",
"@todesktop/tailwind-variants": "^1.0.0",
"eslint-config-next": "^15.4.5",
"eslint-config-next": "^13.2.1",
"eslint-config-prettier": "^8.6.0",
"eslint-config-turbo": "^0.0.7",
"eslint-plugin-playwright": "^0.12.0",
@@ -6,7 +6,7 @@ import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker
import { buildDateRanges } from "@calcom/lib/date-ranges";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getBusyTimesForLimitChecks } from "@calcom/lib/getBusyTimes";
import { getUsersAvailability } from "@calcom/lib/getUserAvailability";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
@@ -62,6 +62,7 @@ const _ensureAvailableUsers = async (
shouldServeCache?: boolean
// ReturnType hint of at least one IsFixedAwareUser, as it's made sure at least one entry exists
): Promise<[IsFixedAwareUser, ...IsFixedAwareUser[]]> => {
const userAvailabilityService = getUserAvailabilityService()
const availableUsers: IsFixedAwareUser[] = [];
const startDateTimeUtc = getDateTimeInUtc(input.dateFrom, input.timeZone);
@@ -86,7 +87,7 @@ const _ensureAvailableUsers = async (
})
: [];
const usersAvailability = await getUsersAvailability({
const usersAvailability = await userAvailabilityService.getUsersAvailability({
users: eventType.users,
query: {
...input,
@@ -103,7 +103,7 @@ export default function InsightsPage() {
## Step 4: Create tRPC Handler
Add the tRPC endpoint in the insights router using the `createInsightsBookingService()` helper:
Add the tRPC endpoint in the insights router using the `getInsightsBookingService()` DI container function:
```typescript
// packages/features/insights/server/trpc-router.ts
@@ -118,6 +118,7 @@ export const insightsRouter = router({
myNewChartData: userBelongsToTeamProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
// `createInsightsBookingService` is defined at the root level in this file
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
@@ -129,13 +130,13 @@ export const insightsRouter = router({
});
```
## Step 5: Add Service Method to InsightsBookingService
## Step 5: Add Service Method to InsightsBookingBaseService
Add your new method to the `InsightsBookingService` class:
Add your new method to the `InsightsBookingBaseService` class:
```typescript
// packages/lib/server/service/insightsBooking.ts
export class InsightsBookingService {
// packages/lib/server/service/InsightsBookingBaseService.ts
export class InsightsBookingBaseService {
// ... existing methods
async getMyNewChartData() {
@@ -168,7 +169,7 @@ export class InsightsBookingService {
## Best Practices
1. **Use `createInsightsBookingService()`**: Always use the helper function for consistent service creation
1. **Use `getInsightsBookingService()`**: Always use the DI container function for consistent service creation
2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance
3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions
4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError`
@@ -9,8 +9,8 @@ import {
routingRepositoryBaseInputSchema,
bookingRepositoryBaseInputSchema,
} from "@calcom/features/insights/server/raw-data.schema";
import { getInsightsBookingService } from "@calcom/lib/di/containers/insights-booking";
import { getInsightsRoutingService } from "@calcom/lib/di/containers/insights-routing";
import { InsightsBookingService } from "@calcom/lib/server/service/insightsBooking";
import type { readonlyPrisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
@@ -316,14 +316,13 @@ const BATCH_SIZE = 1000; // Adjust based on your needs
* Helper function to create InsightsBookingService with standardized parameters
*/
function createInsightsBookingService(
ctx: { insightsDb: typeof readonlyPrisma; user: { id: number; organizationId: number | null } },
ctx: { user: { id: number; organizationId: number | null } },
input: z.infer<typeof bookingRepositoryBaseInputSchema>,
dateTarget: "createdAt" | "startTime" = "createdAt"
) {
const { scope, selectedTeamId, startDate, endDate, columnFilters } = input;
return new InsightsBookingService({
prisma: ctx.insightsDb,
return getInsightsBookingService({
options: {
scope,
userId: ctx.user.id,
+1 -1
View File
@@ -1,7 +1,7 @@
export interface IRedisService {
get: <TData>(key: string) => Promise<TData | null>;
set: <TData>(key: string, value: TData) => Promise<"OK" | TData | null>;
set: <TData>(key: string, value: TData, opts?: { ttl?: number }) => Promise<"OK" | TData | null>;
expire: (key: string, seconds: number) => Promise<0 | 1>;
@@ -0,0 +1,32 @@
import type { IRedisService } from "./IRedisService";
/**
* Noop implementation of IRedisService for testing or fallback scenarios.
*/
export class NoopRedisService implements IRedisService {
async get<TData>(_key: string): Promise<TData | null> {
return null;
}
async del(_key: string): Promise<number> {
return 0;
}
async set<TData>(_key: string, _value: TData, _opts?: { ttl?: number }): Promise<"OK" | TData | null> {
return "OK";
}
async expire(_key: string, _seconds: number): Promise<0 | 1> {
// Implementation for setting expiration time for key in Redis
return 0;
}
async lrange<TResult = string>(_key: string, _start: number, _end: number): Promise<TResult[]> {
return [];
}
async lpush<TData>(_key: string, ..._elements: TData[]): Promise<number> {
return 0;
}
}
+17 -4
View File
@@ -6,7 +6,13 @@ export class RedisService implements IRedisService {
private redis: Redis;
constructor() {
this.redis = Redis.fromEnv();
// Ensure we throw an Error to mimick old behavior
if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) {
throw new Error("Attempted to initialize Upstash Redis client without url or token.");
}
this.redis = Redis.fromEnv({
signal: () => AbortSignal.timeout(2000),
});
}
async get<TData>(key: string): Promise<TData | null> {
@@ -17,9 +23,16 @@ export class RedisService implements IRedisService {
return this.redis.del(key);
}
async set<TData>(key: string, value: TData): Promise<"OK" | TData | null> {
// Implementation for setting value in Redis
return this.redis.set(key, value);
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
return this.redis.set(
key,
value,
opts?.ttl
? {
px: opts.ttl,
}
: undefined
);
}
async expire(key: string, seconds: number): Promise<0 | 1> {
+17
View File
@@ -0,0 +1,17 @@
import { createModule } from "@evyweb/ioctopus";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { NoopRedisService } from "../NoopRedisService";
import { RedisService } from "../RedisService";
const redisModule = createModule();
redisModule.bind(DI_TOKENS.REDIS_CLIENT).toFactory(() => {
if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
return new RedisService();
}
return new NoopRedisService();
}, "singleton");
export { redisModule };
+2 -1
View File
@@ -151,6 +151,7 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
src={avatar}
alt="Profile picture"
width="160"
height="160"
/>
))}
{avatars.length > 3 && (
@@ -217,7 +218,7 @@ export const App = ({ name, description, slug }: AppImageProps) => (
<div tw="flex items-center w-full">
<div tw="flex">
<img src={`${WEBAPP_URL}${slug}`} alt="App icon" width="172" />
<img src={`${WEBAPP_URL}${slug}`} alt="App icon" width="172" height="172" />
</div>
</div>
<div style={{ color: "#111827" }} tw="flex mt-auto w-full flex-col">
@@ -1,5 +1,6 @@
import { createContainer } from "@evyweb/ioctopus";
import { redisModule } from "@calcom/features/redis/di/redisModule";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
@@ -16,8 +17,10 @@ import { scheduleRepositoryModule } from "../modules/schedule";
import { selectedSlotsRepositoryModule } from "../modules/selectedSlots";
import { teamRepositoryModule } from "../modules/team";
import { userRepositoryModule } from "../modules/user";
import { getUserAvailabilityModule } from "../modules/get-user-availability";
const container = createContainer();
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
container.load(DI_TOKENS.SCHEDULE_REPOSITORY_MODULE, scheduleRepositoryModule);
@@ -31,7 +34,7 @@ container.load(DI_TOKENS.FEATURES_REPOSITORY_MODULE, featuresRepositoryModule);
container.load(DI_TOKENS.CACHE_SERVICE_MODULE, cacheModule);
container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimitsModule);
container.load(DI_TOKENS.AVAILABLE_SLOTS_SERVICE_MODULE, availableSlotsModule);
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule)
export function getAvailableSlotsService() {
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
}
@@ -0,0 +1,26 @@
import { createContainer } from "@evyweb/ioctopus";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import { getUserAvailabilityModule } from "../modules/get-user-availability";
import { bookingRepositoryModule } from "../modules/booking";
import { eventTypeRepositoryModule } from "../modules/eventType";
import { redisModule } from "@calcom/features/redis/di/redisModule";
import { oooRepositoryModule } from "../modules/ooo";
import { UserAvailabilityService } from "../../getUserAvailability";
const container = createContainer();
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule);
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule);
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
export function getUserAvailabilityService() {
return container.get<UserAvailabilityService>(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE);
}
@@ -0,0 +1,27 @@
import { createContainer } from "@evyweb/ioctopus";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import type {
InsightsBookingServicePublicOptions,
InsightsBookingServiceFilterOptions,
InsightsBookingBaseService,
} from "@calcom/lib/server/service/InsightsBookingBaseService";
import type { InsightsBookingService } from "@calcom/lib/server/service/InsightsBookingDIService";
import { prismaModule } from "@calcom/prisma/prisma.module";
import { insightsBookingModule } from "../modules/insights-booking";
export function getInsightsBookingService({
options,
filters,
}: {
options: InsightsBookingServicePublicOptions;
filters?: InsightsBookingServiceFilterOptions;
}): InsightsBookingBaseService {
const container = createContainer();
container.load(DI_TOKENS.READ_ONLY_PRISMA_CLIENT, prismaModule);
container.load(DI_TOKENS.INSIGHTS_BOOKING_SERVICE_MODULE, insightsBookingModule);
const diService = container.get<InsightsBookingService>(DI_TOKENS.INSIGHTS_BOOKING_SERVICE);
return diService.create({ options, filters });
}
@@ -15,6 +15,8 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
bookingRepo: DI_TOKENS.BOOKING_REPOSITORY,
eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY,
routingFormResponseRepo: DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY,
redisClient: DI_TOKENS.REDIS_CLIENT,
cacheService: DI_TOKENS.CACHE_SERVICE,
checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE,
userAvailabilityService: DI_TOKENS.GET_USER_AVAILABILITY_SERVICE
} satisfies Record<keyof IAvailableSlotsService, symbol>);
@@ -0,0 +1,12 @@
import { createModule } from "@evyweb/ioctopus";
import { DI_TOKENS } from "../tokens";
import { IUserAvailabilityService, UserAvailabilityService } from "../../getUserAvailability";
export const getUserAvailabilityModule = createModule();
getUserAvailabilityModule.bind(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE).toClass(UserAvailabilityService, {
oooRepo: DI_TOKENS.OOO_REPOSITORY,
bookingRepo: DI_TOKENS.BOOKING_REPOSITORY,
eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY,
redisClient: DI_TOKENS.REDIS_CLIENT,
} satisfies Record<keyof IUserAvailabilityService, symbol>);
@@ -0,0 +1,11 @@
import { createModule } from "@evyweb/ioctopus";
import type { IInsightsBookingService } from "@calcom/lib/server/service/InsightsBookingDIService";
import { InsightsBookingService } from "@calcom/lib/server/service/InsightsBookingDIService";
import { DI_TOKENS } from "../tokens";
export const insightsBookingModule = createModule();
insightsBookingModule.bind(DI_TOKENS.INSIGHTS_BOOKING_SERVICE).toClass(InsightsBookingService, {
prisma: DI_TOKENS.READ_ONLY_PRISMA_CLIENT,
} satisfies Record<keyof IInsightsBookingService, symbol>);
+5
View File
@@ -2,6 +2,7 @@ export const DI_TOKENS = {
PRISMA_CLIENT: Symbol("PrismaClient"),
READ_ONLY_PRISMA_CLIENT: Symbol("ReadOnlyPrismaClient"),
PRISMA_MODULE: Symbol("PrismaModule"),
REDIS_CLIENT: Symbol("RedisClient"),
OOO_REPOSITORY: Symbol("OOORepository"),
OOO_REPOSITORY_MODULE: Symbol("OOORepositoryModule"),
SCHEDULE_REPOSITORY: Symbol("ScheduleRepository"),
@@ -22,6 +23,8 @@ export const DI_TOKENS = {
AVAILABLE_SLOTS_SERVICE_MODULE: Symbol("AvailableSlotsModule"),
INSIGHTS_ROUTING_SERVICE: Symbol("InsightsRoutingService"),
INSIGHTS_ROUTING_SERVICE_MODULE: Symbol("InsightsRoutingServiceModule"),
INSIGHTS_BOOKING_SERVICE: Symbol("InsightsBookingService"),
INSIGHTS_BOOKING_SERVICE_MODULE: Symbol("InsightsBookingServiceModule"),
FEATURES_REPOSITORY: Symbol("FeaturesRepository"),
FEATURES_REPOSITORY_MODULE: Symbol("FeaturesRepositoryModule"),
CACHE_SERVICE: Symbol("CacheService"),
@@ -30,4 +33,6 @@ export const DI_TOKENS = {
CHECK_BOOKING_LIMITS_SERVICE_MODULE: Symbol("CheckBookingLimitsServiceModule"),
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE: Symbol("CheckBookingAndDurationLimitsService"),
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE_MODULE: Symbol("CheckBookingAndDurationLimitsServiceModule"),
GET_USER_AVAILABILITY_SERVICE: Symbol("GetUserAvailabilityService"),
GET_USER_AVAILABILITY_SERVICE_MODULE: Symbol("GetUserAvailabilityModule"),
};
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@ import dayjs from "@calcom/dayjs";
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
import { getStartEndDateforLimitCheck } from "@calcom/lib/getBusyTimes";
import type { EventType } from "@calcom/lib/getUserAvailability";
import { getPeriodStartDatesBetween } from "@calcom/lib/getUserAvailability";
import { getPeriodStartDatesBetween } from "@calcom/lib/intervalLimits/utils/getPeriodStartDatesBetween";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { performance } from "@calcom/lib/server/perfObserver";
import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking";
@@ -0,0 +1,24 @@
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { IntervalLimitUnit } from "../intervalLimitSchema";
function _getPeriodStartDatesBetween(
dateFrom: Dayjs,
dateTo: Dayjs,
period: IntervalLimitUnit,
timeZone?: string
): Dayjs[] {
const dates = [];
let startDate = timeZone ? dayjs(dateFrom).tz(timeZone).startOf(period) : dayjs(dateFrom).startOf(period);
const endDate = timeZone ? dayjs(dateTo).tz(timeZone).endOf(period) : dayjs(dateTo).endOf(period);
while (startDate.isBefore(endDate)) {
dates.push(startDate);
startDate = startDate.add(1, period);
}
return dates;
}
export const getPeriodStartDatesBetween = withReporting(_getPeriodStartDatesBetween, "getPeriodStartDatesBetween");
+22 -15
View File
@@ -1,12 +1,13 @@
import { createInstance } from "i18next";
/* eslint-disable @typescript-eslint/no-var-requires */
const { i18n } = require("@calcom/config/next-i18next.config");
import { WEBAPP_URL } from "@calcom/lib/constants";
import { fetchWithTimeout } from "../fetchWithTimeout";
import logger from "../logger";
/* eslint-disable @typescript-eslint/no-var-requires */
const { i18n } = require("@calcom/config/next-i18next.config");
const translationCache = new Map<string, Record<string, string>>();
const i18nInstanceCache = new Map<string, any>();
const SUPPORTED_NAMESPACES = ["common"];
@@ -28,22 +29,28 @@ export async function loadTranslations(_locale: string, _ns: string) {
}
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
const response = await fetchWithTimeout(
url,
{
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
},
3000
);
try {
const response = await fetchWithTimeout(
url,
{
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
},
3000
);
if (!response.ok) {
logger.error(`Failed to fetch translations: ${response.status}`);
return {};
}
const translations = await response.json();
translationCache.set(cacheKey, translations);
return translations;
} catch (err) {
console.error("loadTranslations Error:", err);
if (!response.ok) {
logger.error(`Failed to fetch translations: ${response.status}`);
return {};
}
const translations = await response.json();
translationCache.set(cacheKey, translations);
return translations;
}
/**
+22
View File
@@ -869,4 +869,26 @@ export class BookingRepository {
},
});
}
async findAcceptedBookingByEventTypeId({eventTypeId, dateFrom, dateTo}: {eventTypeId?: number, dateFrom: string, dateTo: string}) {
return this.prismaClient.booking.findMany({
where: {
eventTypeId,
startTime: {
gte: dateFrom,
lte: dateTo,
},
status: BookingStatus.ACCEPTED,
},
select: {
uid: true,
startTime: true,
attendees: {
select: {
email: true,
},
},
},
});
}
}
@@ -1299,4 +1299,93 @@ export class EventTypeRepository {
}) {
return user.allSelectedCalendars.filter((calendar) => calendar.eventTypeId === eventTypeId);
}
async findByIdForUserAvailability({ id }: { id: number }) {
const eventType = await this.prismaClient.eventType.findUnique({
where: { id },
select: {
id: true,
seatsPerTimeSlot: true,
bookingLimits: true,
useEventLevelSelectedCalendars: true,
parent: {
select: {
team: {
select: {
id: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
},
},
},
},
team: {
select: {
id: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
},
},
hosts: {
select: {
user: {
select: {
email: true,
id: true,
},
},
schedule: {
select: {
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
timeZone: true,
id: true,
},
},
},
},
durationLimits: true,
assignAllTeamMembers: true,
schedulingType: true,
timeZone: true,
length: true,
metadata: true,
schedule: {
select: {
id: true,
availability: {
select: {
days: true,
date: true,
startTime: true,
endTime: true,
},
},
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
date: true,
},
},
},
});
if (!eventType) {
return eventType;
}
return {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
};
}
}
+67
View File
@@ -79,4 +79,71 @@ export class PrismaOOORepository {
},
});
}
async findUserOOODays({userId, dateTo, dateFrom}: {userId: number, dateTo: string, dateFrom: string}) {
return this.prismaClient.outOfOfficeEntry.findMany({
where: {
userId,
OR: [
// outside of range
// (start <= 'dateTo' AND end >= 'dateFrom')
{
start: {
lte: dateTo,
},
end: {
gte: dateFrom,
},
},
// start is between dateFrom and dateTo but end is outside of range
// (start <= 'dateTo' AND end >= 'dateTo')
{
start: {
lte: dateTo,
},
end: {
gte: dateTo,
},
},
// end is between dateFrom and dateTo but start is outside of range
// (start <= 'dateFrom' OR end <= 'dateTo')
{
start: {
lte: dateFrom,
},
end: {
lte: dateTo,
},
},
],
},
select: {
id: true,
start: true,
end: true,
user: {
select: {
id: true,
name: true,
},
},
toUser: {
select: {
id: true,
username: true,
name: true,
},
},
reason: {
select: {
id: true,
emoji: true,
reason: true,
},
},
},
});
}
}
@@ -0,0 +1,432 @@
import { describe, expect, beforeAll, afterAll, beforeEach, it } from "vitest";
import prisma from "@calcom/prisma";
import { PrismaQueuedFormResponseRepository } from "./PrismaQueuedFormResponseRepository";
const repository = new PrismaQueuedFormResponseRepository(prisma);
// Test Data Builders
const createTestUser = async (overrides?: { email?: string; username?: string }) => {
const timestamp = Date.now();
return prisma.user.create({
data: {
email: overrides?.email || `test-qfr-${timestamp}@example.com`,
username: overrides?.username || `test-qfr-${timestamp}`,
},
});
};
const createTestForm = async (userId: number, overrides?: { name?: string }) => {
return prisma.app_RoutingForms_Form.create({
data: {
name: overrides?.name || `Test Form ${Date.now()}`,
userId,
fields: [],
routes: [],
settings: {},
},
});
};
const createFormResponse = async (
formId: string,
overrides?: {
response?: Record<string, unknown>;
chosenRouteId?: string;
}
) => {
return prisma.app_RoutingForms_FormResponse.create({
data: {
formId,
response: overrides?.response || { test: "response" },
chosenRouteId: overrides?.chosenRouteId || "test-route-id",
},
});
};
const createQueuedResponse = async (
formId: string,
overrides?: {
response?: Record<string, unknown>;
chosenRouteId?: string;
actualResponseId?: number | null;
createdAt?: Date;
}
) => {
return prisma.app_RoutingForms_QueuedFormResponse.create({
data: {
formId,
response: overrides?.response || { field1: "value" },
chosenRouteId: overrides?.chosenRouteId || "test-route-id",
actualResponseId: overrides?.actualResponseId ?? null,
...(overrides?.createdAt && { createdAt: overrides.createdAt }),
},
});
};
const createQueuedResponsesBatch = async (
formId: string,
count: number,
baseOverrides?: Parameters<typeof createQueuedResponse>[1]
) => {
const responses = [];
for (let i = 0; i < count; i++) {
const response = await createQueuedResponse(formId, {
...baseOverrides,
response: { field1: `value${i}` },
});
responses.push(response);
}
return responses;
};
// Date Helpers
const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// Assertion Helpers
const expectQueuedResponseCount = async (formId: string, expectedCount: number) => {
const count = await prisma.app_RoutingForms_QueuedFormResponse.count({
where: { formId },
});
expect(count).toBe(expectedCount);
};
const expectQueuedResponseExists = async (responseId: string) => {
const response = await prisma.app_RoutingForms_QueuedFormResponse.findUnique({
where: { id: responseId },
});
expect(response).not.toBeNull();
};
const expectQueuedResponseNotExists = async (responseId: string) => {
const response = await prisma.app_RoutingForms_QueuedFormResponse.findUnique({
where: { id: responseId },
});
expect(response).toBeNull();
};
const expectResponseFields = (response: unknown) => {
const expectedFields = [
"id",
"formId",
"response",
"chosenRouteId",
"createdAt",
"updatedAt",
"actualResponseId",
];
expectedFields.forEach((field) => {
expect(response).toHaveProperty(field);
});
expect(Object.keys(response as object).sort()).toEqual(expectedFields.sort());
};
// Cleanup Helpers
const cleanupQueuedResponses = async (formId: string) => {
await prisma.app_RoutingForms_QueuedFormResponse.deleteMany({
where: { formId },
});
};
describe("PrismaQueuedFormResponseRepository Integration Tests", () => {
let testForm: { id: string };
let testUser: { id: number };
let testFormResponse: { id: number };
let testResponses: Array<{ id: string; actualResponseId: number | null; createdAt: Date }> = [];
beforeAll(async () => {
// Create test user
testUser = await createTestUser();
// Create test form
testForm = await createTestForm(testUser.id);
// Create test form response for actualResponseId references
testFormResponse = await createFormResponse(testForm.id);
});
afterAll(async () => {
// Clean up all test data
await prisma.app_RoutingForms_QueuedFormResponse.deleteMany({
where: { formId: testForm.id },
});
await prisma.app_RoutingForms_FormResponse.deleteMany({
where: { formId: testForm.id },
});
await prisma.app_RoutingForms_Form.delete({
where: { id: testForm.id },
});
await prisma.user.delete({
where: { id: testUser.id },
});
await prisma.$disconnect();
});
beforeEach(async () => {
// Clear queued responses before each test
await cleanupQueuedResponses(testForm.id);
testResponses = [];
});
describe("findMany", () => {
it("should find queued responses by actualResponseId", async () => {
// Create additional form responses for testing
const formResponse1 = await createFormResponse(testForm.id, {
response: { field1: "form-response-1" },
});
const formResponse2 = await createFormResponse(testForm.id, {
response: { field1: "form-response-2" },
});
// Create test queued responses
const queuedResponse1 = await createQueuedResponse(testForm.id, {
response: { field1: "value1" },
actualResponseId: formResponse1.id,
});
const queuedResponse2 = await createQueuedResponse(testForm.id, {
response: { field1: "value2" },
actualResponseId: formResponse2.id,
});
const queuedResponse3 = await createQueuedResponse(testForm.id, {
response: { field1: "value3" },
actualResponseId: null,
});
testResponses.push(queuedResponse1, queuedResponse2, queuedResponse3);
// Test finding by specific actualResponseId
const result = await repository.findMany({
where: { actualResponseId: formResponse1.id },
});
expect(result).toHaveLength(1);
expect(result[0].id).toBe(queuedResponse1.id);
expect(result[0].actualResponseId).toBe(formResponse1.id);
expect(result[0].formId).toBe(testForm.id);
expect(result[0].response).toEqual({ field1: "value1" });
});
it("should find queued responses by actualResponseId null", async () => {
// Create test queued responses
await createQueuedResponse(testForm.id, {
response: { field1: "value1" },
actualResponseId: testFormResponse.id,
});
const queuedResponse2 = await createQueuedResponse(testForm.id, {
response: { field1: "value2" },
actualResponseId: null,
});
const queuedResponse3 = await createQueuedResponse(testForm.id, {
response: { field1: "value3" },
actualResponseId: null,
});
// Test finding by actualResponseId null
const result = await repository.findMany({
where: { actualResponseId: null },
});
expect(result).toHaveLength(2);
const resultIds = result.map((r) => r.id).sort();
const expectedIds = [queuedResponse2.id, queuedResponse3.id].sort();
expect(resultIds).toEqual(expectedIds);
});
it("should find queued responses created before a specific date", async () => {
// Create test queued responses with different timestamps
const now = new Date();
const oneDayAgo = daysAgo(1);
const twoDaysAgo = daysAgo(2);
// Create responses with specific timestamps
const oldResponse1 = await createQueuedResponse(testForm.id, {
response: { field1: "old1" },
createdAt: twoDaysAgo,
});
const oldResponse2 = await createQueuedResponse(testForm.id, {
response: { field1: "old2" },
createdAt: twoDaysAgo,
});
const recentResponse = await createQueuedResponse(testForm.id, {
response: { field1: "recent" },
createdAt: now,
});
testResponses.push(oldResponse1, oldResponse2, recentResponse);
// Test finding responses created before oneDayAgo
const result = await repository.findMany({
where: { createdAt: { lt: oneDayAgo } },
});
expect(result).toHaveLength(2);
const resultIds = result.map((r) => r.id).sort();
const expectedIds = [oldResponse1.id, oldResponse2.id].sort();
expect(resultIds).toEqual(expectedIds);
});
it("should apply take parameter to limit results", async () => {
// Create multiple test queued responses
const responses = await createQueuedResponsesBatch(testForm.id, 5, { actualResponseId: null });
testResponses.push(...responses);
// Test with take parameter
const result = await repository.findMany({
where: { actualResponseId: null },
params: { take: 3 },
});
expect(result).toHaveLength(3);
});
it("should handle combined where conditions", async () => {
// Create test queued responses
const now = new Date();
const oneDayAgo = daysAgo(1);
const twoDaysAgo = daysAgo(2);
const oldWithNull = await createQueuedResponse(testForm.id, {
response: { field1: "old-null" },
actualResponseId: null,
createdAt: twoDaysAgo,
});
await createQueuedResponse(testForm.id, {
response: { field1: "old-with-id" },
actualResponseId: testFormResponse.id,
createdAt: twoDaysAgo,
});
await createQueuedResponse(testForm.id, {
response: { field1: "recent-null" },
actualResponseId: null,
createdAt: now,
});
// Test finding old responses with null actualResponseId
const result = await repository.findMany({
where: {
actualResponseId: null,
createdAt: { lt: oneDayAgo },
},
});
expect(result).toHaveLength(1);
expect(result[0].id).toBe(oldWithNull.id);
});
it("should throw error when where is empty", async () => {
await expect(
repository.findMany({
where: {},
})
).rejects.toThrow("where is empty");
});
it("should throw error when where has only undefined values", async () => {
await expect(
repository.findMany({
where: {
actualResponseId: undefined,
createdAt: undefined,
},
})
).rejects.toThrow("where is empty");
});
it("should return empty array when no matches found", async () => {
const result = await repository.findMany({
where: { actualResponseId: 999999 },
});
expect(result).toHaveLength(0);
expect(result).toEqual([]);
});
it("should return all fields defined in safeSelect", async () => {
const formResponseForTest = await createFormResponse(testForm.id, {
response: { field1: "form-response-test" },
});
const response = await createQueuedResponse(testForm.id, {
response: { field1: "test-value" },
actualResponseId: formResponseForTest.id,
});
const result = await repository.findMany({
where: { actualResponseId: formResponseForTest.id },
});
expect(result).toHaveLength(1);
const foundResponse = result[0];
// Verify all fields from safeSelect are present
expect(foundResponse).toHaveProperty("id", response.id);
expect(foundResponse).toHaveProperty("formId", response.formId);
expect(foundResponse).toHaveProperty("response", response.response);
expect(foundResponse).toHaveProperty("chosenRouteId", response.chosenRouteId);
expect(foundResponse).toHaveProperty("createdAt");
expect(foundResponse).toHaveProperty("updatedAt");
expect(foundResponse).toHaveProperty("actualResponseId", response.actualResponseId);
// Verify no extra fields are included
expectResponseFields(foundResponse);
});
});
describe("deleteByIds", () => {
it("should delete queued responses by their ids", async () => {
// Create test queued responses
const response1 = await createQueuedResponse(testForm.id, { response: { field1: "value1" } });
const response2 = await createQueuedResponse(testForm.id, { response: { field1: "value2" } });
const response3 = await createQueuedResponse(testForm.id, { response: { field1: "value3" } });
// Delete response1 and response2
const result = await repository.deleteByIds([response1.id, response2.id]);
expect(result.count).toBe(2);
// Verify only response3 remains
await expectQueuedResponseCount(testForm.id, 1);
await expectQueuedResponseExists(response3.id);
});
it("should return count 0 when deleting non-existent ids", async () => {
const result = await repository.deleteByIds(["non-existent-id-1", "non-existent-id-2"]);
expect(result.count).toBe(0);
});
it("should handle empty array of ids", async () => {
const result = await repository.deleteByIds([]);
expect(result.count).toBe(0);
});
it("should handle single id deletion", async () => {
const response = await createQueuedResponse(testForm.id, { response: { field1: "single-delete" } });
const result = await repository.deleteByIds([response.id]);
expect(result.count).toBe(1);
// Verify it's deleted
await expectQueuedResponseNotExists(response.id);
});
it("should handle mix of existing and non-existing ids", async () => {
const response = await createQueuedResponse(testForm.id, { response: { field1: "mixed-delete" } });
const result = await repository.deleteByIds([response.id, "non-existent-id"]);
expect(result.count).toBe(1);
});
});
});
@@ -0,0 +1,53 @@
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import type {
QueuedFormResponseRepositoryFindManyArgs,
QueuedFormResponseRepositoryFindManyWhere,
QueuedFormResponseRepositoryInterface,
} from "./QueuedFormResponseRepository.interface";
const safeSelect = {
id: true,
formId: true,
response: true,
chosenRouteId: true,
createdAt: true,
updatedAt: true,
actualResponseId: true,
};
export class PrismaQueuedFormResponseRepository implements QueuedFormResponseRepositoryInterface {
constructor(private readonly prismaClient: PrismaClient = prisma) {}
private assertWhereIsNotEmpty(where: QueuedFormResponseRepositoryFindManyWhere) {
let found = false;
Object.entries(where).forEach(([_, value]) => {
if (value !== undefined) {
found = true;
}
});
if (!found) {
throw new Error("where is empty");
}
}
async findMany({ where, params }: QueuedFormResponseRepositoryFindManyArgs) {
this.assertWhereIsNotEmpty(where);
return await this.prismaClient.app_RoutingForms_QueuedFormResponse.findMany({
where,
select: safeSelect,
...(params?.take && { take: params.take }),
});
}
async deleteByIds(ids: string[]) {
return await this.prismaClient.app_RoutingForms_QueuedFormResponse.deleteMany({
where: {
id: {
in: ids,
},
},
});
}
}
@@ -0,0 +1,30 @@
export type QueuedFormResponseRepositoryFindManyWhere = {
actualResponseId?: number | null;
createdAt?: {
lt: Date;
};
};
export type QueuedFormResponseRepositoryFindManyArgs = {
where: QueuedFormResponseRepositoryFindManyWhere;
params?: {
take?: number;
};
};
export type QueuedFormResponseRepositoryFindManyResult = {
id: string;
formId: string;
response: unknown;
chosenRouteId: string | null;
createdAt: Date;
updatedAt: Date | null;
actualResponseId: number | null;
};
export interface QueuedFormResponseRepositoryInterface {
findMany(
args: QueuedFormResponseRepositoryFindManyArgs
): Promise<QueuedFormResponseRepositoryFindManyResult[]>;
deleteByIds(ids: string[]): Promise<{ count: number }>;
}
@@ -135,7 +135,7 @@ const NOTHING_CONDITION = Prisma.sql`1=0`;
const bookingDataKeys = new Set(Object.keys(bookingDataSchema.shape));
export class InsightsBookingService {
export class InsightsBookingBaseService {
private prisma: typeof readonlyPrisma;
private options: InsightsBookingServiceOptions | null;
private filters: InsightsBookingServiceFilterOptions | null;
@@ -0,0 +1,29 @@
import type { readonlyPrisma } from "@calcom/prisma";
import {
InsightsBookingBaseService,
type InsightsBookingServicePublicOptions,
type InsightsBookingServiceFilterOptions,
} from "./InsightsBookingBaseService";
export interface IInsightsBookingService {
prisma: typeof readonlyPrisma;
}
export class InsightsBookingService {
constructor(private readonly dependencies: IInsightsBookingService) {}
create({
options,
filters,
}: {
options: InsightsBookingServicePublicOptions;
filters?: InsightsBookingServiceFilterOptions;
}): InsightsBookingBaseService {
return new InsightsBookingBaseService({
prisma: this.dependencies.prisma,
options,
filters,
});
}
}
@@ -6,7 +6,7 @@ import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import prisma from "@calcom/prisma";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { InsightsBookingService } from "../../service/insightsBooking";
import { InsightsBookingBaseService as InsightsBookingService } from "../InsightsBookingBaseService";
const NOTHING_CONDITION = Prisma.sql`1=0`;
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type logger from "@calcom/lib/logger";
import type { QueuedFormResponseRepositoryInterface } from "../../repository/routingForm/queuedFormResponse/QueuedFormResponseRepository.interface";
import { QueuedFormResponseService } from "./QueuedFormResponseService";
describe("QueuedFormResponseService", () => {
let service: QueuedFormResponseService;
let mockRepo: jest.Mocked<QueuedFormResponseRepositoryInterface>;
let mockLogger: ReturnType<typeof createMockLogger>;
function createMockLogger() {
const subLogger = {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
return {
getSubLogger: vi.fn().mockReturnValue(subLogger),
subLogger,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockRepo = {
findMany: vi.fn(),
deleteByIds: vi.fn(),
} as unknown as jest.Mocked<QueuedFormResponseRepositoryInterface>;
mockLogger = createMockLogger();
service = new QueuedFormResponseService({
logger: mockLogger as unknown as typeof logger,
queuedFormResponseRepo: mockRepo,
});
});
afterEach(() => {
vi.useRealTimers();
});
describe("cleanupExpiredResponses", () => {
it("should clean up expired responses in batches", async () => {
const now = Date.now();
vi.spyOn(Date, "now").mockReturnValue(now);
// Mock first batch
mockRepo.findMany
.mockResolvedValueOnce([{ id: "1" }, { id: "2" }] as any)
.mockResolvedValueOnce([{ id: "3" }] as any)
.mockResolvedValueOnce([]);
mockRepo.deleteByIds.mockResolvedValueOnce({ count: 2 }).mockResolvedValueOnce({ count: 1 });
const resultPromise = service.cleanupExpiredResponses({
batchSize: 2,
});
// Advance timers to handle delays
await vi.runAllTimersAsync();
const result = await resultPromise;
// Verify cutoff time calculation (7 days is hardcoded)
const expectedCutoffTime = new Date(now - 7 * 24 * 60 * 60 * 1000);
expect(mockRepo.findMany).toHaveBeenCalledWith({
where: {
actualResponseId: null,
createdAt: {
lt: expectedCutoffTime,
},
},
params: {
take: 2,
},
});
// Verify deletion calls
expect(mockRepo.deleteByIds).toHaveBeenCalledWith(["1", "2"]);
expect(mockRepo.deleteByIds).toHaveBeenCalledWith(["3"]);
// Verify result
expect(result).toEqual({
count: 3,
batches: 2,
});
});
it("should use default values when config is not provided", async () => {
mockRepo.findMany.mockResolvedValueOnce([]);
const result = await service.cleanupExpiredResponses();
const expectedCutoffTime = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
// Verify default batch size and older than days
expect(mockRepo.findMany).toHaveBeenCalledWith({
where: {
actualResponseId: null,
createdAt: {
lt: expectedCutoffTime,
},
},
params: {
take: 1000, // default batch size
},
});
expect(result).toEqual({
count: 0,
batches: 0,
});
});
it("should handle no expired responses", async () => {
mockRepo.findMany.mockResolvedValueOnce([]);
const result = await service.cleanupExpiredResponses();
expect(mockRepo.deleteByIds).not.toHaveBeenCalled();
expect(result).toEqual({
count: 0,
batches: 0,
});
});
it("should stop when batch is not full", async () => {
// Return partial batch
mockRepo.findMany.mockResolvedValueOnce([{ id: "1" }, { id: "2" }, { id: "3" }]);
mockRepo.deleteByIds.mockResolvedValueOnce({ count: 3 });
const result = await service.cleanupExpiredResponses({ batchSize: 5 });
expect(mockRepo.findMany).toHaveBeenCalledTimes(1);
expect(mockRepo.deleteByIds).toHaveBeenCalledTimes(1);
expect(result).toEqual({
count: 3,
batches: 1,
});
});
it("should handle errors during batch processing", async () => {
mockRepo.findMany.mockRejectedValueOnce(new Error("Database error"));
await expect(service.cleanupExpiredResponses()).rejects.toThrow("Database error");
});
});
});
@@ -0,0 +1,96 @@
import type logger from "@calcom/lib/logger";
import type { QueuedFormResponseRepositoryInterface } from "../../../repository/routingForm/queuedFormResponse/QueuedFormResponseRepository.interface";
interface Dependencies {
logger: typeof logger;
queuedFormResponseRepo: QueuedFormResponseRepositoryInterface;
}
interface CleanupConfig {
batchSize?: number;
}
export class QueuedFormResponseService {
constructor(private readonly deps: Dependencies) {}
private getExpiredResponses({ batchSize }: { batchSize: number }) {
const cutoffTime = this.getExpiryCutoffTime();
return this.deps.queuedFormResponseRepo.findMany({
where: {
actualResponseId: null,
createdAt: {
lt: cutoffTime,
},
},
params: {
take: batchSize,
},
});
}
/**
* Calculate the cutoff time. All responses older than this time could be considered expired.
*/
private getExpiryCutoffTime(): Date {
const olderThanDays = 7;
return new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
}
/**
* Clean up expired queued form responses in batches
*/
async cleanupExpiredResponses(config: CleanupConfig = {}): Promise<{
count: number;
batches: number;
}> {
const { batchSize = 1000 } = config;
const log = this.deps.logger.getSubLogger({
prefix: ["[QueuedFormResponseService]", "cleanupExpiredResponses"],
});
let totalDeleted = 0;
let batchCount = 0;
let hasMore = true;
while (hasMore) {
try {
const expiredResponses = await this.getExpiredResponses({ batchSize });
if (expiredResponses.length === 0) {
hasMore = false;
break;
}
const idsToDelete = expiredResponses.map((r: { id: string }) => r.id);
const deleteResult = await this.deps.queuedFormResponseRepo.deleteByIds(idsToDelete);
totalDeleted += deleteResult.count;
batchCount++;
log.info(`Batch ${batchCount}: Deleted ${deleteResult.count} records. Total: ${totalDeleted}`);
// If we found fewer records than the batch size, we're done
if (expiredResponses.length < batchSize) {
hasMore = false;
}
// Add a small delay between batches to reduce database load
if (hasMore) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
} catch (error) {
log.error(`Error processing batch ${batchCount + 1}:`, error);
throw error;
}
}
log.info(`Cleanup completed. Total deleted: ${totalDeleted}, Batches: ${batchCount}`);
return {
count: totalDeleted,
batches: batchCount,
};
}
}
+1
View File
@@ -4,3 +4,4 @@ export {
} from "@calcom/lib/server/repository/schedule";
export { updateSchedule, type UpdateScheduleResponse } from "@calcom/lib/schedules/updateSchedule";
export {UserAvailabilityService} from "@calcom/lib/getUserAvailability"
Binary file not shown.
@@ -1,4 +1,4 @@
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
import type { TrpcSessionUser } from "../../../types";
import type { TUserInputSchema } from "./user.schema";
@@ -11,7 +11,8 @@ type UserOptions = {
};
export const userHandler = async ({ input }: UserOptions) => {
return getUserAvailability(
const userAvailabilityService = getUserAvailabilityService()
return userAvailabilityService.getUserAvailability(
{ returnDateOverrides: true, bypassBusyCalendarTimes: false, ...input },
undefined
);
@@ -8,6 +8,7 @@ import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import { findQualifiedHostsWithDelegationCredentials } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { shouldIgnoreContactOwner } from "@calcom/lib/bookings/routing/utils";
import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants";
@@ -20,10 +21,10 @@ import type {
CurrentSeats,
EventType,
GetAvailabilityUser,
UserAvailabilityService,
IFromUser,
IToUser,
} from "@calcom/lib/getUserAvailability";
import { getPeriodStartDatesBetween, getUsersAvailability } from "@calcom/lib/getUserAvailability";
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimits/intervalLimit";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
@@ -64,6 +65,7 @@ import { handleNotificationWhenNoSlots } from "./handleNotificationWhenNoSlots";
import type { GetScheduleOptions } from "./types";
const log = logger.getSubLogger({ prefix: ["[slots/util]"] });
const DEFAULT_SLOTS_CACHE_TTL = 2000;
type GetAvailabilityUserWithDelegationCredentials = Omit<GetAvailabilityUser, "credentials"> & {
credentials: CredentialForCalendarService[];
@@ -101,6 +103,47 @@ export interface IAvailableSlotsService {
routingFormResponseRepo: RoutingFormResponseRepository;
cacheService: CacheService;
checkBookingLimitsService: CheckBookingLimitsService;
userAvailabilityService: UserAvailabilityService;
redisClient: IRedisService;
}
function withSlotsCache(
redisClient: IRedisService,
func: (args: GetScheduleOptions) => Promise<IGetAvailableSlots>
) {
return async (args: GetScheduleOptions): Promise<IGetAvailableSlots> => {
const cacheKey = `${JSON.stringify(args.input)}`;
let success = false;
let cachedResult: IGetAvailableSlots | null = null;
const startTime = process.hrtime();
try {
cachedResult = await redisClient.get(cacheKey);
success = true;
} catch (err: unknown) {
if (err instanceof Error && err.name === "TimeoutError") {
const endTime = process.hrtime(startTime);
log.error(`Redis request timed out after ${endTime[0]}${endTime[1] / 1e6}ms`);
} else {
throw err;
}
}
if (!success) {
// If the cache request fails, we proceed to call the function directly
return await func(args);
}
if (cachedResult) {
log.info("[CACHE HIT] Available slots", { cacheKey });
return cachedResult;
}
const result = await func(args);
const ttl = parseInt(process.env.SLOTS_CACHE_TTL ?? "", 10) || DEFAULT_SLOTS_CACHE_TTL;
// we do not wait for the cache to complete setting; we fire and forget, and hope it'll finish.
// this is to already start responding to the client.
redisClient.set(cacheKey, result, { ttl });
log.info("[CACHE MISS] Available slots", { cacheKey, ttl });
return result;
};
}
export class AvailableSlotsService {
@@ -357,7 +400,7 @@ export class AvailableSlotsService {
if (!limit) continue;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
for (const periodStart of periodStartDates) {
if (globalLimitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue;
@@ -392,7 +435,7 @@ export class AvailableSlotsService {
if (!limit) continue;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue;
@@ -443,7 +486,7 @@ export class AvailableSlotsService {
if (!limit) continue;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue;
@@ -543,7 +586,7 @@ export class AvailableSlotsService {
if (!limit) continue;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
for (const periodStart of periodStartDates) {
if (globalLimitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue;
@@ -591,7 +634,7 @@ export class AvailableSlotsService {
if (!limit) continue;
const unit = intervalLimitKeyToUnit(key);
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
const periodStartDates = this.dependencies.userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit, timeZone);
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue;
@@ -818,7 +861,7 @@ export class AvailableSlotsService {
const users = enrichUsersWithData();
// TODO: DI getUsersAvailability
const premappedUsersAvailability = await getUsersAvailability({
const premappedUsersAvailability = await this.dependencies.userAvailabilityService.getUsersAvailability({
users,
query: {
dateFrom: startTime.format(),
@@ -883,7 +926,10 @@ export class AvailableSlotsService {
"getRegularOrDynamicEventType"
);
getAvailableSlots = withReporting(this._getAvailableSlots.bind(this), "getAvailableSlots");
getAvailableSlots = withReporting(
withSlotsCache(this.dependencies.redisClient, this._getAvailableSlots.bind(this)),
"getAvailableSlots"
);
async _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<IGetAvailableSlots> {
const {
@@ -1,5 +1,5 @@
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
import { isTeamMember } from "@calcom/lib/server/queries/teams";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -16,6 +16,7 @@ type GetMemberAvailabilityOptions = {
};
export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvailabilityOptions) => {
const userAvailabilityService = getUserAvailabilityService()
const team = await isTeamMember(ctx.user?.id, input.teamId);
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -32,7 +33,7 @@ export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvai
});
// get availability for this member
return await getUserAvailability(
return await userAvailabilityService.getUserAvailability(
{
username: username,
dateFrom: input.dateFrom,
+2 -1
View File
@@ -490,6 +490,7 @@
"NEXT_PUBLIC_SINGLE_ORG_MODE_ENABLED",
"CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY",
"CAL_VIDEO_RECORDING_TOKEN_SECRET",
"ORGANIZER_EMAIL_EXEMPT_DOMAINS"
"ORGANIZER_EMAIL_EXEMPT_DOMAINS",
"SLOTS_CACHE_TTL"
]
}
+73 -41
View File
@@ -2500,7 +2500,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*"
"@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.287"
"@calcom/platform-types": "*"
"@calcom/platform-utils": "*"
"@calcom/prisma": "*"
@@ -2836,7 +2836,7 @@ __metadata:
"@typescript-eslint/eslint-plugin": ^5.52.0
"@typescript-eslint/parser": ^5.52.0
eslint: ^8.34.0
eslint-config-next: ^15.4.5
eslint-config-next: ^13.2.1
eslint-config-prettier: ^8.6.0
eslint-config-turbo: ^0.0.7
eslint-plugin-playwright: ^0.12.0
@@ -3556,13 +3556,13 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.276":
version: 0.0.276
resolution: "@calcom/platform-libraries@npm:0.0.276"
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.287":
version: 0.0.287
resolution: "@calcom/platform-libraries@npm:0.0.287"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: 4effc186c0301ec35496ebffbe32280f26261667b60057d18207b30636b038cdcc3783b95390eecc8460f3203d0cfe5d732e3b3f89031ff064b1a556321779a8
checksum: 098a50e1b20f70c0f67a332d5f4f6a13c9aaf03f0c0ac9c9ffe1b74c06c607e6650bc0f08bdc1a9bdd89fa25461c0e153580305b845620a1506b23150334c5bd
languageName: node
linkType: hard
@@ -4076,7 +4076,7 @@ __metadata:
"@types/turndown": ^5.0.1
"@types/uuid": 8.3.1
"@unkey/ratelimit": ^0.1.1
"@upstash/redis": ^1.21.0
"@upstash/redis": ^1.35.2
"@vercel/edge-config": ^0.1.1
"@vercel/edge-functions-ui": ^0.2.1
"@vercel/og": ^0.6.3
@@ -8943,6 +8943,15 @@ __metadata:
languageName: node
linkType: hard
"@next/eslint-plugin-next@npm:13.5.11":
version: 13.5.11
resolution: "@next/eslint-plugin-next@npm:13.5.11"
dependencies:
glob: 7.1.7
checksum: ee960ee89b5bc056c00ba667bcdab69469d792ebf682996a5a7abc34366cf350c45f0cfbbac8311e28ec86766d087c968b40d51a84f1eb9f589a4ff1104a708c
languageName: node
linkType: hard
"@next/eslint-plugin-next@npm:14.0.4":
version: 14.0.4
resolution: "@next/eslint-plugin-next@npm:14.0.4"
@@ -8961,15 +8970,6 @@ __metadata:
languageName: node
linkType: hard
"@next/eslint-plugin-next@npm:15.4.5":
version: 15.4.5
resolution: "@next/eslint-plugin-next@npm:15.4.5"
dependencies:
fast-glob: 3.3.1
checksum: b377bbff604a3aa0a5c1c480077cc2430186abe18f9d0de3b192dca44106f06407509a2c8744f49788a09222f950c4cf67fd08beaad011af5053f410692616c3
languageName: node
linkType: hard
"@next/swc-darwin-arm64@npm:13.5.7":
version: 13.5.7
resolution: "@next/swc-darwin-arm64@npm:13.5.7"
@@ -18756,6 +18756,22 @@ __metadata:
languageName: node
linkType: hard
"@unkey/api@npm:2.0.3":
version: 2.0.3
resolution: "@unkey/api@npm:2.0.3"
dependencies:
zod: ^3.20.0
peerDependencies:
"@modelcontextprotocol/sdk": ">=1.5.0 <1.10.0"
peerDependenciesMeta:
"@modelcontextprotocol/sdk":
optional: true
bin:
mcp: bin/mcp-server.js
checksum: 24b876dfc2d83f17889c95891eeb8c18945df6fe75a3fb4582a90c23c9171e1711b48e876c7cf76f596ac66c20424bbe16b1ba810eb450da9f5b654713d02f49
languageName: node
linkType: hard
"@unkey/api@npm:^0.19.4":
version: 0.19.4
resolution: "@unkey/api@npm:0.19.4"
@@ -18774,6 +18790,15 @@ __metadata:
languageName: node
linkType: hard
"@unkey/ratelimit@npm:^2.1.2":
version: 2.1.2
resolution: "@unkey/ratelimit@npm:2.1.2"
dependencies:
"@unkey/api": 2.0.3
checksum: 645959a8f2e458fc671cc4ea3d51497e76ee9ff967a9c233dacd51caaa7d0fa20386cf47ec138026113c18527cd2a24f6f941d0b0c0bd7723c8bf9e17c87baa5
languageName: node
linkType: hard
"@unkey/rbac@npm:^0.1.11":
version: 0.1.11
resolution: "@unkey/rbac@npm:0.1.11"
@@ -18784,12 +18809,12 @@ __metadata:
languageName: node
linkType: hard
"@upstash/redis@npm:^1.21.0":
version: 1.30.1
resolution: "@upstash/redis@npm:1.30.1"
"@upstash/redis@npm:^1.35.2":
version: 1.35.2
resolution: "@upstash/redis@npm:1.35.2"
dependencies:
crypto-js: ^4.2.0
checksum: 1d8083650fbc1760fe4c58d263c62f9c658d08035480927316932df2757f4e987799f7ce752579e7dd871155e96996bb4caf8ad1034af3ab18965d7ca76421cf
uncrypto: ^0.1.3
checksum: 1d54217589418873702f08bdc27d9455ea44df0fce30ace612b5637a1c4c261b762a80fb85d7b3c72d3213e82b8938bb57c413ae44f9f1e713eeab3f31d5539a
languageName: node
linkType: hard
@@ -21550,6 +21575,7 @@ __metadata:
"@snaplet/copycat": ^4.1.0
"@testing-library/jest-dom": ^5.16.5
"@testing-library/react": ^16.0.1
"@unkey/ratelimit": ^2.1.2
"@vercel/functions": ^1.4.0
"@vitest/ui": ^2.1.1
c8: ^7.13.0
@@ -23319,13 +23345,6 @@ __metadata:
languageName: node
linkType: hard
"crypto-js@npm:^4.2.0":
version: 4.2.0
resolution: "crypto-js@npm:4.2.0"
checksum: f051666dbc077c8324777f44fbd3aaea2986f198fe85092535130d17026c7c2ccf2d23ee5b29b36f7a4a07312db2fae23c9094b644cc35f7858b1b4fcaf27774
languageName: node
linkType: hard
"crypto-random-string@npm:^2.0.0":
version: 2.0.0
resolution: "crypto-random-string@npm:2.0.0"
@@ -25896,27 +25915,26 @@ __metadata:
languageName: node
linkType: hard
"eslint-config-next@npm:^15.4.5":
version: 15.4.5
resolution: "eslint-config-next@npm:15.4.5"
"eslint-config-next@npm:^13.2.1":
version: 13.5.11
resolution: "eslint-config-next@npm:13.5.11"
dependencies:
"@next/eslint-plugin-next": 15.4.5
"@rushstack/eslint-patch": ^1.10.3
"@typescript-eslint/eslint-plugin": ^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0
"@typescript-eslint/parser": ^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0
"@next/eslint-plugin-next": 13.5.11
"@rushstack/eslint-patch": ^1.3.3
"@typescript-eslint/parser": ^5.4.2 || ^6.0.0
eslint-import-resolver-node: ^0.3.6
eslint-import-resolver-typescript: ^3.5.2
eslint-plugin-import: ^2.31.0
eslint-plugin-jsx-a11y: ^6.10.0
eslint-plugin-react: ^7.37.0
eslint-plugin-react-hooks: ^5.0.0
eslint-plugin-import: ^2.28.1
eslint-plugin-jsx-a11y: ^6.7.1
eslint-plugin-react: ^7.33.2
eslint-plugin-react-hooks: ^4.5.0 || 5.0.0-canary-7118f5dd7-20230705
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
eslint: ^7.23.0 || ^8.0.0
typescript: ">=3.3.1"
peerDependenciesMeta:
typescript:
optional: true
checksum: c4f61ee00fc29d669477037481557a282ab080e0276da3e02e187ad54ae543bd2ca22668ee73fbf7212f69ed9d1866cc6fb648a9e5726d7b2c33875b4dd82722
checksum: 6694d86f779f2a6e820873db6641d43cdf446aaa2e9c97b13c0019627369538d217f41e4b7349d7a9308a5378a454c51201e47e8eac2e873fb3f0309985a72d6
languageName: node
linkType: hard
@@ -45439,6 +45457,13 @@ __metadata:
languageName: node
linkType: hard
"uncrypto@npm:^0.1.3":
version: 0.1.3
resolution: "uncrypto@npm:0.1.3"
checksum: 07160e08806dd6cea16bb96c3fd54cd70fc801e02fc3c6f86980144d15c9ebbd1c55587f7280a207b3af6cd34901c0d0b77ada5a02c2f7081a033a05acf409e2
languageName: node
linkType: hard
"undici-types@npm:~6.19.2":
version: 6.19.8
resolution: "undici-types@npm:6.19.8"
@@ -47646,6 +47671,13 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.20.0":
version: 3.25.76
resolution: "zod@npm:3.25.76"
checksum: c9a403a62b329188a5f6bd24d5d935d2bba345f7ab8151d1baa1505b5da9f227fb139354b043711490c798e91f3df75991395e40142e6510a4b16409f302b849
languageName: node
linkType: hard
"zod@npm:^3.22.2":
version: 3.24.4
resolution: "zod@npm:3.24.4"