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
github-actions[bot] 2965450929 chore: version packages (#22890)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-05 06:15:25 +00:00
ddoemonnandKartik Saini db9b9c45dc fix(event-types): remove gap in URL input for event creation (#22123)
* fix(event-types): remove gap in URL input for event creation

* fix: event setup tab spacing

* fix: space in profile view

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-05 03:51:30 +00:00
emrysal 37f500526c chore: release v5.5.11 2025-08-05 00:46:56 +00:00
Eunjae Lee 006aecb066 fix: add backfill migration for routing response denormalized tables (#21474)
* fix: add backfill migration for routing response denormalized tables

* update script

* implement select type

* improve number handling

* clean up queries

* rename

* handle zero record case

* revert

* rename

* fix division problem

* log clean up

* force adding new rows after removing existing rows

* backfill only missing or wrong data

* remove sleep
2025-08-05 01:40:22 +01:00
Amit Sharma 0829929e98 perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC (#21781)
* perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC

* fix type check

* invalidate cache on webhook list item actions

* fix review comment

* fix merge conflicts
2025-08-05 01:25:31 +01:00
a61148cb51 test: add no show webhooks integration tests (#21697)
* test: add no show webhooks integration tests

* fix

* chore

* chore

* chore

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-05 01:22:44 +01:00
Eunjae Lee 593c48c8b4 refactor: replace tremor with recharts (#22791)
* refactor: replace tremor with recharts

* update tooltip

* clean up types

* replace tremor with recharts

* replace BarList with recharts

* replace ProgressBar

* remove tremor from the repository

* clean up

* fix UserStatsTable

* fix type error

* add explicit return type
2025-08-05 01:21:23 +01:00
Lauris Skraucis fb364971fa fix: atoms fonts for non next apps (#22886)
* fix: atoms fonts for non next apps

* chore: add changeset
2025-08-04 16:51:05 +00:00
Eunjae LeeandAlex van Andel a2205d497d feat: add booking status filter to /insights (#22842)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-04 13:48:17 +00:00
Eunjae LeeandDevanshu Sharma cf4e7a5d2c refactor: add useInsightsRoutingParameters() for InsightsRoutingService (#22792)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-04 14:51:23 +02:00
Anik Dhabal Babu 0e2ee3adb2 chore: add logging for office365 calendar (#22867) 2025-08-04 12:26:57 +00:00
Anik Dhabal Babu dbe8a85cf5 fix: enable auto reording for organizer (#22882) 2025-08-04 12:23:56 +00:00
Anik Dhabal Babu 84210dc1b4 fix: build docs workflow failing in ci (#22883)
* fix: flaky e2e

* fix: build docs failure
2025-08-04 17:36:35 +05:30
Benny Joo 4061bfb3fe fix: refactor i18n loadTranslations and set timeout to 3s (#22878)
* refactor

* use abort controller

* address comment

* fix tests

* fix time

* fix tests

* fix platform library build
2025-08-04 10:30:18 +01:00
138 changed files with 3830 additions and 1648 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"
+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"
};
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { revalidateTag } from "next/cache";
export async function revalidateWebhooksList() {
revalidatePath("/settings/developer/webhooks");
export async function revalidateWebhooksListGetByViewer() {
revalidateTag("viewer.webhook.getByViewer");
}
@@ -1,13 +1,13 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
import { APP_NAME } from "@calcom/lib/constants";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -20,6 +20,17 @@ export const generateMetadata = async () =>
"/settings/developer/webhooks"
);
const getCachedWebhooksList = unstable_cache(
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
return await WebhookRepository.getAllWebhooksByUserId({
userId,
userRole,
});
},
undefined,
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
);
const WebhooksViewServerWrapper = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -27,8 +38,9 @@ const WebhooksViewServerWrapper = async () => {
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
const caller = await createRouterCaller(webhookRouter);
const data = await caller.getByViewer();
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
return <WebhooksView data={data} isAdmin={isAdmin} />;
};
@@ -0,0 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookById(id: string) {
revalidateTag(`viewer.webhook.get:${id}`);
}
@@ -1,5 +1,6 @@
import type { PageProps } from "app/_types";
import { getTranslate, _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
@@ -15,12 +16,24 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/developer/webhooks/${(await params).id}`
);
const getCachedWebhook = (id?: string) => {
const fn = unstable_cache(
async () => {
return await WebhookRepository.findByWebhookId(id);
},
undefined,
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
);
return fn();
};
const Page = async ({ params: _params }: PageProps) => {
const t = await getTranslate();
const params = await _params;
const id = typeof params?.id === "string" ? params.id : undefined;
const webhook = await WebhookRepository.findByWebhookId(id);
const webhook = await getCachedWebhook(id);
return (
<SettingsHeader
@@ -0,0 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookList() {
revalidateTag("viewer.webhook.list");
}
@@ -1,10 +1,16 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
import { APP_NAME } from "@calcom/lib/constants";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,15 +21,25 @@ export const generateMetadata = async () =>
"/settings/developer/webhooks/new"
);
const getCachedWebhooksList = unstable_cache(
async ({ userId }: { userId: number }) => {
return await WebhookRepository.findWebhooksByFilters({ userId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
);
const Page = async () => {
const [appsCaller, webhookCaller] = await Promise.all([
createRouterCaller(appsRouter),
createRouterCaller(webhookRouter),
]);
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
const appsCaller = await createRouterCaller(appsRouter);
const [installedApps, webhooks] = await Promise.all([
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
webhookCaller.list(),
getCachedWebhooksList({ userId: session.user.id }),
]);
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
@@ -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);
@@ -124,8 +124,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"
containerClassName="[&>div]:gap-0"
className={classNames(
"mb-0 mt-0 rounded-md rounded-l-none",
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
@@ -117,7 +117,7 @@ const checkIfUserIsHost = async ({
});
// If eventType exists, it means user is either owner, host or user
return !eventType;
return !!eventType;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
@@ -22,7 +22,6 @@ import {
RecentFeedbackTable,
TimezoneBadge,
} from "@calcom/features/insights/components/booking";
import "@calcom/features/insights/components/tremor.css";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { Download } from "@calcom/features/insights/filters/Download";
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
@@ -1,6 +1,5 @@
"use client";
import { ProgressBar } from "@tremor/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
@@ -16,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { TextField, Label, InputError } from "@calcom/ui/components/form";
import { ProgressBar } from "@calcom/ui/components/progress-bar";
import { showToast } from "@calcom/ui/toast";
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.10",
"version": "5.5.13",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -76,10 +76,9 @@
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^5.17.15",
"@team-plain/typescript-sdk": "^5.9.0",
"@tremor/react": "^2.11.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",
+7 -15
View File
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
await page.keyboard.press("Escape");
// Choose User filter item from dropdown
await addFilter(page, "bookingUserId");
await addFilter(page, "userId");
// Wait for the URL to include bookingUserId
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
// Wait for the URL to include userId
await page.waitForURL((url) => url.toString().includes("userId"));
// Click User filter to see a user list
await openFilter(page, "bookingUserId");
await openFilter(page, "userId");
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(0)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(1)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
// press escape button to close the filter
await page.keyboard.press("Escape");
await clearFilters(page);
await expect(page.url()).not.toContain("bookingUserId");
await expect(page.url()).not.toContain("userId");
});
test("should test download button", async ({ page, users }) => {
@@ -3402,6 +3402,7 @@
"license_key_saved": "License key saved successfully",
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"failed_bookings_by_field": "Failed Bookings By Field",
"failed_bookings": "Failed bookings",
"event_type_no_hosts": "No hosts are assigned to event type",
"cache_status": "Cache Status",
"cache_last_updated": "Last updated: {{timestamp}}",
@@ -3429,5 +3430,7 @@
"usage_based_expiration_description": "This link can be used for {{count}} booking",
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
"stats": "Stats",
"booking_status": "Booking status",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+1 -5
View File
@@ -2,10 +2,6 @@ const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: [
...base.content,
"../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}",
"../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
],
content: [...base.content, "../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}"],
plugins: [...base.plugins, require("tailwindcss-animate")],
};
@@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => {
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: (key: string) => key,
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
+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 -1
View File
@@ -109,7 +109,7 @@
"group": "Embeds",
"icon": "code-simple",
"pages": ["developing/guides/embeds/embed-events"]
}
},
{
"group": "Insights",
"icon": "chart-bar",
+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;
}
}
}
@@ -5,6 +5,7 @@ import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { handleErrorsJson } from "@calcom/lib/errors";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
@@ -91,8 +92,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
"Content-Type": "application/json",
},
});
logger.info("Office365 Calendar: Received calendar response", {
userId: req.session?.user?.id,
status: calRequest.status,
statusText: calRequest.statusText,
url: calRequest.url,
});
let calBody = await handleErrorsJson<{ value: OfficeCalendar[]; "@odata.nextLink"?: string }>(calRequest);
logger.info("Office365 Calendar: handleErrorsJson completed", {
userId: req.session?.user?.id,
calendarCount: calBody.value.length ?? 0,
});
if (typeof responseBody === "string") {
calBody = JSON.parse(responseBody) as { value: OfficeCalendar[] };
}
@@ -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,
@@ -0,0 +1,302 @@
import type { Booking, User, Webhook } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getMeetingSessionsFromRoomName } from "@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName";
import { triggerHostNoShow } from "@calcom/features/tasker/tasks/triggerNoShow/triggerHostNoShow";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { prisma } from "@calcom/prisma";
import { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { scheduleNoShowTriggers } from "./scheduleNoShowTriggers";
vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({
getMeetingSessionsFromRoomName: vi.fn(),
}));
vi.mock("@calcom/features/webhooks/lib/sendPayload", () => ({
sendGenericWebhookPayload: vi.fn().mockResolvedValue({}),
}));
describe("scheduleNoShowTriggers Integration", () => {
let testUser: User;
let hostWebhook: Webhook;
let guestWebhook: Webhook;
const testEventTypeId = 67890;
const testBookingIds = [98765, 98766];
const testBookingId = 123456;
beforeAll(async () => {
testUser = await prisma.user.create({
data: {
email: "test-user-no-show@example.com",
username: "test-user-no-show",
name: "Test User No Show",
},
});
[hostWebhook, guestWebhook] = await Promise.all([
prisma.webhook.create({
data: {
id: "test-host-webhook-id",
userId: testUser.id,
subscriberUrl: "https://example.com/host-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
active: true,
time: 5,
timeUnit: TimeUnit.MINUTE,
},
}),
prisma.webhook.create({
data: {
id: "test-guest-webhook-id",
userId: testUser.id,
subscriberUrl: "https://example.com/guest-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
active: true,
time: 10,
timeUnit: TimeUnit.MINUTE,
},
}),
]);
});
afterAll(async () => {
await prisma.task.deleteMany({
where: {
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
},
});
await prisma.webhook.deleteMany({
where: {
userId: testUser.id,
},
});
await prisma.booking.deleteMany({
where: {
id: testBookingId,
},
});
await prisma.user.deleteMany({
where: {
id: testUser.id,
},
});
await prisma.eventType.deleteMany({
where: {
id: testEventTypeId,
},
});
});
test("scheduling a webhook creates the correct Tasker jobs for no-show webhooks", async () => {
const currentDate = new Date("2023-01-01T10:00:00.000Z");
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
id: testBookingIds[0],
startTime: currentDate,
endTime: new Date("2023-01-01T10:30:00.000Z"),
location: DailyLocationType,
};
await scheduleNoShowTriggers({
booking: bookingData,
organizerUser: { id: testUser.id },
eventTypeId: testEventTypeId,
triggerForUser: true,
});
const createdTasks = await prisma.task.findMany({
where: {
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
},
});
expect(createdTasks.length).toBe(2);
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
expect(hostTask).toBeDefined();
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
expect(guestTask).toBeDefined();
});
test("created task payloads for no-show webhooks are correct", async () => {
const currentDate = new Date("2023-01-01T11:00:00.000Z");
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
id: testBookingIds[1],
startTime: currentDate,
endTime: new Date("2023-01-01T11:30:00.000Z"),
location: DailyLocationType,
};
await scheduleNoShowTriggers({
booking: bookingData,
organizerUser: { id: testUser.id },
eventTypeId: testEventTypeId,
triggerForUser: true,
});
const createdTasks = await prisma.task.findMany({
where: {
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
payload: {
contains: bookingData.id.toString(),
},
},
});
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
expect(hostTask).toBeDefined();
let hostPayload;
try {
hostPayload = JSON.parse(hostTask?.payload ?? "");
} catch (e) {
console.error("Failed to parse host task payload.");
throw e;
}
expect(hostPayload).toEqual({
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
bookingId: bookingData.id,
webhook: expect.objectContaining({
id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
time: 5,
timeUnit: TimeUnit.MINUTE,
}),
});
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
expect(guestTask).toBeDefined();
let guestPayload;
try {
guestPayload = JSON.parse(guestTask?.payload ?? "");
} catch (e) {
console.error("Failed to parse guest task payload.");
throw e;
}
expect(guestPayload).toEqual({
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
bookingId: bookingData.id,
webhook: expect.objectContaining({
id: guestWebhook.id,
subscriberUrl: "https://example.com/guest-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
time: 10,
timeUnit: TimeUnit.MINUTE,
}),
});
});
test("task handler runs properly with the correct payload", async () => {
await prisma.eventType.create({
data: {
id: testEventTypeId,
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: testUser.id,
description: "Test description",
},
});
const now = new Date();
await prisma.booking.create({
data: {
id: testBookingId,
uid: "test-uid-no-show",
title: "Test No-Show",
eventTypeId: testEventTypeId,
status: "ACCEPTED",
startTime: now,
endTime: new Date(now.getTime() + 30 * 60 * 1000),
location: DailyLocationType,
userId: testUser.id,
references: {
create: {
type: "daily_video",
uid: "meeting-test-uid",
},
},
},
});
vi.mocked(getMeetingSessionsFromRoomName).mockResolvedValue({
total_count: 1,
data: [
{
id: "meeting-test-id",
room: "meeting-test-uid",
start_time: Math.floor(now.getTime() / 1000),
duration: 30,
max_participants: 1,
participants: [
{
user_id: "999999",
participant_id: "participant-test-id",
user_name: "Guest User",
join_time: Math.floor(now.getTime() / 1000),
duration: 30,
},
],
},
],
});
const taskPayload = JSON.stringify({
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
bookingId: testBookingId,
webhook: {
id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
active: true,
time: 5,
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
appId: null,
},
});
await triggerHostNoShow(taskPayload);
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
expect.objectContaining({
webhook: expect.objectContaining({
id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
}),
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
data: expect.objectContaining({
bookingId: testBookingId,
bookingUid: "test-uid-no-show",
message: expect.stringContaining("Host with email"),
participants: expect.arrayContaining([
expect.objectContaining({
user_id: "999999",
user_name: "Guest User",
}),
]),
}),
})
);
const updatedBooking = await prisma.booking.findUnique({
where: {
id: testBookingId,
},
});
expect(updatedBooking?.noShowHost).toBe(true);
});
});
@@ -25,6 +25,17 @@ vi.mock("@calcom/prisma", () => {
vi.mock("stripe");
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
vi.mock("@calcom/lib/constants", async () => {
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
return {
@@ -43,6 +43,17 @@ vi.mock("@calcom/lib/domainManager/organization", () => ({
createDomain: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
const mockOrganizationOnboarding = {
name: "Test Org",
slug: "test-org",
@@ -17,6 +17,16 @@ vi.mock("@calcom/features/ee/workflows/lib/reminders/providers/emailProvider", (
sendOrScheduleWorkflowEmails: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
describe("reminderScheduler", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -1,5 +1,5 @@
import { createInstance } from "i18next";
import { expect, test, describe } from "vitest";
import { vi, expect, test, describe } from "vitest";
import { getTranslation } from "@calcom/lib/server/i18n";
import { TimeFormat } from "@calcom/lib/timeFormat";
@@ -10,6 +10,17 @@ import { getTemplateBodyForAction } from "../actionHelperFunctions";
import compareReminderBodyToTemplate from "../compareReminderBodyToTemplate";
import plainTextReminderTemplates from "../reminders/templates/plainTextTemplates";
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
const translation = async () => {
const _i18n = createInstance();
await _i18n.init({
@@ -66,11 +66,13 @@ export default function CreateEventTypeForm({
!isPlatform ? (
<Tooltip content={!isManagedEventType ? pageSlug : t("username_placeholder")}>
<span className="max-w-24 md:max-w-56">
/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
{`/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
</span>
</Tooltip>
) : undefined
}
containerClassName="[&>div]:gap-0"
className="pl-0"
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
@@ -91,11 +93,13 @@ export default function CreateEventTypeForm({
<Tooltip
content={`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}>
<span className="max-w-24 md:max-w-56">
{urlPrefix}/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
{`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
</span>
</Tooltip>
) : undefined
}
containerClassName="[&>div]:gap-0"
className="pl-0"
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
@@ -208,9 +208,12 @@ export const EventSetupTab = (
{...(isManagedEventType || isChildrenManagedEventType ? urlLockedProps : {})}
defaultValue={eventType.slug}
data-testid="event-slug"
containerClassName={classNames(customClassNames?.titleSection?.urlInput?.container)}
containerClassName={classNames(
"[&>div]:gap-0",
customClassNames?.titleSection?.urlInput?.container
)}
labelClassName={classNames(customClassNames?.titleSection?.urlInput?.label)}
className={classNames(customClassNames?.titleSection?.urlInput?.input)}
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
addOnLeading={
isPlatform ? undefined : (
<>
@@ -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`
@@ -1,18 +0,0 @@
import { Card } from "@tremor/react";
import classNames from "@calcom/ui/classNames";
interface ICardProps {
children: React.ReactNode;
className?: string;
}
export const CardInsights = (props: ICardProps) => {
const { children, className = "", ...rest } = props;
return (
<Card className={classNames(`ring-subtle bg-muted shadow-none `, className)} {...rest}>
{children}
</Card>
);
};
@@ -21,7 +21,7 @@ export function ChartCard({
legendSize,
children,
}: {
title: string;
title: string | React.ReactNode;
subtitle?: string;
cta?: { label: string; onClick: () => void };
legend?: Array<LegendItem>;
@@ -31,7 +31,11 @@ export function ChartCard({
return (
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
{typeof title === "string" ? (
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
) : (
title
)}
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
{legend && legend.length > 0 && <Legend items={legend} size={legendSize} />}
{cta && (
@@ -80,13 +84,13 @@ function Legend({ items, size = "default" }: { items: LegendItem[]; size?: Legen
{items.map((item, index) => (
<Fragment key={item.label}>
<div
className="relative flex items-center gap-2 rounded-md px-1.5 py-1"
className="relative flex items-center gap-2 rounded-md px-1.5 py-0.5"
style={{ backgroundColor: `${item.color}33` }}>
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
<Tooltip content={item.label}>
<p
className={classNames(
"text-default truncate text-sm font-medium leading-none",
"text-default truncate py-0.5 text-sm font-medium leading-none",
size === "sm" ? "w-16" : ""
)}>
{item.label}
@@ -1,11 +1,11 @@
"use client";
import { Flex, Text, Metric, BadgeDelta } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { calculateDeltaType, colors, valueFormatter } from "../lib";
import { calculateDeltaType, valueFormatter } from "../lib";
export const KPICard = ({
title,
@@ -13,7 +13,6 @@ export const KPICard = ({
previousDateRange,
}: {
title: string;
value: number;
previousMetricData: {
count: number;
deltaPrevious: number;
@@ -21,22 +20,54 @@ export const KPICard = ({
previousDateRange: { startDate: string; endDate: string };
}) => {
const { t } = useLocale();
const deltaType = calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count);
const deltaValue = Number(previousMetricData.deltaPrevious).toFixed(0);
const getBadgeVariant = (type: string) => {
switch (type) {
case "increase":
case "moderateIncrease":
return "success" as const;
case "decrease":
case "moderateDecrease":
return "error" as const;
case "unchanged":
default:
return "gray" as const;
}
};
// Get appropriate icon for delta
const getDeltaIcon = (type: string) => {
switch (type) {
case "increase":
case "moderateIncrease":
return "arrow-up" as const;
case "decrease":
case "moderateDecrease":
return "arrow-down" as const;
case "unchanged":
default:
return null;
}
};
const badgeVariant = getBadgeVariant(deltaType);
const deltaIcon = getDeltaIcon(deltaType);
return (
<div>
<Text className="text-default">{title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate">
<Metric className="text-emphasis">{valueFormatter(previousMetricData.count)}</Metric>
</Flex>
<Flex className="mt-4 justify-start space-x-2">
<BadgeDelta
deltaType={calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)}
/>
<Flex className="justify-start space-x-1 truncate">
<Text
color={colors[calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)]}>
{Number(previousMetricData.deltaPrevious).toFixed(0)}%
</Text>
<div className="text-default text-sm">{title}</div>
<div className="flex items-baseline justify-start space-x-3 truncate">
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(previousMetricData.count)}</div>
</div>
<div className="mt-4 flex items-center justify-start space-x-2">
<Badge variant={badgeVariant} className="flex items-center gap-1">
{deltaIcon && <Icon name={deltaIcon} className="h-3 w-3" />}
{deltaValue}%
</Badge>
<div className="flex justify-start space-x-1 truncate">
<Tooltip
content={t("from_to_date_period", {
startDate: previousDateRange.startDate,
@@ -46,8 +77,8 @@ export const KPICard = ({
{t("from_last_period")}
</small>
</Tooltip>
</Flex>
</Flex>
</div>
</div>
</div>
);
};
@@ -1,8 +0,0 @@
import { LineChart as ExternalLineChart } from "@tremor/react";
import type { LineChartProps } from "./tremor.types";
// Honestly this is a mess. Why are all chart libraries in existance horrible to theme
export const LineChart = (props: LineChartProps) => {
return <ExternalLineChart {...props} />;
};
@@ -1,26 +1,25 @@
import classNames from "@calcom/ui/classNames";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { CardInsights } from "./Card";
import { ChartCard } from "./ChartCard";
export const LoadingInsight = () => {
return (
<CardInsights>
<SkeletonText className="w-32" />
<ChartCard title={<SkeletonText className="w-32" />}>
<div className="m-auto flex h-80 flex-col items-center justify-center">
<svg
className={classNames("mx-4 h-8 w-8 animate-spin", "text-black dark:text-white")}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</div>
</CardInsights>
</ChartCard>
);
};
@@ -2,25 +2,24 @@
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { User } from "@calcom/prisma/client";
import { Avatar } from "@calcom/ui/components/avatar";
import { ChartCardItem } from "./ChartCard";
export const UserStatsTable = ({
data,
}: {
data:
| {
userId: number | null;
user: Pick<User, "avatarUrl" | "name">;
emailMd5?: string;
count?: number;
averageRating?: number | null;
username?: string;
}[]
| undefined;
}) => {
type UserStatsData = {
userId: number;
user: {
id: number;
username: string | null;
name: string | null;
email: string;
avatarUrl: string;
};
emailMd5: string;
count: number;
}[];
export const UserStatsTable = ({ data }: { data: UserStatsData }) => {
const { t } = useLocale();
// Filter out items without user data
@@ -32,7 +31,7 @@ export const UserStatsTable = ({
filteredData.map((item) => (
<ChartCardItem
key={item.userId || `user-${Math.random()}`}
count={item.averageRating ? item.averageRating.toFixed(1) : item.count}
count={Number.isInteger(item.count) ? item.count : item.count.toFixed(1)}
className="py-3">
<div className="flex items-center">
<Avatar
@@ -1,14 +1,70 @@
"use client";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { valueFormatter } from "../../lib/valueFormatter";
import { ChartCard } from "../ChartCard";
import { LineChart } from "../LineChart";
import { LoadingInsight } from "../LoadingInsights";
const COLOR = {
AVERAGE: "#3b82f6",
};
type AverageEventDurationData = RouterOutputs["viewer"]["insights"]["averageEventDuration"][number];
// Custom duration formatter that shows hours and minutes
const formatDuration = (minutes: number) => {
if (!minutes) return "0m";
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
const remainingMinutesStr = remainingMinutes.toFixed(1);
if (hours > 0 && remainingMinutes > 0) {
return `${hours}h ${remainingMinutesStr}m`;
} else if (hours > 0) {
return `${hours}h`;
} else {
return `${remainingMinutesStr}m`;
}
};
// Custom Tooltip component
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
value: number;
dataKey: string;
name: string;
color: string;
payload: AverageEventDurationData;
}>;
label?: string;
}) => {
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{label}</p>
{payload.map((entry, index: number) => (
<p key={index} style={{ color: entry.color }}>
{entry.name}: {formatDuration(entry.value)}
</p>
))}
</div>
);
};
export const AverageEventDurationChart = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
@@ -36,14 +92,32 @@ export const AverageEventDurationChart = () => {
</div>
)}
{data && data.length > 0 && !isNoData && (
<LineChart
className="mt-4 h-80"
data={data}
index="Date"
categories={["Average"]}
colors={["blue"]}
valueFormatter={valueFormatter}
/>
<div className="mt-4 h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Date" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={formatDuration}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="Average"
name={t("average_event_duration")}
stroke={COLOR.AVERAGE}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</ChartCard>
);
@@ -84,7 +84,6 @@ export const BookingKPICards = () => {
<StatItem key={item.title} index={index}>
<KPICard
title={item.title}
value={data[item.index].count}
previousMetricData={data[item.index]}
previousDateRange={data.previousRange}
/>
@@ -99,7 +98,6 @@ export const BookingKPICards = () => {
<StatItem key={item.title} index={index}>
<KPICard
title={item.title}
value={data[item.index].count}
previousMetricData={data[item.index]}
previousDateRange={data.previousRange}
/>
@@ -13,15 +13,13 @@ import {
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
type BookingsByHourData = {
hour: number;
count: number;
};
type BookingsByHourData = RouterOutputs["viewer"]["insights"]["bookingsByHourStats"][number];
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
const { t } = useLocale();
@@ -1,14 +1,68 @@
"use client";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { valueFormatter } from "../../lib/valueFormatter";
import { ChartCard } from "../ChartCard";
import { LineChart } from "../LineChart";
import { LoadingInsight } from "../LoadingInsights";
const COLOR = {
CREATED: "#a855f7",
COMPLETED: "#22c55e",
RESCHEDULED: "#3b82f6",
CANCELLED: "#ef4444",
NO_SHOW_HOST: "#64748b",
NO_SHOW_GUEST: "#f97316",
};
export const legend = [
{ label: "Created", color: COLOR.CREATED },
{ label: "Completed", color: COLOR.COMPLETED },
{ label: "Rescheduled", color: COLOR.RESCHEDULED },
{ label: "Cancelled", color: COLOR.CANCELLED },
{ label: "No-Show (Host)", color: COLOR.NO_SHOW_HOST },
{ label: "No-Show (Guest)", color: COLOR.NO_SHOW_GUEST },
];
type EventTrendsData = RouterOutputs["viewer"]["insights"]["eventTrends"][number];
// Custom Tooltip component
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
value: number;
dataKey: string;
name: string;
color: string;
payload: EventTrendsData;
}>;
label?: string;
}) => {
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
{payload.map((entry, index: number) => (
<p key={index} style={{ color: entry.color }}>
{entry.name}: {valueFormatter ? valueFormatter(entry.value) : entry.value}
</p>
))}
</div>
);
};
export const EventTrendsChart = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
@@ -30,15 +84,36 @@ export const EventTrendsChart = () => {
if (!isSuccess) return null;
return (
<ChartCard title={t("event_trends")}>
<LineChart
className="linechart ml-4 mt-4 h-80 sm:ml-0"
data={eventTrends ?? []}
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)", "No-Show (Guest)"]}
index="Month"
colors={["purple", "green", "blue", "red", "slate", "orange"]}
valueFormatter={valueFormatter}
/>
<ChartCard title={t("event_trends")} legend={legend}>
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={valueFormatter}
/>
<Tooltip content={<CustomTooltip />} />
{legend.map((item) => (
<Line
key={item.label}
type="linear"
dataKey={item.label}
name={item.label}
stroke={item.color}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</ChartCard>
);
};
@@ -5,8 +5,8 @@ import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { FeedbackTable } from "../FeedbackTable";
import { LoadingInsight } from "../LoadingInsights";
import { RecentFeedbackTableContent } from "./RecentFeedbackTableContent";
export const RecentFeedbackTable = () => {
const { t } = useLocale();
@@ -26,7 +26,7 @@ export const RecentFeedbackTable = () => {
return (
<ChartCard title={t("recent_ratings")}>
<FeedbackTable data={data} />
<RecentFeedbackTableContent data={data} />
</ChartCard>
);
};
@@ -2,31 +2,20 @@
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { User } from "@calcom/prisma/client";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Tooltip } from "@calcom/ui/components/tooltip";
export const FeedbackTable = ({
data,
}: {
data:
| {
userId: number | null;
user: Pick<User, "avatarUrl" | "name">;
emailMd5?: string;
username?: string;
rating: number | null;
feedback: string | null;
}[]
| undefined;
}) => {
type FeedbackData = RouterOutputs["viewer"]["insights"]["recentRatings"];
export const RecentFeedbackTableContent = ({ data }: { data: FeedbackData }) => {
const { t } = useLocale();
return (
<div className="overflow-hidden rounded-md">
{data && data?.length > 0 ? (
data?.map((item) => (
{data && data.length > 0 ? (
data.map((item) => (
<div
key={item.userId}
className="border-subtle flex items-center justify-between border-b px-4 py-3 last:border-b-0">
@@ -1,10 +1,8 @@
export { BookedByCell } from "./BookedByCell";
export { BookingAtCell } from "./BookingAtCell";
export { BookingStatusBadge } from "./BookingStatusBadge";
export { CardInsights } from "./Card";
export { CellWithOverflowX } from "./CellWithOverflowX";
export { ChartCard } from "./ChartCard";
export { FeedbackTable } from "./FeedbackTable";
export { KPICard } from "./KPICard";
export { LineChart } from "./LineChart";
export { LoadingInsight } from "./LoadingInsights";
@@ -1,6 +1,16 @@
"use client";
import { useState } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Rectangle,
} from "recharts";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -8,7 +18,39 @@ import { ToggleGroup } from "@calcom/ui/components/form";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { ChartCard } from "../ChartCard";
import { BarList } from "../tremor/BarList";
// Custom Tooltip component
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
value: number;
dataKey: string;
name: string;
color: string;
payload: { name: string; value: number };
}>;
label?: string;
}) => {
const { t } = useLocale();
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-default border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{label}</p>
{payload.map((entry, index: number) => (
<p key={index}>
{t("failed_bookings")}: {entry.value}
</p>
))}
</div>
);
};
interface FormCardProps {
formName: string;
@@ -16,6 +58,7 @@ interface FormCardProps {
}
function FormCard({ formName, fields }: FormCardProps) {
const { t } = useLocale();
const fieldNames = Object.keys(fields);
const [selectedField, setSelectedField] = useState(fieldNames[0]);
@@ -29,6 +72,17 @@ function FormCard({ formName, fields }: FormCardProps) {
value: option.count,
}));
const maxCount = Math.max(...(selectedFieldData?.map((item) => item.value) || [0]));
const isEmpty = maxCount === 0;
if (isEmpty) {
return (
<div className="text-default flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</div>
);
}
return (
<div className="flex flex-col">
<div className="p-4">
@@ -38,13 +92,36 @@ function FormCard({ formName, fields }: FormCardProps) {
className="w-fit"
onValueChange={(value) => value && setSelectedField(value)}
/>
{selectedFieldData && (
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
<BarList
data={selectedFieldData}
valueFormatter={(value: number) => value.toString()}
className="mt-2"
/>
{selectedFieldData && selectedFieldData.length > 0 ? (
<div className="mt-4 h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={selectedFieldData}
layout="vertical"
margin={{
top: 20,
right: 10,
left: 20,
bottom: 5,
}}
barCategoryGap="10%">
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" className="text-xs" axisLine={false} tickLine={false} />
<YAxis dataKey="name" type="category" className="text-xs" axisLine={false} tickLine={false} />
<Tooltip cursor={false} content={<CustomTooltip />} />
<Bar
dataKey="value"
fill="var(--cal-bg-subtle)"
radius={[0, 2, 2, 0]}
activeBar={<Rectangle fill="var(--cal-bg-info)" />}
/>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="mt-4 p-4 text-center text-gray-500">
No data available for selected field
<div className="mt-2 text-xs">Data: {JSON.stringify(selectedFieldData, null, 2)}</div>
</div>
)}
</div>
@@ -66,8 +143,9 @@ export function FailedBookingsByField() {
// routingFormId is always set, meaning data has only one entry.
const [formName, fields] = Object.entries(data)[0];
return (
<ChartCard title={t("failed_bookings_by_field")} subtitle={formName}>
<ChartCard title={t("failed_bookings_by_field")}>
<FormCard key={formName} formName={formName} fields={fields} />
</ChartCard>
);
@@ -9,6 +9,7 @@ import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
@@ -165,14 +166,7 @@ function FormCard({
);
}
type RoutedToTableRow = {
id: number;
name: string;
avatarUrl: string | null;
stats: { [key: string]: number };
performance: "above_average" | "at_average" | "below_average" | "median" | "no_data";
totalBookings: number;
};
type RoutedToTableRow = RouterOutputs["viewer"]["insights"]["routedToPerPeriod"]["users"]["data"][number];
const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TFunction) => {
switch (performance) {
@@ -292,7 +286,7 @@ export function RoutedToPerPeriod() {
.sort((a, b) => a.getTime() - b.getTime());
}, [data?.pages]);
const processedData: RoutedToTableRow[] = useMemo(() => {
const processedData = useMemo(() => {
if (!data?.pages) return [];
// Create a map for quick lookup of stats
@@ -419,7 +413,7 @@ export function RoutedToPerPeriod() {
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex cursor-pointer items-center gap-2">
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name} />
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name || ""} />
<div className="flex flex-col gap-1 truncate">
<span>{row.name}</span>
</div>
@@ -21,8 +21,8 @@ import { trpc } from "@calcom/trpc";
import { RoutingFormResponsesDownload } from "../../filters/Download";
import { OrgTeamsFilter } from "../../filters/OrgTeamsFilter";
import { useInsightsColumns } from "../../hooks/useInsightsColumns";
import { useInsightsFacetedUniqueValues } from "../../hooks/useInsightsFacetedUniqueValues";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { useInsightsRoutingFacetedUniqueValues } from "../../hooks/useInsightsRoutingFacetedUniqueValues";
import type { RoutingFormTableRow } from "../../lib/types";
import { RoutingKPICards } from "./RoutingKPICards";
@@ -38,19 +38,21 @@ export function RoutingFormResponsesTable() {
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
useInsightsParameters();
const {
data: headers,
isLoading: isHeadersLoading,
isSuccess: isHeadersSuccess,
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
const { data: headers, isSuccess: isHeadersSuccess } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
userId,
teamId,
isAll,
routingFormId,
});
const getInsightsFacetedUniqueValues = useInsightsRoutingFacetedUniqueValues({
headers,
userId,
teamId,
isAll,
routingFormId,
});
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
@@ -109,10 +111,6 @@ export function RoutingFormResponsesTable() {
}
}, [table, getInsightsFacetedUniqueValues, routingFormId]);
if (isHeadersLoading && !headers) {
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
}
return (
<>
<div className="flex-1">
@@ -1,7 +1,6 @@
"use client";
import { useColumnFilters } from "@calcom/features/data-table";
import { useInsightsParameters } from "@calcom/features/insights/hooks/useInsightsParameters";
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -11,18 +10,9 @@ import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
export function RoutingFunnel() {
const { t } = useLocale();
const { scope, selectedTeamId, startDate, endDate } = useInsightsParameters();
const columnFilters = useColumnFilters({
exclude: ["createdAt"],
});
const insightsRoutingParams = useInsightsRoutingParameters();
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
{
scope,
selectedTeamId,
startDate,
endDate,
columnFilters,
},
insightsRoutingParams,
{
staleTime: 30000,
trpc: {
@@ -1,14 +1,13 @@
"use client";
import { Grid } from "@tremor/react";
import { Flex, Text, Metric } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import classNames from "@calcom/ui/classNames";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { valueFormatter } from "../../lib";
import { CardInsights } from "../Card";
import { ChartCard } from "../ChartCard";
export const RoutingKPICards = () => {
const { t } = useLocale();
@@ -61,35 +60,46 @@ export const RoutingKPICards = () => {
}
return (
<>
<Grid numColsSm={1} numColsLg={3} className="mt-4 gap-x-4 gap-y-4">
<ChartCard title={t("stats")}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{categories.map((item) => (
<CardInsights key={item.title}>
<Text className="text-default">{item.title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate">
<Metric className="text-emphasis">{valueFormatter(data[item.index])}</Metric>
</Flex>
</CardInsights>
<div
key={item.title}
className={classNames(
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
)}>
<div className="text-default text-sm">{item.title}</div>
<div className="flex items-baseline justify-start space-x-3 truncate">
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(data[item.index])}</div>
</div>
</div>
))}
</Grid>
</>
</div>
</ChartCard>
);
};
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
const { t } = useLocale();
const { categories } = props;
return (
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
{categories.map((item) => (
<CardInsights key={item.title}>
<div className="animate-pulse">
<div className="h-4 w-24 rounded bg-gray-200" />
<div className="mt-4 flex items-baseline space-x-3">
<div className="h-8 w-16 rounded bg-gray-200" />
<ChartCard title={t("stats")}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{categories.map((item) => (
<div
key={item.title}
className={classNames(
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
)}>
<div>
<SkeletonText className="mt-1 h-5 w-32" />
</div>
<div>
<SkeletonText className="h-6 w-12" />
</div>
</div>
</CardInsights>
))}
</Grid>
))}
</div>
</ChartCard>
);
};
@@ -1,107 +0,0 @@
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
box-shadow: none;
width: 100%;
background-color: transparent;
}
/* Media query for screens larger than 768px */
@media (max-width: 639) {
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
max-width: 400px;
}
}
.recharts-cartesian-grid-horizontal line {
@apply stroke-emphasis;
}
.tremor-DateRangePicker-button button {
@apply border-default hover:border-emphasis !h-9 !max-h-9;
}
.tremor-DateRangePicker-calendarButton,
.tremor-DateRangePicker-dropdownButton {
@apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0;
}
.tremor-DateRangePicker-dropdownModal {
@apply divide-none;
}
.tremor-DropdownItem-root {
@apply bg-default hover:bg-subtle text-default hover:text-emphasis !h-9 !max-h-9;
}
.tremor-DateRangePicker-calendarButtonText,
.tremor-DateRangePicker-dropdownButtonText {
@apply text-default;
}
.tremor-DateRangePicker-calendarHeaderText {
@apply !text-default;
}
.tremor-DateRangePicker-calendarHeader svg {
@apply text-default;
}
.tremor-DateRangePicker-calendarHeader button {
@apply hover:bg-emphasis shadow-none focus:ring-0;
}
.tremor-DateRangePicker-calendarHeader button:hover svg {
@apply text-emphasis;
}
.tremor-DateRangePicker-calendarButtonIcon {
@apply text-default;
}
.tremor-DateRangePicker-calendarModal,
.tremor-DateRangePicker-dropdownModal {
@apply bg-default border-subtle shadow-dropdown;
}
.tremor-DateRangePicker-calendarBodyDate button {
@apply text-default hover:bg-emphasis;
}
.tremor-DateRangePicker-calendarBodyDate button:disabled,
.tremor-DateRangePicker-calendarBodyDate button[disabled] {
@apply opacity-25;
}
.tremor-DateRangePicker-calendarHeader button {
@apply border-default text-default;
}
.tremor-DateRangePicker-calendarBodyDate .bg-gray-100 {
@apply bg-subtle;
}
.tremor-DateRangePicker-calendarBodyDate .bg-gray-500 {
@apply !bg-brand-default text-inverted;
}
.tremor-Card-root {
@apply bg-default p-5;
}
.tremor-TableCell-root {
@apply pl-0;
}
.recharts-responsive-container {
@apply -mx-4;
}
.tremor-Card-root > p {
@apply mb-2 text-base font-semibold;
}
.tremor-Legend-legendItem {
@apply ml-2;
}
.tremor-TableBody-root {
@apply divide-subtle;
}
@@ -1,49 +0,0 @@
type ValueFormatter = {
(value: number): string;
};
type Color =
| "slate"
| "gray"
| "zinc"
| "neutral"
| "stone"
| "red"
| "orange"
| "amber"
| "yellow"
| "lime"
| "green"
| "emerald"
| "teal"
| "cyan"
| "sky"
| "blue"
| "indigo"
| "violet"
| "purple"
| "fuchsia"
| "pink"
| "rose";
interface BaseChartProps extends React.HTMLAttributes<HTMLDivElement> {
data: any[];
categories: string[];
index: string;
colors?: Color[];
valueFormatter?: ValueFormatter;
startEndOnly?: boolean;
showXAxis?: boolean;
showYAxis?: boolean;
yAxisWidth?: number;
showAnimation?: boolean;
showTooltip?: boolean;
showGradient?: boolean;
showLegend?: boolean;
showGridLines?: boolean;
autoMinValue?: boolean;
minValue?: number;
maxValue?: number;
}
export type LineChartProps = BaseChartProps & React.RefAttributes<HTMLDivElement>;
@@ -1,167 +0,0 @@
"use client";
// Tremor BarList [v0.1.1]
import React from "react";
import classNames from "@calcom/ui/classNames";
type Bar<T> = T & {
key?: string;
href?: string;
value: number;
name: string;
};
const focusRing = [
// base
"outline outline-offset-2 outline-0 focus-visible:outline-2",
// outline color
"outline-brand",
];
interface BarListProps<T = unknown> extends React.HTMLAttributes<HTMLDivElement> {
data: Bar<T>[];
valueFormatter?: (value: number) => string;
showAnimation?: boolean;
onValueChange?: (payload: Bar<T>) => void;
sortOrder?: "ascending" | "descending" | "none";
}
function BarListInner<T>(
{
data = [],
valueFormatter = (value) => value.toString(),
showAnimation = false,
onValueChange,
sortOrder = "descending",
className,
...props
}: BarListProps<T>,
forwardedRef: React.ForwardedRef<HTMLDivElement>
) {
const Component = onValueChange ? "button" : "div";
const sortedData = React.useMemo(() => {
if (sortOrder === "none") {
return data;
}
return [...data].sort((a, b) => {
return sortOrder === "ascending" ? a.value - b.value : b.value - a.value;
});
}, [data, sortOrder]);
const widths = React.useMemo(() => {
const maxValue = Math.max(...sortedData.map((item) => item.value), 0);
return sortedData.map((item) => (item.value === 0 ? 0 : Math.max((item.value / maxValue) * 100, 2)));
}, [sortedData]);
const rowHeight = "h-8";
return (
<div
ref={forwardedRef}
className={classNames("flex justify-between space-x-6", className)}
aria-sort={sortOrder}
tremor-id="tremor-raw"
{...props}>
<div className="relative w-full space-y-1.5">
{sortedData.map((item, index) => (
<Component
key={item.key ?? item.name}
onClick={() => {
onValueChange?.(item);
}}
className={classNames(
// base
"group w-full rounded",
// focus
focusRing,
onValueChange
? [
"!-m-0 cursor-pointer",
// hover
"hover:bg-gray-50 hover:dark:bg-gray-900",
]
: ""
)}>
<div
className={classNames(
// base
"flex items-center rounded transition-all",
rowHeight,
// background color
"bg-blue-200 dark:bg-blue-900",
onValueChange ? "group-hover:bg-blue-300 group-hover:dark:bg-blue-800" : "",
// margin and duration
{
"mb-0": index === sortedData.length - 1,
"duration-800": showAnimation,
}
)}
style={{ width: `${widths[index]}%` }}>
<div className={classNames("absolute left-2 flex max-w-full pr-2")}>
{item.href ? (
<a
href={item.href}
className={classNames(
// base
"truncate whitespace-nowrap rounded text-sm",
// text color
"text-default",
// hover
"hover:underline hover:underline-offset-2",
// focus
focusRing
)}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}>
{item.name}
</a>
) : (
<p
className={classNames(
// base
"truncate whitespace-nowrap text-sm",
// text color
"text-default"
)}>
{item.name}
</p>
)}
</div>
</div>
</Component>
))}
</div>
<div>
{sortedData.map((item, index) => (
<div
key={item.key ?? item.name}
className={classNames(
"flex items-center justify-end",
rowHeight,
index === sortedData.length - 1 ? "mb-0" : "mb-1.5"
)}>
<p
className={classNames(
// base
"truncate whitespace-nowrap text-sm leading-none",
// text color
"text-default"
)}>
{valueFormatter(item.value)}
</p>
</div>
))}
</div>
</div>
);
}
BarListInner.displayName = "BarList";
const BarList = React.forwardRef(BarListInner) as <T>(
p: BarListProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
) => ReturnType<typeof BarListInner>;
export { BarList, type BarListProps };
@@ -0,0 +1,76 @@
import type { Table } from "@tanstack/react-table";
import { useCallback } from "react";
import { convertFacetedValuesToMap, type FacetedValue } from "@calcom/features/data-table";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { bookingStatusToText } from "../lib/bookingStatusToText";
const statusOrder: Record<BookingStatus, number> = {
[BookingStatus.ACCEPTED]: 1,
[BookingStatus.PENDING]: 2,
[BookingStatus.AWAITING_HOST]: 3,
[BookingStatus.CANCELLED]: 4,
[BookingStatus.REJECTED]: 5,
};
export const useInsightsBookingFacetedUniqueValues = ({
userId,
teamId,
isAll,
}: {
userId: number | undefined;
teamId: number | undefined;
isAll: boolean;
}) => {
const { data: users } = trpc.viewer.insights.userList.useQuery(
{
teamId,
isAll,
},
{
refetchOnWindowFocus: false,
}
);
const { data: eventTypes } = trpc.viewer.insights.eventTypeList.useQuery(
{
teamId,
userId,
isAll,
},
{
refetchOnWindowFocus: false,
}
);
return useCallback(
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
if (columnId === "status") {
return convertFacetedValuesToMap(
Object.keys(statusOrder).map((status) => ({
value: status.toLowerCase(),
label: bookingStatusToText(status as BookingStatus),
}))
);
} else if (columnId === "userId") {
return convertFacetedValuesToMap(
users?.map((user) => ({
label: user.name ?? user.email,
value: user.id,
})) ?? []
);
} else if (columnId === "eventTypeId") {
return convertFacetedValuesToMap(
eventTypes?.map((eventType) => ({
value: eventType.id,
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
})) ?? []
);
}
return new Map<FacetedValue, number>();
},
[users, eventTypes]
);
};
@@ -1,8 +1,9 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { ZSingleSelectFilterValue, ZDateRangeFilterValue } from "@calcom/features/data-table";
import { ZDateRangeFilterValue } from "@calcom/features/data-table";
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
import { useColumnFilters } from "@calcom/features/data-table/hooks/useColumnFilters";
import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable";
import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue";
import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange";
@@ -14,8 +15,6 @@ export function useInsightsBookingParameters() {
const { scope, selectedTeamId } = useInsightsOrgTeams();
const { timeZone } = useDataTable();
const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined;
const eventTypeId = useFilterValue("eventTypeId", ZSingleSelectFilterValue)?.data as number | undefined;
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
// TODO for future: this preserving local time & startOf & endOf should be handled
// from DateRangeFilter out of the box.
@@ -34,6 +33,7 @@ export function useInsightsBookingParameters() {
.toISOString();
}, [createdAtRange?.endDate])
);
const columnFilters = useColumnFilters({ exclude: ["createdAt"] });
return {
scope,
@@ -41,7 +41,6 @@ export function useInsightsBookingParameters() {
startDate,
endDate,
timeZone: timeZone || CURRENT_TIMEZONE,
eventTypeId,
memberUserId,
columnFilters,
};
}
@@ -3,25 +3,24 @@ import { useMemo } from "react";
import { ColumnFilterType } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { HeaderRow, RoutingFormTableRow } from "../lib/types";
import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues";
import { useInsightsBookingFacetedUniqueValues } from "./useInsightsBookingFacetedUniqueValues";
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
type DummyTableRow = {
bookingUserId: RoutingFormTableRow["bookingUserId"];
userId: number | null;
eventTypeId: number | null;
status: BookingStatus;
};
const emptyData: DummyTableRow[] = [];
const dummyHeaders: HeaderRow[] = [];
export const useInsightsBookings = () => {
const { t } = useLocale();
const { isAll, teamId, userId } = useInsightsOrgTeams();
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({
headers: dummyHeaders,
const getInsightsFacetedUniqueValues = useInsightsBookingFacetedUniqueValues({
userId,
teamId,
isAll,
@@ -30,18 +29,6 @@ export const useInsightsBookings = () => {
const columns = useMemo(() => {
const columnHelper = createColumnHelper<DummyTableRow>();
return [
columnHelper.accessor("bookingUserId", {
id: "bookingUserId",
header: t("user"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.SINGLE_SELECT,
},
},
cell: () => null,
}),
columnHelper.accessor("eventTypeId", {
id: "eventTypeId",
header: t("event_type"),
@@ -55,6 +42,31 @@ export const useInsightsBookings = () => {
enableSorting: false,
cell: () => null,
}),
columnHelper.accessor("status", {
id: "status",
header: t("booking_status"),
size: 200,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
}),
columnHelper.accessor("userId", {
id: "userId",
header: t("member"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.SINGLE_SELECT,
},
},
cell: () => null,
}),
];
}, [t]);
@@ -16,7 +16,7 @@ const statusOrder: Record<BookingStatus, number> = {
[BookingStatus.REJECTED]: 5,
};
export const useInsightsFacetedUniqueValues = ({
export const useInsightsRoutingFacetedUniqueValues = ({
headers,
userId,
teamId,
@@ -0,0 +1,43 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useFilterValue, useColumnFilters, ZDateRangeFilterValue } from "@calcom/features/data-table";
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange";
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
export function useInsightsRoutingParameters() {
const { scope, selectedTeamId } = useInsightsOrgTeams();
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
// TODO for future: this preserving local time & startOf & endOf should be handled
// from DateRangeFilter out of the box.
// When we do it, we also need to remove those timezone handling logic from the backend side at the same time.
const startDate = useChangeTimeZoneWithPreservedLocalTime(
useMemo(() => {
return dayjs(createdAtRange?.startDate ?? getDefaultStartDate().toISOString())
.startOf("day")
.toISOString();
}, [createdAtRange?.startDate])
);
const endDate = useChangeTimeZoneWithPreservedLocalTime(
useMemo(() => {
return dayjs(createdAtRange?.endDate ?? getDefaultEndDate().toISOString())
.endOf("day")
.toISOString();
}, [createdAtRange?.endDate])
);
const columnFilters = useColumnFilters({
exclude: ["createdAt"],
});
return {
scope,
selectedTeamId,
startDate,
endDate,
columnFilters,
};
}
-9
View File
@@ -1,9 +0,0 @@
import type { Color } from "@tremor/react";
export const colors: { [key: string]: Color } = {
increase: "emerald",
moderateIncrease: "emerald",
unchanged: "orange",
moderateDecrease: "rose",
decrease: "rose",
};
-1
View File
@@ -1,3 +1,2 @@
export { calculateDeltaType } from "./calculateDeltaType";
export { colors } from "./colors";
export { valueFormatter } from "./valueFormatter";
@@ -76,6 +76,5 @@ export const bookingRepositoryBaseInputSchema = z.object({
startDate: z.string(),
endDate: z.string(),
timeZone: z.string(),
eventTypeId: z.coerce.number().optional().nullable(),
memberUserId: z.coerce.number().optional().nullable(),
columnFilters: z.array(ZColumnFilter).optional(),
});
@@ -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, eventTypeId, memberUserId, startDate, endDate } = input;
const { scope, selectedTeamId, startDate, endDate, columnFilters } = input;
return new InsightsBookingService({
prisma: ctx.insightsDb,
return getInsightsBookingService({
options: {
scope,
userId: ctx.user.id,
@@ -331,8 +330,7 @@ function createInsightsBookingService(
...(selectedTeamId && { teamId: selectedTeamId }),
},
filters: {
...(eventTypeId && { eventTypeId }),
...(memberUserId && { memberUserId }),
...(columnFilters && { columnFilters }),
dateRange: {
target: dateTarget,
startDate,
@@ -341,6 +339,26 @@ function createInsightsBookingService(
},
});
}
function createInsightsRoutingService(
ctx: { insightsDb: typeof readonlyPrisma; user: { id: number; organizationId: number | null } },
input: z.infer<typeof routingRepositoryBaseInputSchema>
) {
return getInsightsRoutingService({
options: {
scope: input.scope,
teamId: input.selectedTeamId,
userId: ctx.user.id,
orgId: ctx.user.organizationId,
},
filters: {
startDate: input.startDate,
endDate: input.endDate,
columnFilters: input.columnFilters,
},
});
}
export const insightsRouter = router({
bookingKPIStats: userBelongsToTeamProcedure
.input(bookingRepositoryBaseInputSchema)
@@ -1002,19 +1020,7 @@ export const insightsRouter = router({
timeView,
weekStart: ctx.user.weekStart,
});
const insightsRoutingService = getInsightsRoutingService({
options: {
scope: input.scope,
teamId: input.selectedTeamId,
userId: ctx.user.id,
orgId: ctx.user.organizationId,
},
filters: {
startDate: input.startDate,
endDate: input.endDate,
columnFilters: input.columnFilters,
},
});
const insightsRoutingService = createInsightsRoutingService(ctx, input);
try {
return await insightsRoutingService.getRoutingFunnelData(dateRanges);
} catch (e) {
+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 };
@@ -18,7 +18,8 @@ import { Switch } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import { revalidateWebhooksListGetByViewer } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import { revalidateWebhookList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/actions";
type WebhookProps = {
id: string;
@@ -46,21 +47,19 @@ export default function WebhookListItem(props: {
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
async onSuccess() {
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
revalidateWebhooksList();
revalidateWebhooksListGetByViewer();
revalidateWebhookList();
showToast(t("webhook_removed_successfully"), "success");
await utils.viewer.webhook.getByViewer.invalidate();
await utils.viewer.webhook.list.invalidate();
await utils.viewer.eventTypes.get.invalidate();
},
});
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
async onSuccess(data) {
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
revalidateWebhooksList();
revalidateWebhooksListGetByViewer();
revalidateWebhookList();
// TODO: Better success message
showToast(t(data?.active ? "enabled" : "disabled"), "success");
await utils.viewer.webhook.getByViewer.invalidate();
await utils.viewer.webhook.list.invalidate();
await utils.viewer.eventTypes.get.invalidate();
},
});
@@ -7,7 +7,9 @@ import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import { revalidateWebhooksListGetByViewer } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import { revalidateWebhookById } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/actions";
import { revalidateWebhookList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/actions";
import type { WebhookFormSubmitData } from "../components/WebhookForm";
import WebhookForm from "../components/WebhookForm";
@@ -43,10 +45,10 @@ export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
});
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
async onSuccess() {
await utils.viewer.webhook.list.invalidate();
await utils.viewer.webhook.get.invalidate({ webhookId: webhook?.id });
revalidateWebhookById(webhook?.id ?? "");
revalidateWebhookList();
showToast(t("webhook_updated_successfully"), "success");
revalidateWebhooksList();
revalidateWebhooksListGetByViewer();
router.push("/settings/developer/webhooks");
},
onError(error) {
@@ -10,7 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import { revalidateWebhooksListGetByViewer } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import { revalidateWebhookList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/actions";
import type { WebhookFormSubmitData } from "../components/WebhookForm";
import WebhookForm from "../components/WebhookForm";
@@ -35,7 +36,8 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
async onSuccess() {
showToast(t("webhook_created_successfully"), "success");
await utils.viewer.webhook.list.invalidate();
revalidateWebhooksList();
revalidateWebhookList();
revalidateWebhooksListGetByViewer();
router.push("/settings/developer/webhooks");
},
onError(error) {
+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"),
};
+13
View File
@@ -0,0 +1,13 @@
export async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} finally {
clearTimeout(timeout);
}
}
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");
+25 -41
View File
@@ -2,38 +2,15 @@ import { createInstance } from "i18next";
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>();
/**
* Loads English fallback translations for when requested locale translations fail
* Implements caching to avoid redundant network requests
* @returns {Promise<Record<string, string>>} English translations object or empty object on failure
*/
async function loadFallbackTranslations() {
const cacheKey = "en-common";
if (translationCache.has(cacheKey)) {
return translationCache.get(cacheKey);
}
try {
const res = await fetch(`${WEBAPP_URL}/static/locales/en/common.json`, {
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
});
if (!res.ok) {
throw new Error(`Failed to fetch fallback translations: ${res.status}`);
}
const translations = await res.json();
translationCache.set(cacheKey, translations);
return translations;
} catch (error) {
console.error("Could not fetch fallback translations:", error);
return {};
}
}
const SUPPORTED_NAMESPACES = ["common"];
/**
* Loads translations for a specific locale and namespace with optimized caching
@@ -41,31 +18,38 @@ async function loadFallbackTranslations() {
* @param {string} ns - The namespace for the translations
* @returns {Promise<Record<string, string>>} Translations object or fallback translations on failure
*/
export async function loadTranslations(_locale: string, ns: string) {
const locale = _locale === "zh" ? "zh-CN" : _locale;
export async function loadTranslations(_locale: string, _ns: string) {
let locale = _locale === "zh" ? "zh-CN" : _locale;
locale = i18n.locales.includes(locale) ? locale : "en";
const ns = SUPPORTED_NAMESPACES.includes(_ns) ? _ns : "common";
const cacheKey = `${locale}-${ns}`;
if (translationCache.has(cacheKey)) {
return translationCache.get(cacheKey);
}
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
try {
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
const response = await fetch(url, {
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
});
const response = await fetchWithTimeout(
url,
{
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
},
3000
);
if (!response.ok) {
throw new Error(`Failed to fetch translations: ${response.status}`);
logger.error(`Failed to fetch translations: ${response.status}`);
return {};
}
const translations = await response.json();
translationCache.set(cacheKey, translations);
return translations;
} catch (error) {
console.warn(`Failed to load translations for ${locale}/${ns}, falling back to English:`, error);
const fallbackTranslations = await loadFallbackTranslations();
return fallbackTranslations;
} catch (err) {
console.error("loadTranslations Error:", err);
return {};
}
}
+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),
};
}
}

Some files were not shown because too many files have changed in this diff Show More