Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d0dc3f546 | ||
|
|
e4baf15b53 | ||
|
|
a1bd8b6259 | ||
|
|
7987c9f45d | ||
|
|
781d0d92df | ||
|
|
b71d8baccc | ||
|
|
a71d949a0c | ||
|
|
e55cee443b | ||
|
|
c3634b3aba | ||
|
|
cd576f5378 | ||
|
|
2965450929 | ||
|
|
db9b9c45dc | ||
|
|
37f500526c | ||
|
|
006aecb066 | ||
|
|
0829929e98 | ||
|
|
a61148cb51 | ||
|
|
593c48c8b4 | ||
|
|
fb364971fa | ||
|
|
a2205d497d | ||
|
|
cf4e7a5d2c | ||
|
|
0e2ee3adb2 | ||
|
|
dbe8a85cf5 | ||
|
|
84210dc1b4 | ||
|
|
4061bfb3fe |
@@ -22,6 +22,7 @@ env:
|
|||||||
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
|
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
|
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -191,8 +191,9 @@ const availabilitySchema = z
|
|||||||
async function handler(req: NextApiRequest) {
|
async function handler(req: NextApiRequest) {
|
||||||
const { isSystemWideAdmin, userId: reqUserId } = req;
|
const { isSystemWideAdmin, userId: reqUserId } = req;
|
||||||
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
||||||
|
const userAvailabilityService = getUserAvailabilityService()
|
||||||
if (!teamId)
|
if (!teamId)
|
||||||
return getUserAvailability({
|
return userAvailabilityService.getUserAvailability({
|
||||||
username,
|
username,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
@@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
const availabilities = members.map(async (user) => {
|
const availabilities = members.map(async (user) => {
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
availability: await getUserAvailability({
|
availability: await userAvailabilityService.getUserAvailability({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.286",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
|
|||||||
@@ -104,10 +104,11 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
const rescheduledToInfo = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
|
||||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
|
||||||
|
|
||||||
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
|
const rescheduledByEmail = databaseBooking.rescheduled
|
||||||
|
? rescheduledToInfo?.rescheduledBy
|
||||||
|
: databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
@@ -157,13 +158,12 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
|
|
||||||
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
|
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
|
||||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||||
return {
|
return {
|
||||||
uid: rescheduledTo?.uid,
|
uid: rescheduledTo?.uid,
|
||||||
rescheduledBy: rescheduledTo?.rescheduledBy
|
rescheduledBy: rescheduledTo?.rescheduledBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||||
if (databaseMetadata === null) return {};
|
if (databaseMetadata === null) return {};
|
||||||
|
|
||||||
@@ -279,9 +279,11 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
const rescheduledToInfo = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
const rescheduledByEmail = databaseBooking.rescheduled
|
||||||
|
? rescheduledToInfo?.rescheduledBy
|
||||||
|
: databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
|
|||||||
+4
-1
@@ -290,7 +290,10 @@ describe("Event types Endpoints", () => {
|
|||||||
expect(responseBookingFields).toBeDefined();
|
expect(responseBookingFields).toBeDefined();
|
||||||
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
||||||
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
||||||
expect(responseBookingField).toEqual(bookingFields[0]);
|
const fields = responseBookingField
|
||||||
|
//@ts-ignore
|
||||||
|
delete fields.labelAsSafeHtml
|
||||||
|
expect(fields).toEqual(bookingFields[0]);
|
||||||
eventType.bookingFields = responseBookingFields;
|
eventType.bookingFields = responseBookingFields;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
|||||||
import { CacheService } from "@/lib/services/cache.service";
|
import { CacheService } from "@/lib/services/cache.service";
|
||||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
@@ -24,10 +26,12 @@ import { Module } from "@nestjs/common";
|
|||||||
PrismaEventTypeRepository,
|
PrismaEventTypeRepository,
|
||||||
PrismaRoutingFormResponseRepository,
|
PrismaRoutingFormResponseRepository,
|
||||||
PrismaTeamRepository,
|
PrismaTeamRepository,
|
||||||
|
RedisService,
|
||||||
PrismaFeaturesRepository,
|
PrismaFeaturesRepository,
|
||||||
CheckBookingLimitsService,
|
CheckBookingLimitsService,
|
||||||
CacheService,
|
CacheService,
|
||||||
AvailableSlotsService,
|
AvailableSlotsService,
|
||||||
|
UserAvailabilityService
|
||||||
],
|
],
|
||||||
exports: [AvailableSlotsService],
|
exports: [AvailableSlotsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"
|
|||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
import { CacheService } from "@/lib/services/cache.service";
|
import { CacheService } from "@/lib/services/cache.service";
|
||||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||||
|
|
||||||
|
import { UserAvailabilityService } from "./user-availability.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -24,6 +27,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
selectedSlotRepository: PrismaSelectedSlotRepository,
|
selectedSlotRepository: PrismaSelectedSlotRepository,
|
||||||
eventTypeRepository: PrismaEventTypeRepository,
|
eventTypeRepository: PrismaEventTypeRepository,
|
||||||
userRepository: PrismaUserRepository,
|
userRepository: PrismaUserRepository,
|
||||||
|
redisService: RedisService,
|
||||||
featuresRepository: PrismaFeaturesRepository
|
featuresRepository: PrismaFeaturesRepository
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
@@ -35,8 +39,15 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
selectedSlotRepo: selectedSlotRepository,
|
selectedSlotRepo: selectedSlotRepository,
|
||||||
eventTypeRepo: eventTypeRepository,
|
eventTypeRepo: eventTypeRepository,
|
||||||
userRepo: userRepository,
|
userRepo: userRepository,
|
||||||
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
|
redisClient: redisService,
|
||||||
|
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
|
||||||
cacheService: new CacheService(featuresRepository),
|
cacheService: new CacheService(featuresRepository),
|
||||||
|
userAvailabilityService: new UserAvailabilityService(
|
||||||
|
oooRepoDependency,
|
||||||
|
bookingRepository,
|
||||||
|
eventTypeRepository,
|
||||||
|
redisService
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserAvailabilityService extends BaseUserAvailabilityService {
|
||||||
|
constructor(
|
||||||
|
oooRepoDependency: PrismaOOORepository,
|
||||||
|
bookingRepository: PrismaBookingRepository,
|
||||||
|
eventTypeRepository: PrismaEventTypeRepository,
|
||||||
|
redisService: RedisService
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
oooRepo: oooRepoDependency,
|
||||||
|
bookingRepo: bookingRepository,
|
||||||
|
eventTypeRepo: eventTypeRepository,
|
||||||
|
redisClient: redisService,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
fromReschedule?: string | null;
|
fromReschedule?: string | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
if (this.configService.get("e2e")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const { uid, startTime, fromReschedule } = booking;
|
const { uid, startTime, fromReschedule } = booking;
|
||||||
|
|
||||||
const delay = startTime.getTime() - Date.now();
|
const delay = startTime.getTime() - Date.now();
|
||||||
@@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
* Removing an attendee from a booking does not cancel the usage increment job.
|
* Removing an attendee from a booking does not cancel the usage increment job.
|
||||||
*/
|
*/
|
||||||
async cancelUsageByBookingUid(bookingUid: string) {
|
async cancelUsageByBookingUid(bookingUid: string) {
|
||||||
|
if (this.configService.get("e2e")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
||||||
if (job) {
|
if (job) {
|
||||||
await job.remove();
|
await job.remove();
|
||||||
@@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
await this.billingQueue.close();
|
await this.billingQueue.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
|
|||||||
@@ -7,12 +7,32 @@ import { Redis } from "ioredis";
|
|||||||
export class RedisService implements OnModuleDestroy {
|
export class RedisService implements OnModuleDestroy {
|
||||||
public redis: Redis;
|
public redis: Redis;
|
||||||
private readonly logger = new Logger("RedisService");
|
private readonly logger = new Logger("RedisService");
|
||||||
|
private isReady = false; // Track connection status
|
||||||
|
|
||||||
constructor(readonly configService: ConfigService<AppConfig>) {
|
constructor(readonly configService: ConfigService<AppConfig>) {
|
||||||
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
|
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
|
||||||
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
|
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
|
||||||
|
|
||||||
this.redis = new Redis(dbUrl);
|
this.redis = new Redis(dbUrl);
|
||||||
|
|
||||||
|
this.redis.on("error", (err) => {
|
||||||
|
this.logger.error(`IoRedis connection error: ${err.message}`);
|
||||||
|
this.isReady = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on("connect", () => {
|
||||||
|
this.logger.log("IoRedis connected!");
|
||||||
|
this.isReady = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on("reconnecting", (delay: string) => {
|
||||||
|
this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on("end", () => {
|
||||||
|
this.logger.warn("IoRedis connection ended.");
|
||||||
|
this.isReady = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
@@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy {
|
|||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async get<TData>(key: string): Promise<TData | null> {
|
||||||
|
let data = null;
|
||||||
|
if (!this.isReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = await this.redis.get(key);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as TData;
|
||||||
|
} catch (e) {
|
||||||
|
return data as TData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<number> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return this.redis.del(key);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||||
|
if (opts?.ttl) {
|
||||||
|
await this.redis.set(key, stringifiedValue, "PX", opts.ttl);
|
||||||
|
} else {
|
||||||
|
await this.redis.set(key, stringifiedValue);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
|
|
||||||
|
async expire(key: string, seconds: number): Promise<0 | 1> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return this.redis.expire(key, seconds) as Promise<0 | 1>;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const results = await this.redis.lrange(key, start, end);
|
||||||
|
return results.map((item) => JSON.parse(item) as TResult);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
|
||||||
|
if (!this.isReady) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stringifiedElements = elements.map((element) =>
|
||||||
|
typeof element === "object" ? JSON.stringify(element) : String(element)
|
||||||
|
);
|
||||||
|
return this.redis.lpush(key, ...stringifiedElements);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,4 +36,5 @@ process.env = {
|
|||||||
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
|
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
|
||||||
INTEGRATION_TEST_MODE: "true",
|
INTEGRATION_TEST_MODE: "true",
|
||||||
e2e: "true",
|
e2e: "true",
|
||||||
|
SLOTS_CACHE_TTL: "1"
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
|
|
||||||
export async function revalidateWebhooksList() {
|
export async function revalidateWebhooksListGetByViewer() {
|
||||||
revalidatePath("/settings/developer/webhooks");
|
revalidateTag("viewer.webhook.getByViewer");
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -1,13 +1,13 @@
|
|||||||
import { createRouterCaller } from "app/_trpc/context";
|
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -20,6 +20,17 @@ export const generateMetadata = async () =>
|
|||||||
"/settings/developer/webhooks"
|
"/settings/developer/webhooks"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getCachedWebhooksList = unstable_cache(
|
||||||
|
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
|
||||||
|
return await WebhookRepository.getAllWebhooksByUserId({
|
||||||
|
userId,
|
||||||
|
userRole,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
|
||||||
|
);
|
||||||
|
|
||||||
const WebhooksViewServerWrapper = async () => {
|
const WebhooksViewServerWrapper = async () => {
|
||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -27,8 +38,9 @@ const WebhooksViewServerWrapper = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||||
const caller = await createRouterCaller(webhookRouter);
|
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
|
||||||
const data = await caller.getByViewer();
|
|
||||||
|
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
|
||||||
|
|
||||||
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
||||||
};
|
};
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidateTag } from "next/cache";
|
||||||
|
|
||||||
|
export async function revalidateWebhookById(id: string) {
|
||||||
|
revalidateTag(`viewer.webhook.get:${id}`);
|
||||||
|
}
|
||||||
+14
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { PageProps } from "app/_types";
|
import type { PageProps } from "app/_types";
|
||||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
import { getTranslate, _generateMetadata } from "app/_utils";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
||||||
@@ -15,12 +16,24 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
|||||||
`/settings/developer/webhooks/${(await params).id}`
|
`/settings/developer/webhooks/${(await params).id}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getCachedWebhook = (id?: string) => {
|
||||||
|
const fn = unstable_cache(
|
||||||
|
async () => {
|
||||||
|
return await WebhookRepository.findByWebhookId(id);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
|
||||||
|
);
|
||||||
|
|
||||||
|
return fn();
|
||||||
|
};
|
||||||
|
|
||||||
const Page = async ({ params: _params }: PageProps) => {
|
const Page = async ({ params: _params }: PageProps) => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const params = await _params;
|
const params = await _params;
|
||||||
const id = typeof params?.id === "string" ? params.id : undefined;
|
const id = typeof params?.id === "string" ? params.id : undefined;
|
||||||
|
|
||||||
const webhook = await WebhookRepository.findByWebhookId(id);
|
const webhook = await getCachedWebhook(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidateTag } from "next/cache";
|
||||||
|
|
||||||
|
export async function revalidateWebhookList() {
|
||||||
|
revalidateTag("viewer.webhook.list");
|
||||||
|
}
|
||||||
+22
-6
@@ -1,10 +1,16 @@
|
|||||||
import { createRouterCaller } from "app/_trpc/context";
|
import { createRouterCaller } from "app/_trpc/context";
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
|
||||||
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -15,15 +21,25 @@ export const generateMetadata = async () =>
|
|||||||
"/settings/developer/webhooks/new"
|
"/settings/developer/webhooks/new"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getCachedWebhooksList = unstable_cache(
|
||||||
|
async ({ userId }: { userId: number }) => {
|
||||||
|
return await WebhookRepository.findWebhooksByFilters({ userId });
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
|
||||||
|
);
|
||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const [appsCaller, webhookCaller] = await Promise.all([
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
createRouterCaller(appsRouter),
|
if (!session?.user?.id) {
|
||||||
createRouterCaller(webhookRouter),
|
redirect("/auth/login");
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
const appsCaller = await createRouterCaller(appsRouter);
|
||||||
|
|
||||||
const [installedApps, webhooks] = await Promise.all([
|
const [installedApps, webhooks] = await Promise.all([
|
||||||
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
||||||
webhookCaller.list(),
|
getCachedWebhooksList({ userId: session.user.id }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
|
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"
|
autoComplete="none"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect="none"
|
autoCorrect="none"
|
||||||
|
containerClassName="[&>div]:gap-0"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mb-0 mt-0 rounded-md rounded-l-none",
|
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
|
||||||
markAsError
|
markAsError
|
||||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
? "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
|
// If eventType exists, it means user is either owner, host or user
|
||||||
return !eventType;
|
return !!eventType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
RecentFeedbackTable,
|
RecentFeedbackTable,
|
||||||
TimezoneBadge,
|
TimezoneBadge,
|
||||||
} from "@calcom/features/insights/components/booking";
|
} from "@calcom/features/insights/components/booking";
|
||||||
import "@calcom/features/insights/components/tremor.css";
|
|
||||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||||
import { Download } from "@calcom/features/insights/filters/Download";
|
import { Download } from "@calcom/features/insights/filters/Download";
|
||||||
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
|
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ProgressBar } from "@tremor/react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
@@ -16,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
|
|||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { Select } from "@calcom/ui/components/form";
|
import { Select } from "@calcom/ui/components/form";
|
||||||
import { TextField, Label, InputError } 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 { showToast } from "@calcom/ui/toast";
|
||||||
|
|
||||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.5.10",
|
"version": "5.5.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -76,10 +76,9 @@
|
|||||||
"@stripe/stripe-js": "^1.35.0",
|
"@stripe/stripe-js": "^1.35.0",
|
||||||
"@tanstack/react-query": "^5.17.15",
|
"@tanstack/react-query": "^5.17.15",
|
||||||
"@team-plain/typescript-sdk": "^5.9.0",
|
"@team-plain/typescript-sdk": "^5.9.0",
|
||||||
"@tremor/react": "^2.11.0",
|
|
||||||
"@types/turndown": "^5.0.1",
|
"@types/turndown": "^5.0.1",
|
||||||
"@unkey/ratelimit": "^0.1.1",
|
"@unkey/ratelimit": "^0.1.1",
|
||||||
"@upstash/redis": "^1.21.0",
|
"@upstash/redis": "^1.35.2",
|
||||||
"@vercel/edge-config": "^0.1.1",
|
"@vercel/edge-config": "^0.1.1",
|
||||||
"@vercel/edge-functions-ui": "^0.2.1",
|
"@vercel/edge-functions-ui": "^0.2.1",
|
||||||
"@vercel/og": "^0.6.3",
|
"@vercel/og": "^0.6.3",
|
||||||
|
|||||||
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
// Choose User filter item from dropdown
|
// Choose User filter item from dropdown
|
||||||
await addFilter(page, "bookingUserId");
|
await addFilter(page, "userId");
|
||||||
|
|
||||||
// Wait for the URL to include bookingUserId
|
// Wait for the URL to include userId
|
||||||
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
|
await page.waitForURL((url) => url.toString().includes("userId"));
|
||||||
|
|
||||||
// Click User filter to see a user list
|
// Click User filter to see a user list
|
||||||
await openFilter(page, "bookingUserId");
|
await openFilter(page, "userId");
|
||||||
|
|
||||||
await page
|
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
|
||||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
|
||||||
.getByRole("option")
|
|
||||||
.nth(0)
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page
|
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
|
||||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
|
||||||
.getByRole("option")
|
|
||||||
.nth(1)
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// press escape button to close the filter
|
// press escape button to close the filter
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
await clearFilters(page);
|
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 }) => {
|
test("should test download button", async ({ page, users }) => {
|
||||||
|
|||||||
@@ -3402,6 +3402,7 @@
|
|||||||
"license_key_saved": "License key saved successfully",
|
"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}})",
|
"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_by_field": "Failed Bookings By Field",
|
||||||
|
"failed_bookings": "Failed bookings",
|
||||||
"event_type_no_hosts": "No hosts are assigned to event type",
|
"event_type_no_hosts": "No hosts are assigned to event type",
|
||||||
"cache_status": "Cache Status",
|
"cache_status": "Cache Status",
|
||||||
"cache_last_updated": "Last updated: {{timestamp}}",
|
"cache_last_updated": "Last updated: {{timestamp}}",
|
||||||
@@ -3429,5 +3430,7 @@
|
|||||||
"usage_based_expiration_description": "This link can be used for {{count}} booking",
|
"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_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",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ const base = require("@calcom/config/tailwind-preset");
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...base,
|
...base,
|
||||||
content: [
|
content: [...base.content, "../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}"],
|
||||||
...base.content,
|
|
||||||
"../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}",
|
|
||||||
"../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
plugins: [...base.plugins, require("tailwindcss-animate")],
|
plugins: [...base.plugins, require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => {
|
|||||||
|
|
||||||
vi.mock("@calcom/lib/server/i18n", () => {
|
vi.mock("@calcom/lib/server/i18n", () => {
|
||||||
return {
|
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,6 +4,10 @@
|
|||||||
"path": "/api/cron/calendar-cache-cleanup",
|
"path": "/api/cron/calendar-cache-cleanup",
|
||||||
"schedule": "0 5 * * *"
|
"schedule": "0 5 * * *"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/cron/queuedFormResponseCleanup",
|
||||||
|
"schedule": "0 */12 * * *"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "/api/tasks/cron",
|
"path": "/api/tasks/cron",
|
||||||
"schedule": "* * * * *"
|
"schedule": "* * * * *"
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@
|
|||||||
"group": "Embeds",
|
"group": "Embeds",
|
||||||
"icon": "code-simple",
|
"icon": "code-simple",
|
||||||
"pages": ["developing/guides/embeds/embed-events"]
|
"pages": ["developing/guides/embeds/embed-events"]
|
||||||
}
|
},
|
||||||
{
|
{
|
||||||
"group": "Insights",
|
"group": "Insights",
|
||||||
"icon": "chart-bar",
|
"icon": "chart-bar",
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
"@daily-co/daily-js": "^0.76.0",
|
"@daily-co/daily-js": "^0.76.0",
|
||||||
"@evyweb/ioctopus": "^1.2.0",
|
"@evyweb/ioctopus": "^1.2.0",
|
||||||
"@next/third-parties": "^14.2.5",
|
"@next/third-parties": "^14.2.5",
|
||||||
|
"@unkey/ratelimit": "^2.1.2",
|
||||||
"@vercel/functions": "^1.4.0",
|
"@vercel/functions": "^1.4.0",
|
||||||
"city-timezones": "^1.2.1",
|
"city-timezones": "^1.2.1",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
|
|||||||
@@ -1114,4 +1114,18 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMainTimeZone(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const primaryCalendar = await this.getPrimaryCalendar();
|
||||||
|
if (!primaryCalendar?.timeZone) {
|
||||||
|
this.log.warn("No timezone found in primary calendar, defaulting to UTC");
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
return primaryCalendar.timeZone;
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("Error getting main timezone from Google Calendar", { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar
|
|||||||
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||||
import { handleErrorsJson } from "@calcom/lib/errors";
|
import { handleErrorsJson } from "@calcom/lib/errors";
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -91,8 +92,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
"Content-Type": "application/json",
|
"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);
|
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") {
|
if (typeof responseBody === "string") {
|
||||||
calBody = JSON.parse(responseBody) as { value: OfficeCalendar[] };
|
calBody = JSON.parse(responseBody) as { value: OfficeCalendar[] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -625,4 +625,21 @@ export default class Office365CalendarService implements Calendar {
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async getMainTimeZone(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await this.fetcher(`${await this.getUserEndpoint()}/mailboxSettings/timeZone`);
|
||||||
|
const timezone = await handleErrorsJson<string>(response);
|
||||||
|
|
||||||
|
if (!timezone) {
|
||||||
|
this.log.warn("No timezone found in mailbox settings, defaulting to Europe/London");
|
||||||
|
return "Europe/London";
|
||||||
|
}
|
||||||
|
|
||||||
|
return timezone;
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("Error getting main timezone from Office365 Calendar", { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { PrismaQueuedFormResponseRepository } from "@calcom/lib/server/repository/routingForm/queuedFormResponse/PrismaQueuedFormResponseRepository";
|
||||||
|
import { QueuedFormResponseService } from "@calcom/lib/server/service/routingForm/queuedFormResponse/QueuedFormResponseService";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
function validateRequest(request: NextRequest) {
|
||||||
|
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
|
||||||
|
|
||||||
|
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||||
|
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleQueuedFormResponseCleanup(request: NextRequest) {
|
||||||
|
const authError = validateRequest(request);
|
||||||
|
if (authError) {
|
||||||
|
return authError;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queuedFormResponseRepo = new PrismaQueuedFormResponseRepository(prisma);
|
||||||
|
const queuedFormResponseService = new QueuedFormResponseService({
|
||||||
|
logger,
|
||||||
|
queuedFormResponseRepo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await queuedFormResponseService.cleanupExpiredResponses({
|
||||||
|
batchSize: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "success",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during queued form response cleanup:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: "error", message: "Failed to cleanup queued form responses" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/eslint-plugin-eslint": "*",
|
"@calcom/eslint-plugin-eslint": "*",
|
||||||
"@todesktop/tailwind-variants": "^1.0.0",
|
"@todesktop/tailwind-variants": "^1.0.0",
|
||||||
"eslint-config-next": "^15.4.5",
|
"eslint-config-next": "^13.2.1",
|
||||||
"eslint-config-prettier": "^8.6.0",
|
"eslint-config-prettier": "^8.6.0",
|
||||||
"eslint-config-turbo": "^0.0.7",
|
"eslint-config-turbo": "^0.0.7",
|
||||||
"eslint-plugin-playwright": "^0.12.0",
|
"eslint-plugin-playwright": "^0.12.0",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker
|
|||||||
import { buildDateRanges } from "@calcom/lib/date-ranges";
|
import { buildDateRanges } from "@calcom/lib/date-ranges";
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { getBusyTimesForLimitChecks } from "@calcom/lib/getBusyTimes";
|
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 { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
|
||||||
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
|
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
|
||||||
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
|
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
|
||||||
@@ -62,6 +62,7 @@ const _ensureAvailableUsers = async (
|
|||||||
shouldServeCache?: boolean
|
shouldServeCache?: boolean
|
||||||
// ReturnType hint of at least one IsFixedAwareUser, as it's made sure at least one entry exists
|
// ReturnType hint of at least one IsFixedAwareUser, as it's made sure at least one entry exists
|
||||||
): Promise<[IsFixedAwareUser, ...IsFixedAwareUser[]]> => {
|
): Promise<[IsFixedAwareUser, ...IsFixedAwareUser[]]> => {
|
||||||
|
const userAvailabilityService = getUserAvailabilityService()
|
||||||
const availableUsers: IsFixedAwareUser[] = [];
|
const availableUsers: IsFixedAwareUser[] = [];
|
||||||
|
|
||||||
const startDateTimeUtc = getDateTimeInUtc(input.dateFrom, input.timeZone);
|
const startDateTimeUtc = getDateTimeInUtc(input.dateFrom, input.timeZone);
|
||||||
@@ -86,7 +87,7 @@ const _ensureAvailableUsers = async (
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const usersAvailability = await getUsersAvailability({
|
const usersAvailability = await userAvailabilityService.getUsersAvailability({
|
||||||
users: eventType.users,
|
users: eventType.users,
|
||||||
query: {
|
query: {
|
||||||
...input,
|
...input,
|
||||||
|
|||||||
+302
@@ -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("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 () => {
|
vi.mock("@calcom/lib/constants", async () => {
|
||||||
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
|
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
|
||||||
return {
|
return {
|
||||||
|
|||||||
+11
@@ -43,6 +43,17 @@ vi.mock("@calcom/lib/domainManager/organization", () => ({
|
|||||||
createDomain: vi.fn(),
|
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 = {
|
const mockOrganizationOnboarding = {
|
||||||
name: "Test Org",
|
name: "Test Org",
|
||||||
slug: "test-org",
|
slug: "test-org",
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ vi.mock("@calcom/features/ee/workflows/lib/reminders/providers/emailProvider", (
|
|||||||
sendOrScheduleWorkflowEmails: vi.fn(),
|
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", () => {
|
describe("reminderScheduler", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createInstance } from "i18next";
|
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 { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||||
@@ -10,6 +10,17 @@ import { getTemplateBodyForAction } from "../actionHelperFunctions";
|
|||||||
import compareReminderBodyToTemplate from "../compareReminderBodyToTemplate";
|
import compareReminderBodyToTemplate from "../compareReminderBodyToTemplate";
|
||||||
import plainTextReminderTemplates from "../reminders/templates/plainTextTemplates";
|
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 translation = async () => {
|
||||||
const _i18n = createInstance();
|
const _i18n = createInstance();
|
||||||
await _i18n.init({
|
await _i18n.init({
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ export default function CreateEventTypeForm({
|
|||||||
!isPlatform ? (
|
!isPlatform ? (
|
||||||
<Tooltip content={!isManagedEventType ? pageSlug : t("username_placeholder")}>
|
<Tooltip content={!isManagedEventType ? pageSlug : t("username_placeholder")}>
|
||||||
<span className="max-w-24 md:max-w-56">
|
<span className="max-w-24 md:max-w-56">
|
||||||
/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
|
{`/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
containerClassName="[&>div]:gap-0"
|
||||||
|
className="pl-0"
|
||||||
{...register("slug")}
|
{...register("slug")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
||||||
@@ -91,11 +93,13 @@ export default function CreateEventTypeForm({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
content={`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}>
|
content={`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}>
|
||||||
<span className="max-w-24 md:max-w-56">
|
<span className="max-w-24 md:max-w-56">
|
||||||
{urlPrefix}/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
|
{`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
containerClassName="[&>div]:gap-0"
|
||||||
|
className="pl-0"
|
||||||
{...register("slug")}
|
{...register("slug")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
||||||
|
|||||||
@@ -208,9 +208,12 @@ export const EventSetupTab = (
|
|||||||
{...(isManagedEventType || isChildrenManagedEventType ? urlLockedProps : {})}
|
{...(isManagedEventType || isChildrenManagedEventType ? urlLockedProps : {})}
|
||||||
defaultValue={eventType.slug}
|
defaultValue={eventType.slug}
|
||||||
data-testid="event-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)}
|
labelClassName={classNames(customClassNames?.titleSection?.urlInput?.label)}
|
||||||
className={classNames(customClassNames?.titleSection?.urlInput?.input)}
|
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
|
||||||
addOnLeading={
|
addOnLeading={
|
||||||
isPlatform ? undefined : (
|
isPlatform ? undefined : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function InsightsPage() {
|
|||||||
|
|
||||||
## Step 4: Create tRPC Handler
|
## 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
|
```typescript
|
||||||
// packages/features/insights/server/trpc-router.ts
|
// packages/features/insights/server/trpc-router.ts
|
||||||
@@ -118,6 +118,7 @@ export const insightsRouter = router({
|
|||||||
myNewChartData: userBelongsToTeamProcedure
|
myNewChartData: userBelongsToTeamProcedure
|
||||||
.input(bookingRepositoryBaseInputSchema)
|
.input(bookingRepositoryBaseInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
// `createInsightsBookingService` is defined at the root level in this file
|
||||||
const insightsBookingService = createInsightsBookingService(ctx, input);
|
const insightsBookingService = createInsightsBookingService(ctx, input);
|
||||||
|
|
||||||
try {
|
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
|
```typescript
|
||||||
// packages/lib/server/service/insightsBooking.ts
|
// packages/lib/server/service/InsightsBookingBaseService.ts
|
||||||
export class InsightsBookingService {
|
export class InsightsBookingBaseService {
|
||||||
// ... existing methods
|
// ... existing methods
|
||||||
|
|
||||||
async getMyNewChartData() {
|
async getMyNewChartData() {
|
||||||
@@ -168,7 +169,7 @@ export class InsightsBookingService {
|
|||||||
|
|
||||||
## Best Practices
|
## 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
|
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
|
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`
|
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,
|
legendSize,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string | React.ReactNode;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
cta?: { label: string; onClick: () => void };
|
cta?: { label: string; onClick: () => void };
|
||||||
legend?: Array<LegendItem>;
|
legend?: Array<LegendItem>;
|
||||||
@@ -31,7 +31,11 @@ export function ChartCard({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
|
<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">
|
<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">
|
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
|
||||||
{legend && legend.length > 0 && <Legend items={legend} size={legendSize} />}
|
{legend && legend.length > 0 && <Legend items={legend} size={legendSize} />}
|
||||||
{cta && (
|
{cta && (
|
||||||
@@ -80,13 +84,13 @@ function Legend({ items, size = "default" }: { items: LegendItem[]; size?: Legen
|
|||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<Fragment key={item.label}>
|
<Fragment key={item.label}>
|
||||||
<div
|
<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` }}>
|
style={{ backgroundColor: `${item.color}33` }}>
|
||||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||||
<Tooltip content={item.label}>
|
<Tooltip content={item.label}>
|
||||||
<p
|
<p
|
||||||
className={classNames(
|
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" : ""
|
size === "sm" ? "w-16" : ""
|
||||||
)}>
|
)}>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Flex, Text, Metric, BadgeDelta } from "@tremor/react";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
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 { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
import { calculateDeltaType, colors, valueFormatter } from "../lib";
|
import { calculateDeltaType, valueFormatter } from "../lib";
|
||||||
|
|
||||||
export const KPICard = ({
|
export const KPICard = ({
|
||||||
title,
|
title,
|
||||||
@@ -13,7 +13,6 @@ export const KPICard = ({
|
|||||||
previousDateRange,
|
previousDateRange,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
value: number;
|
|
||||||
previousMetricData: {
|
previousMetricData: {
|
||||||
count: number;
|
count: number;
|
||||||
deltaPrevious: number;
|
deltaPrevious: number;
|
||||||
@@ -21,22 +20,54 @@ export const KPICard = ({
|
|||||||
previousDateRange: { startDate: string; endDate: string };
|
previousDateRange: { startDate: string; endDate: string };
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLocale();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Text className="text-default">{title}</Text>
|
<div className="text-default text-sm">{title}</div>
|
||||||
<Flex className="items-baseline justify-start space-x-3 truncate">
|
<div className="flex items-baseline justify-start space-x-3 truncate">
|
||||||
<Metric className="text-emphasis">{valueFormatter(previousMetricData.count)}</Metric>
|
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(previousMetricData.count)}</div>
|
||||||
</Flex>
|
</div>
|
||||||
<Flex className="mt-4 justify-start space-x-2">
|
<div className="mt-4 flex items-center justify-start space-x-2">
|
||||||
<BadgeDelta
|
<Badge variant={badgeVariant} className="flex items-center gap-1">
|
||||||
deltaType={calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)}
|
{deltaIcon && <Icon name={deltaIcon} className="h-3 w-3" />}
|
||||||
/>
|
{deltaValue}%
|
||||||
<Flex className="justify-start space-x-1 truncate">
|
</Badge>
|
||||||
<Text
|
<div className="flex justify-start space-x-1 truncate">
|
||||||
color={colors[calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)]}>
|
|
||||||
{Number(previousMetricData.deltaPrevious).toFixed(0)}%
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={t("from_to_date_period", {
|
content={t("from_to_date_period", {
|
||||||
startDate: previousDateRange.startDate,
|
startDate: previousDateRange.startDate,
|
||||||
@@ -46,8 +77,8 @@ export const KPICard = ({
|
|||||||
{t("from_last_period")}
|
{t("from_last_period")}
|
||||||
</small>
|
</small>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Flex>
|
</div>
|
||||||
</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 { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||||
|
|
||||||
import { CardInsights } from "./Card";
|
import { ChartCard } from "./ChartCard";
|
||||||
|
|
||||||
export const LoadingInsight = () => {
|
export const LoadingInsight = () => {
|
||||||
return (
|
return (
|
||||||
<CardInsights>
|
<ChartCard title={<SkeletonText className="w-32" />}>
|
||||||
<SkeletonText className="w-32" />
|
|
||||||
<div className="m-auto flex h-80 flex-col items-center justify-center">
|
<div className="m-auto flex h-80 flex-col items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className={classNames("mx-4 h-8 w-8 animate-spin", "text-black dark:text-white")}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24">
|
stroke="currentColor"
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
stroke-width="2"
|
||||||
<path
|
stroke-linecap="round"
|
||||||
className="opacity-75"
|
stroke-linejoin="round"
|
||||||
fill="currentColor"
|
className="animate-spin">
|
||||||
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"
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</CardInsights>
|
</ChartCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,25 +2,24 @@
|
|||||||
|
|
||||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { User } from "@calcom/prisma/client";
|
|
||||||
import { Avatar } from "@calcom/ui/components/avatar";
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
|
|
||||||
import { ChartCardItem } from "./ChartCard";
|
import { ChartCardItem } from "./ChartCard";
|
||||||
|
|
||||||
export const UserStatsTable = ({
|
type UserStatsData = {
|
||||||
data,
|
userId: number;
|
||||||
}: {
|
user: {
|
||||||
data:
|
id: number;
|
||||||
| {
|
username: string | null;
|
||||||
userId: number | null;
|
name: string | null;
|
||||||
user: Pick<User, "avatarUrl" | "name">;
|
email: string;
|
||||||
emailMd5?: string;
|
avatarUrl: string;
|
||||||
count?: number;
|
};
|
||||||
averageRating?: number | null;
|
emailMd5: string;
|
||||||
username?: string;
|
count: number;
|
||||||
}[]
|
}[];
|
||||||
| undefined;
|
|
||||||
}) => {
|
export const UserStatsTable = ({ data }: { data: UserStatsData }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
// Filter out items without user data
|
// Filter out items without user data
|
||||||
@@ -32,7 +31,7 @@ export const UserStatsTable = ({
|
|||||||
filteredData.map((item) => (
|
filteredData.map((item) => (
|
||||||
<ChartCardItem
|
<ChartCardItem
|
||||||
key={item.userId || `user-${Math.random()}`}
|
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">
|
className="py-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -1,14 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
|
|
||||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||||
import { valueFormatter } from "../../lib/valueFormatter";
|
|
||||||
import { ChartCard } from "../ChartCard";
|
import { ChartCard } from "../ChartCard";
|
||||||
import { LineChart } from "../LineChart";
|
|
||||||
import { LoadingInsight } from "../LoadingInsights";
|
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 = () => {
|
export const AverageEventDurationChart = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const insightsBookingParams = useInsightsBookingParameters();
|
const insightsBookingParams = useInsightsBookingParameters();
|
||||||
@@ -36,14 +92,32 @@ export const AverageEventDurationChart = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data && data.length > 0 && !isNoData && (
|
{data && data.length > 0 && !isNoData && (
|
||||||
<LineChart
|
<div className="mt-4 h-80">
|
||||||
className="mt-4 h-80"
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
data={data}
|
<LineChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||||
index="Date"
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
categories={["Average"]}
|
<XAxis dataKey="Date" className="text-xs" axisLine={false} tickLine={false} />
|
||||||
colors={["blue"]}
|
<YAxis
|
||||||
valueFormatter={valueFormatter}
|
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>
|
</ChartCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ export const BookingKPICards = () => {
|
|||||||
<StatItem key={item.title} index={index}>
|
<StatItem key={item.title} index={index}>
|
||||||
<KPICard
|
<KPICard
|
||||||
title={item.title}
|
title={item.title}
|
||||||
value={data[item.index].count}
|
|
||||||
previousMetricData={data[item.index]}
|
previousMetricData={data[item.index]}
|
||||||
previousDateRange={data.previousRange}
|
previousDateRange={data.previousRange}
|
||||||
/>
|
/>
|
||||||
@@ -99,7 +98,6 @@ export const BookingKPICards = () => {
|
|||||||
<StatItem key={item.title} index={index}>
|
<StatItem key={item.title} index={index}>
|
||||||
<KPICard
|
<KPICard
|
||||||
title={item.title}
|
title={item.title}
|
||||||
value={data[item.index].count}
|
|
||||||
previousMetricData={data[item.index]}
|
previousMetricData={data[item.index]}
|
||||||
previousDateRange={data.previousRange}
|
previousDateRange={data.previousRange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,15 +13,13 @@ import {
|
|||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
|
|
||||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||||
import { ChartCard } from "../ChartCard";
|
import { ChartCard } from "../ChartCard";
|
||||||
import { LoadingInsight } from "../LoadingInsights";
|
import { LoadingInsight } from "../LoadingInsights";
|
||||||
|
|
||||||
type BookingsByHourData = {
|
type BookingsByHourData = RouterOutputs["viewer"]["insights"]["bookingsByHourStats"][number];
|
||||||
hour: number;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
|
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|||||||
@@ -1,14 +1,68 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
|
|
||||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||||
import { valueFormatter } from "../../lib/valueFormatter";
|
import { valueFormatter } from "../../lib/valueFormatter";
|
||||||
import { ChartCard } from "../ChartCard";
|
import { ChartCard } from "../ChartCard";
|
||||||
import { LineChart } from "../LineChart";
|
|
||||||
import { LoadingInsight } from "../LoadingInsights";
|
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 = () => {
|
export const EventTrendsChart = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const insightsBookingParams = useInsightsBookingParameters();
|
const insightsBookingParams = useInsightsBookingParameters();
|
||||||
@@ -30,15 +84,36 @@ export const EventTrendsChart = () => {
|
|||||||
if (!isSuccess) return null;
|
if (!isSuccess) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard title={t("event_trends")}>
|
<ChartCard title={t("event_trends")} legend={legend}>
|
||||||
<LineChart
|
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
|
||||||
className="linechart ml-4 mt-4 h-80 sm:ml-0"
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
data={eventTrends ?? []}
|
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||||
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)", "No-Show (Guest)"]}
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
index="Month"
|
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
|
||||||
colors={["purple", "green", "blue", "red", "slate", "orange"]}
|
<YAxis
|
||||||
valueFormatter={valueFormatter}
|
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>
|
</ChartCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { trpc } from "@calcom/trpc";
|
|||||||
|
|
||||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||||
import { ChartCard } from "../ChartCard";
|
import { ChartCard } from "../ChartCard";
|
||||||
import { FeedbackTable } from "../FeedbackTable";
|
|
||||||
import { LoadingInsight } from "../LoadingInsights";
|
import { LoadingInsight } from "../LoadingInsights";
|
||||||
|
import { RecentFeedbackTableContent } from "./RecentFeedbackTableContent";
|
||||||
|
|
||||||
export const RecentFeedbackTable = () => {
|
export const RecentFeedbackTable = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -26,7 +26,7 @@ export const RecentFeedbackTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard title={t("recent_ratings")}>
|
<ChartCard title={t("recent_ratings")}>
|
||||||
<FeedbackTable data={data} />
|
<RecentFeedbackTableContent data={data} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-17
@@ -2,31 +2,20 @@
|
|||||||
|
|
||||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
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 { Avatar } from "@calcom/ui/components/avatar";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
export const FeedbackTable = ({
|
type FeedbackData = RouterOutputs["viewer"]["insights"]["recentRatings"];
|
||||||
data,
|
|
||||||
}: {
|
export const RecentFeedbackTableContent = ({ data }: { data: FeedbackData }) => {
|
||||||
data:
|
|
||||||
| {
|
|
||||||
userId: number | null;
|
|
||||||
user: Pick<User, "avatarUrl" | "name">;
|
|
||||||
emailMd5?: string;
|
|
||||||
username?: string;
|
|
||||||
rating: number | null;
|
|
||||||
feedback: string | null;
|
|
||||||
}[]
|
|
||||||
| undefined;
|
|
||||||
}) => {
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-md">
|
<div className="overflow-hidden rounded-md">
|
||||||
{data && data?.length > 0 ? (
|
{data && data.length > 0 ? (
|
||||||
data?.map((item) => (
|
data.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.userId}
|
key={item.userId}
|
||||||
className="border-subtle flex items-center justify-between border-b px-4 py-3 last:border-b-0">
|
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 { BookedByCell } from "./BookedByCell";
|
||||||
export { BookingAtCell } from "./BookingAtCell";
|
export { BookingAtCell } from "./BookingAtCell";
|
||||||
export { BookingStatusBadge } from "./BookingStatusBadge";
|
export { BookingStatusBadge } from "./BookingStatusBadge";
|
||||||
export { CardInsights } from "./Card";
|
|
||||||
export { CellWithOverflowX } from "./CellWithOverflowX";
|
export { CellWithOverflowX } from "./CellWithOverflowX";
|
||||||
export { ChartCard } from "./ChartCard";
|
export { ChartCard } from "./ChartCard";
|
||||||
export { FeedbackTable } from "./FeedbackTable";
|
|
||||||
export { KPICard } from "./KPICard";
|
export { KPICard } from "./KPICard";
|
||||||
export { LineChart } from "./LineChart";
|
export { LineChart } from "./LineChart";
|
||||||
export { LoadingInsight } from "./LoadingInsights";
|
export { LoadingInsight } from "./LoadingInsights";
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Rectangle,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
@@ -8,7 +18,39 @@ import { ToggleGroup } from "@calcom/ui/components/form";
|
|||||||
|
|
||||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||||
import { ChartCard } from "../ChartCard";
|
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 {
|
interface FormCardProps {
|
||||||
formName: string;
|
formName: string;
|
||||||
@@ -16,6 +58,7 @@ interface FormCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FormCard({ formName, fields }: FormCardProps) {
|
function FormCard({ formName, fields }: FormCardProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
const fieldNames = Object.keys(fields);
|
const fieldNames = Object.keys(fields);
|
||||||
const [selectedField, setSelectedField] = useState(fieldNames[0]);
|
const [selectedField, setSelectedField] = useState(fieldNames[0]);
|
||||||
|
|
||||||
@@ -29,6 +72,17 @@ function FormCard({ formName, fields }: FormCardProps) {
|
|||||||
value: option.count,
|
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 (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@@ -38,13 +92,36 @@ function FormCard({ formName, fields }: FormCardProps) {
|
|||||||
className="w-fit"
|
className="w-fit"
|
||||||
onValueChange={(value) => value && setSelectedField(value)}
|
onValueChange={(value) => value && setSelectedField(value)}
|
||||||
/>
|
/>
|
||||||
{selectedFieldData && (
|
{selectedFieldData && selectedFieldData.length > 0 ? (
|
||||||
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
|
<div className="mt-4 h-80">
|
||||||
<BarList
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
data={selectedFieldData}
|
<BarChart
|
||||||
valueFormatter={(value: number) => value.toString()}
|
data={selectedFieldData}
|
||||||
className="mt-2"
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -66,8 +143,9 @@ export function FailedBookingsByField() {
|
|||||||
|
|
||||||
// routingFormId is always set, meaning data has only one entry.
|
// routingFormId is always set, meaning data has only one entry.
|
||||||
const [formName, fields] = Object.entries(data)[0];
|
const [formName, fields] = Object.entries(data)[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard title={t("failed_bookings_by_field")} subtitle={formName}>
|
<ChartCard title={t("failed_bookings_by_field")}>
|
||||||
<FormCard key={formName} formName={formName} fields={fields} />
|
<FormCard key={formName} formName={formName} fields={fields} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
|||||||
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { Avatar } from "@calcom/ui/components/avatar";
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
@@ -165,14 +166,7 @@ function FormCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoutedToTableRow = {
|
type RoutedToTableRow = RouterOutputs["viewer"]["insights"]["routedToPerPeriod"]["users"]["data"][number];
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
stats: { [key: string]: number };
|
|
||||||
performance: "above_average" | "at_average" | "below_average" | "median" | "no_data";
|
|
||||||
totalBookings: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TFunction) => {
|
const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TFunction) => {
|
||||||
switch (performance) {
|
switch (performance) {
|
||||||
@@ -292,7 +286,7 @@ export function RoutedToPerPeriod() {
|
|||||||
.sort((a, b) => a.getTime() - b.getTime());
|
.sort((a, b) => a.getTime() - b.getTime());
|
||||||
}, [data?.pages]);
|
}, [data?.pages]);
|
||||||
|
|
||||||
const processedData: RoutedToTableRow[] = useMemo(() => {
|
const processedData = useMemo(() => {
|
||||||
if (!data?.pages) return [];
|
if (!data?.pages) return [];
|
||||||
|
|
||||||
// Create a map for quick lookup of stats
|
// Create a map for quick lookup of stats
|
||||||
@@ -419,7 +413,7 @@ export function RoutedToPerPeriod() {
|
|||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<div className="flex cursor-pointer items-center gap-2">
|
<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">
|
<div className="flex flex-col gap-1 truncate">
|
||||||
<span>{row.name}</span>
|
<span>{row.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import { trpc } from "@calcom/trpc";
|
|||||||
import { RoutingFormResponsesDownload } from "../../filters/Download";
|
import { RoutingFormResponsesDownload } from "../../filters/Download";
|
||||||
import { OrgTeamsFilter } from "../../filters/OrgTeamsFilter";
|
import { OrgTeamsFilter } from "../../filters/OrgTeamsFilter";
|
||||||
import { useInsightsColumns } from "../../hooks/useInsightsColumns";
|
import { useInsightsColumns } from "../../hooks/useInsightsColumns";
|
||||||
import { useInsightsFacetedUniqueValues } from "../../hooks/useInsightsFacetedUniqueValues";
|
|
||||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||||
|
import { useInsightsRoutingFacetedUniqueValues } from "../../hooks/useInsightsRoutingFacetedUniqueValues";
|
||||||
import type { RoutingFormTableRow } from "../../lib/types";
|
import type { RoutingFormTableRow } from "../../lib/types";
|
||||||
import { RoutingKPICards } from "./RoutingKPICards";
|
import { RoutingKPICards } from "./RoutingKPICards";
|
||||||
|
|
||||||
@@ -38,19 +38,21 @@ export function RoutingFormResponsesTable() {
|
|||||||
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
|
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
|
||||||
useInsightsParameters();
|
useInsightsParameters();
|
||||||
|
|
||||||
const {
|
const { data: headers, isSuccess: isHeadersSuccess } =
|
||||||
data: headers,
|
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
||||||
isLoading: isHeadersLoading,
|
userId,
|
||||||
isSuccess: isHeadersSuccess,
|
teamId,
|
||||||
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
isAll,
|
||||||
|
routingFormId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInsightsFacetedUniqueValues = useInsightsRoutingFacetedUniqueValues({
|
||||||
|
headers,
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
isAll,
|
isAll,
|
||||||
routingFormId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
|
|
||||||
|
|
||||||
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
|
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
|
||||||
|
|
||||||
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
|
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
|
||||||
@@ -109,10 +111,6 @@ export function RoutingFormResponsesTable() {
|
|||||||
}
|
}
|
||||||
}, [table, getInsightsFacetedUniqueValues, routingFormId]);
|
}, [table, getInsightsFacetedUniqueValues, routingFormId]);
|
||||||
|
|
||||||
if (isHeadersLoading && !headers) {
|
|
||||||
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useColumnFilters } from "@calcom/features/data-table";
|
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
|
||||||
import { useInsightsParameters } from "@calcom/features/insights/hooks/useInsightsParameters";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
|
|
||||||
@@ -11,18 +10,9 @@ import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
|
|||||||
|
|
||||||
export function RoutingFunnel() {
|
export function RoutingFunnel() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { scope, selectedTeamId, startDate, endDate } = useInsightsParameters();
|
const insightsRoutingParams = useInsightsRoutingParameters();
|
||||||
const columnFilters = useColumnFilters({
|
|
||||||
exclude: ["createdAt"],
|
|
||||||
});
|
|
||||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
|
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
|
||||||
{
|
insightsRoutingParams,
|
||||||
scope,
|
|
||||||
selectedTeamId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
columnFilters,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
trpc: {
|
trpc: {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Grid } from "@tremor/react";
|
|
||||||
import { Flex, Text, Metric } from "@tremor/react";
|
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
|
import classNames from "@calcom/ui/classNames";
|
||||||
|
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||||
|
|
||||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||||
import { valueFormatter } from "../../lib";
|
import { valueFormatter } from "../../lib";
|
||||||
import { CardInsights } from "../Card";
|
import { ChartCard } from "../ChartCard";
|
||||||
|
|
||||||
export const RoutingKPICards = () => {
|
export const RoutingKPICards = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -61,35 +60,46 @@ export const RoutingKPICards = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ChartCard title={t("stats")}>
|
||||||
<Grid numColsSm={1} numColsLg={3} className="mt-4 gap-x-4 gap-y-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
{categories.map((item) => (
|
{categories.map((item) => (
|
||||||
<CardInsights key={item.title}>
|
<div
|
||||||
<Text className="text-default">{item.title}</Text>
|
key={item.title}
|
||||||
<Flex className="items-baseline justify-start space-x-3 truncate">
|
className={classNames(
|
||||||
<Metric className="text-emphasis">{valueFormatter(data[item.index])}</Metric>
|
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
|
||||||
</Flex>
|
)}>
|
||||||
</CardInsights>
|
<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 LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
|
||||||
|
const { t } = useLocale();
|
||||||
const { categories } = props;
|
const { categories } = props;
|
||||||
return (
|
return (
|
||||||
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
|
<ChartCard title={t("stats")}>
|
||||||
{categories.map((item) => (
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<CardInsights key={item.title}>
|
{categories.map((item) => (
|
||||||
<div className="animate-pulse">
|
<div
|
||||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
key={item.title}
|
||||||
<div className="mt-4 flex items-baseline space-x-3">
|
className={classNames(
|
||||||
<div className="h-8 w-16 rounded bg-gray-200" />
|
"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>
|
||||||
</div>
|
</div>
|
||||||
</CardInsights>
|
))}
|
||||||
))}
|
</div>
|
||||||
</Grid>
|
</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 { useMemo } from "react";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
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 { 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 { useDataTable } from "@calcom/features/data-table/hooks/useDataTable";
|
||||||
import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue";
|
import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue";
|
||||||
import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange";
|
import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange";
|
||||||
@@ -14,8 +15,6 @@ export function useInsightsBookingParameters() {
|
|||||||
const { scope, selectedTeamId } = useInsightsOrgTeams();
|
const { scope, selectedTeamId } = useInsightsOrgTeams();
|
||||||
const { timeZone } = useDataTable();
|
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;
|
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
|
||||||
// TODO for future: this preserving local time & startOf & endOf should be handled
|
// TODO for future: this preserving local time & startOf & endOf should be handled
|
||||||
// from DateRangeFilter out of the box.
|
// from DateRangeFilter out of the box.
|
||||||
@@ -34,6 +33,7 @@ export function useInsightsBookingParameters() {
|
|||||||
.toISOString();
|
.toISOString();
|
||||||
}, [createdAtRange?.endDate])
|
}, [createdAtRange?.endDate])
|
||||||
);
|
);
|
||||||
|
const columnFilters = useColumnFilters({ exclude: ["createdAt"] });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scope,
|
scope,
|
||||||
@@ -41,7 +41,6 @@ export function useInsightsBookingParameters() {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
timeZone: timeZone || CURRENT_TIMEZONE,
|
timeZone: timeZone || CURRENT_TIMEZONE,
|
||||||
eventTypeId,
|
columnFilters,
|
||||||
memberUserId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,24 @@ import { useMemo } from "react";
|
|||||||
|
|
||||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import type { HeaderRow, RoutingFormTableRow } from "../lib/types";
|
import { useInsightsBookingFacetedUniqueValues } from "./useInsightsBookingFacetedUniqueValues";
|
||||||
import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues";
|
|
||||||
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
|
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
|
||||||
|
|
||||||
type DummyTableRow = {
|
type DummyTableRow = {
|
||||||
bookingUserId: RoutingFormTableRow["bookingUserId"];
|
userId: number | null;
|
||||||
eventTypeId: number | null;
|
eventTypeId: number | null;
|
||||||
|
status: BookingStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyData: DummyTableRow[] = [];
|
const emptyData: DummyTableRow[] = [];
|
||||||
const dummyHeaders: HeaderRow[] = [];
|
|
||||||
|
|
||||||
export const useInsightsBookings = () => {
|
export const useInsightsBookings = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { isAll, teamId, userId } = useInsightsOrgTeams();
|
const { isAll, teamId, userId } = useInsightsOrgTeams();
|
||||||
|
|
||||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({
|
const getInsightsFacetedUniqueValues = useInsightsBookingFacetedUniqueValues({
|
||||||
headers: dummyHeaders,
|
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
isAll,
|
isAll,
|
||||||
@@ -30,18 +29,6 @@ export const useInsightsBookings = () => {
|
|||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const columnHelper = createColumnHelper<DummyTableRow>();
|
const columnHelper = createColumnHelper<DummyTableRow>();
|
||||||
return [
|
return [
|
||||||
columnHelper.accessor("bookingUserId", {
|
|
||||||
id: "bookingUserId",
|
|
||||||
header: t("user"),
|
|
||||||
enableColumnFilter: true,
|
|
||||||
enableSorting: false,
|
|
||||||
meta: {
|
|
||||||
filter: {
|
|
||||||
type: ColumnFilterType.SINGLE_SELECT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cell: () => null,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("eventTypeId", {
|
columnHelper.accessor("eventTypeId", {
|
||||||
id: "eventTypeId",
|
id: "eventTypeId",
|
||||||
header: t("event_type"),
|
header: t("event_type"),
|
||||||
@@ -55,6 +42,31 @@ export const useInsightsBookings = () => {
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: () => null,
|
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]);
|
}, [t]);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ const statusOrder: Record<BookingStatus, number> = {
|
|||||||
[BookingStatus.REJECTED]: 5,
|
[BookingStatus.REJECTED]: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useInsightsFacetedUniqueValues = ({
|
export const useInsightsRoutingFacetedUniqueValues = ({
|
||||||
headers,
|
headers,
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,3 +1,2 @@
|
|||||||
export { calculateDeltaType } from "./calculateDeltaType";
|
export { calculateDeltaType } from "./calculateDeltaType";
|
||||||
export { colors } from "./colors";
|
|
||||||
export { valueFormatter } from "./valueFormatter";
|
export { valueFormatter } from "./valueFormatter";
|
||||||
|
|||||||
@@ -76,6 +76,5 @@ export const bookingRepositoryBaseInputSchema = z.object({
|
|||||||
startDate: z.string(),
|
startDate: z.string(),
|
||||||
endDate: z.string(),
|
endDate: z.string(),
|
||||||
timeZone: z.string(),
|
timeZone: z.string(),
|
||||||
eventTypeId: z.coerce.number().optional().nullable(),
|
columnFilters: z.array(ZColumnFilter).optional(),
|
||||||
memberUserId: z.coerce.number().optional().nullable(),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
routingRepositoryBaseInputSchema,
|
routingRepositoryBaseInputSchema,
|
||||||
bookingRepositoryBaseInputSchema,
|
bookingRepositoryBaseInputSchema,
|
||||||
} from "@calcom/features/insights/server/raw-data.schema";
|
} 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 { getInsightsRoutingService } from "@calcom/lib/di/containers/insights-routing";
|
||||||
import { InsightsBookingService } from "@calcom/lib/server/service/insightsBooking";
|
|
||||||
import type { readonlyPrisma } from "@calcom/prisma";
|
import type { readonlyPrisma } from "@calcom/prisma";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
|
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
|
* Helper function to create InsightsBookingService with standardized parameters
|
||||||
*/
|
*/
|
||||||
function createInsightsBookingService(
|
function createInsightsBookingService(
|
||||||
ctx: { insightsDb: typeof readonlyPrisma; user: { id: number; organizationId: number | null } },
|
ctx: { user: { id: number; organizationId: number | null } },
|
||||||
input: z.infer<typeof bookingRepositoryBaseInputSchema>,
|
input: z.infer<typeof bookingRepositoryBaseInputSchema>,
|
||||||
dateTarget: "createdAt" | "startTime" = "createdAt"
|
dateTarget: "createdAt" | "startTime" = "createdAt"
|
||||||
) {
|
) {
|
||||||
const { scope, selectedTeamId, eventTypeId, memberUserId, startDate, endDate } = input;
|
const { scope, selectedTeamId, startDate, endDate, columnFilters } = input;
|
||||||
|
|
||||||
return new InsightsBookingService({
|
return getInsightsBookingService({
|
||||||
prisma: ctx.insightsDb,
|
|
||||||
options: {
|
options: {
|
||||||
scope,
|
scope,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -331,8 +330,7 @@ function createInsightsBookingService(
|
|||||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
...(eventTypeId && { eventTypeId }),
|
...(columnFilters && { columnFilters }),
|
||||||
...(memberUserId && { memberUserId }),
|
|
||||||
dateRange: {
|
dateRange: {
|
||||||
target: dateTarget,
|
target: dateTarget,
|
||||||
startDate,
|
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({
|
export const insightsRouter = router({
|
||||||
bookingKPIStats: userBelongsToTeamProcedure
|
bookingKPIStats: userBelongsToTeamProcedure
|
||||||
.input(bookingRepositoryBaseInputSchema)
|
.input(bookingRepositoryBaseInputSchema)
|
||||||
@@ -1002,19 +1020,7 @@ export const insightsRouter = router({
|
|||||||
timeView,
|
timeView,
|
||||||
weekStart: ctx.user.weekStart,
|
weekStart: ctx.user.weekStart,
|
||||||
});
|
});
|
||||||
const insightsRoutingService = getInsightsRoutingService({
|
const insightsRoutingService = createInsightsRoutingService(ctx, input);
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
return await insightsRoutingService.getRoutingFunnelData(dateRanges);
|
return await insightsRoutingService.getRoutingFunnelData(dateRanges);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
export interface IRedisService {
|
export interface IRedisService {
|
||||||
get: <TData>(key: string) => Promise<TData | null>;
|
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>;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,13 @@ export class RedisService implements IRedisService {
|
|||||||
private redis: Redis;
|
private redis: Redis;
|
||||||
|
|
||||||
constructor() {
|
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> {
|
async get<TData>(key: string): Promise<TData | null> {
|
||||||
@@ -17,9 +23,16 @@ export class RedisService implements IRedisService {
|
|||||||
return this.redis.del(key);
|
return this.redis.del(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async set<TData>(key: string, value: TData): Promise<"OK" | TData | null> {
|
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
|
||||||
// Implementation for setting value in Redis
|
return this.redis.set(
|
||||||
return this.redis.set(key, value);
|
key,
|
||||||
|
value,
|
||||||
|
opts?.ttl
|
||||||
|
? {
|
||||||
|
px: opts.ttl,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async expire(key: string, seconds: number): Promise<0 | 1> {
|
async expire(key: string, seconds: number): Promise<0 | 1> {
|
||||||
|
|||||||
@@ -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 { showToast } from "@calcom/ui/components/toast";
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
|
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 = {
|
type WebhookProps = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,21 +47,19 @@ export default function WebhookListItem(props: {
|
|||||||
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
|
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
|
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
|
||||||
revalidateWebhooksList();
|
revalidateWebhooksListGetByViewer();
|
||||||
|
revalidateWebhookList();
|
||||||
showToast(t("webhook_removed_successfully"), "success");
|
showToast(t("webhook_removed_successfully"), "success");
|
||||||
await utils.viewer.webhook.getByViewer.invalidate();
|
|
||||||
await utils.viewer.webhook.list.invalidate();
|
|
||||||
await utils.viewer.eventTypes.get.invalidate();
|
await utils.viewer.eventTypes.get.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
|
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
|
||||||
async onSuccess(data) {
|
async onSuccess(data) {
|
||||||
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
|
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
|
||||||
revalidateWebhooksList();
|
revalidateWebhooksListGetByViewer();
|
||||||
|
revalidateWebhookList();
|
||||||
// TODO: Better success message
|
// TODO: Better success message
|
||||||
showToast(t(data?.active ? "enabled" : "disabled"), "success");
|
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();
|
await utils.viewer.eventTypes.get.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
|||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
|
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
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 type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||||
import WebhookForm 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({
|
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await utils.viewer.webhook.list.invalidate();
|
revalidateWebhookById(webhook?.id ?? "");
|
||||||
await utils.viewer.webhook.get.invalidate({ webhookId: webhook?.id });
|
revalidateWebhookList();
|
||||||
showToast(t("webhook_updated_successfully"), "success");
|
showToast(t("webhook_updated_successfully"), "success");
|
||||||
revalidateWebhooksList();
|
revalidateWebhooksListGetByViewer();
|
||||||
router.push("/settings/developer/webhooks");
|
router.push("/settings/developer/webhooks");
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
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 type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||||
import WebhookForm from "../components/WebhookForm";
|
import WebhookForm from "../components/WebhookForm";
|
||||||
@@ -35,7 +36,8 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
|
|||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
showToast(t("webhook_created_successfully"), "success");
|
showToast(t("webhook_created_successfully"), "success");
|
||||||
await utils.viewer.webhook.list.invalidate();
|
await utils.viewer.webhook.list.invalidate();
|
||||||
revalidateWebhooksList();
|
revalidateWebhookList();
|
||||||
|
revalidateWebhooksListGetByViewer();
|
||||||
router.push("/settings/developer/webhooks");
|
router.push("/settings/developer/webhooks");
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContainer } from "@evyweb/ioctopus";
|
import { createContainer } from "@evyweb/ioctopus";
|
||||||
|
|
||||||
|
import { redisModule } from "@calcom/features/redis/di/redisModule";
|
||||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||||
import type { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
|
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 { selectedSlotsRepositoryModule } from "../modules/selectedSlots";
|
||||||
import { teamRepositoryModule } from "../modules/team";
|
import { teamRepositoryModule } from "../modules/team";
|
||||||
import { userRepositoryModule } from "../modules/user";
|
import { userRepositoryModule } from "../modules/user";
|
||||||
|
import { getUserAvailabilityModule } from "../modules/get-user-availability";
|
||||||
|
|
||||||
const container = createContainer();
|
const container = createContainer();
|
||||||
|
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
|
||||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||||
container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
|
container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
|
||||||
container.load(DI_TOKENS.SCHEDULE_REPOSITORY_MODULE, scheduleRepositoryModule);
|
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.CACHE_SERVICE_MODULE, cacheModule);
|
||||||
container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimitsModule);
|
container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimitsModule);
|
||||||
container.load(DI_TOKENS.AVAILABLE_SLOTS_SERVICE_MODULE, availableSlotsModule);
|
container.load(DI_TOKENS.AVAILABLE_SLOTS_SERVICE_MODULE, availableSlotsModule);
|
||||||
|
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule)
|
||||||
export function getAvailableSlotsService() {
|
export function getAvailableSlotsService() {
|
||||||
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
|
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,
|
bookingRepo: DI_TOKENS.BOOKING_REPOSITORY,
|
||||||
eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY,
|
eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY,
|
||||||
routingFormResponseRepo: DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY,
|
routingFormResponseRepo: DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY,
|
||||||
|
redisClient: DI_TOKENS.REDIS_CLIENT,
|
||||||
cacheService: DI_TOKENS.CACHE_SERVICE,
|
cacheService: DI_TOKENS.CACHE_SERVICE,
|
||||||
checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE,
|
checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE,
|
||||||
|
userAvailabilityService: DI_TOKENS.GET_USER_AVAILABILITY_SERVICE
|
||||||
} satisfies Record<keyof IAvailableSlotsService, symbol>);
|
} 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>);
|
||||||
@@ -2,6 +2,7 @@ export const DI_TOKENS = {
|
|||||||
PRISMA_CLIENT: Symbol("PrismaClient"),
|
PRISMA_CLIENT: Symbol("PrismaClient"),
|
||||||
READ_ONLY_PRISMA_CLIENT: Symbol("ReadOnlyPrismaClient"),
|
READ_ONLY_PRISMA_CLIENT: Symbol("ReadOnlyPrismaClient"),
|
||||||
PRISMA_MODULE: Symbol("PrismaModule"),
|
PRISMA_MODULE: Symbol("PrismaModule"),
|
||||||
|
REDIS_CLIENT: Symbol("RedisClient"),
|
||||||
OOO_REPOSITORY: Symbol("OOORepository"),
|
OOO_REPOSITORY: Symbol("OOORepository"),
|
||||||
OOO_REPOSITORY_MODULE: Symbol("OOORepositoryModule"),
|
OOO_REPOSITORY_MODULE: Symbol("OOORepositoryModule"),
|
||||||
SCHEDULE_REPOSITORY: Symbol("ScheduleRepository"),
|
SCHEDULE_REPOSITORY: Symbol("ScheduleRepository"),
|
||||||
@@ -22,6 +23,8 @@ export const DI_TOKENS = {
|
|||||||
AVAILABLE_SLOTS_SERVICE_MODULE: Symbol("AvailableSlotsModule"),
|
AVAILABLE_SLOTS_SERVICE_MODULE: Symbol("AvailableSlotsModule"),
|
||||||
INSIGHTS_ROUTING_SERVICE: Symbol("InsightsRoutingService"),
|
INSIGHTS_ROUTING_SERVICE: Symbol("InsightsRoutingService"),
|
||||||
INSIGHTS_ROUTING_SERVICE_MODULE: Symbol("InsightsRoutingServiceModule"),
|
INSIGHTS_ROUTING_SERVICE_MODULE: Symbol("InsightsRoutingServiceModule"),
|
||||||
|
INSIGHTS_BOOKING_SERVICE: Symbol("InsightsBookingService"),
|
||||||
|
INSIGHTS_BOOKING_SERVICE_MODULE: Symbol("InsightsBookingServiceModule"),
|
||||||
FEATURES_REPOSITORY: Symbol("FeaturesRepository"),
|
FEATURES_REPOSITORY: Symbol("FeaturesRepository"),
|
||||||
FEATURES_REPOSITORY_MODULE: Symbol("FeaturesRepositoryModule"),
|
FEATURES_REPOSITORY_MODULE: Symbol("FeaturesRepositoryModule"),
|
||||||
CACHE_SERVICE: Symbol("CacheService"),
|
CACHE_SERVICE: Symbol("CacheService"),
|
||||||
@@ -30,4 +33,6 @@ export const DI_TOKENS = {
|
|||||||
CHECK_BOOKING_LIMITS_SERVICE_MODULE: Symbol("CheckBookingLimitsServiceModule"),
|
CHECK_BOOKING_LIMITS_SERVICE_MODULE: Symbol("CheckBookingLimitsServiceModule"),
|
||||||
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE: Symbol("CheckBookingAndDurationLimitsService"),
|
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE: Symbol("CheckBookingAndDurationLimitsService"),
|
||||||
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE_MODULE: Symbol("CheckBookingAndDurationLimitsServiceModule"),
|
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE_MODULE: Symbol("CheckBookingAndDurationLimitsServiceModule"),
|
||||||
|
GET_USER_AVAILABILITY_SERVICE: Symbol("GetUserAvailabilityService"),
|
||||||
|
GET_USER_AVAILABILITY_SERVICE_MODULE: Symbol("GetUserAvailabilityModule"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+547
-609
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
import type { Dayjs } from "@calcom/dayjs";
|
import type { Dayjs } from "@calcom/dayjs";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
||||||
|
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
|
||||||
import { getStartEndDateforLimitCheck } from "@calcom/lib/getBusyTimes";
|
import { getStartEndDateforLimitCheck } from "@calcom/lib/getBusyTimes";
|
||||||
import type { EventType } from "@calcom/lib/getUserAvailability";
|
import type { EventType } from "@calcom/lib/getUserAvailability";
|
||||||
import { getPeriodStartDatesBetween } from "@calcom/lib/getUserAvailability";
|
|
||||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||||
import { performance } from "@calcom/lib/server/perfObserver";
|
import { performance } from "@calcom/lib/server/perfObserver";
|
||||||
import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking";
|
import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking";
|
||||||
@@ -86,6 +86,7 @@ const _getBusyTimesFromBookingLimits = async (params: {
|
|||||||
includeManagedEvents?: boolean;
|
includeManagedEvents?: boolean;
|
||||||
timeZone?: string | null;
|
timeZone?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
|
const userAvailabilityService = getUserAvailabilityService();
|
||||||
const {
|
const {
|
||||||
bookings,
|
bookings,
|
||||||
bookingLimits,
|
bookingLimits,
|
||||||
@@ -105,7 +106,7 @@ const _getBusyTimesFromBookingLimits = async (params: {
|
|||||||
if (!limit) continue;
|
if (!limit) continue;
|
||||||
|
|
||||||
const unit = intervalLimitKeyToUnit(key);
|
const unit = intervalLimitKeyToUnit(key);
|
||||||
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
const periodStartDates = userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||||
|
|
||||||
for (const periodStart of periodStartDates) {
|
for (const periodStart of periodStartDates) {
|
||||||
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
||||||
@@ -162,12 +163,14 @@ const _getBusyTimesFromDurationLimits = async (
|
|||||||
timeZone: string,
|
timeZone: string,
|
||||||
rescheduleUid?: string
|
rescheduleUid?: string
|
||||||
) => {
|
) => {
|
||||||
|
const userAvailabilityService = getUserAvailabilityService();
|
||||||
|
|
||||||
for (const key of descendingLimitKeys) {
|
for (const key of descendingLimitKeys) {
|
||||||
const limit = durationLimits?.[key];
|
const limit = durationLimits?.[key];
|
||||||
if (!limit) continue;
|
if (!limit) continue;
|
||||||
|
|
||||||
const unit = intervalLimitKeyToUnit(key);
|
const unit = intervalLimitKeyToUnit(key);
|
||||||
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
const periodStartDates = userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||||
|
|
||||||
for (const periodStart of periodStartDates) {
|
for (const periodStart of periodStartDates) {
|
||||||
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
||||||
|
|||||||
+25
-41
@@ -2,38 +2,15 @@ import { createInstance } from "i18next";
|
|||||||
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
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 translationCache = new Map<string, Record<string, string>>();
|
||||||
const i18nInstanceCache = new Map<string, any>();
|
const i18nInstanceCache = new Map<string, any>();
|
||||||
|
const SUPPORTED_NAMESPACES = ["common"];
|
||||||
/**
|
|
||||||
* 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 {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads translations for a specific locale and namespace with optimized caching
|
* 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
|
* @param {string} ns - The namespace for the translations
|
||||||
* @returns {Promise<Record<string, string>>} Translations object or fallback translations on failure
|
* @returns {Promise<Record<string, string>>} Translations object or fallback translations on failure
|
||||||
*/
|
*/
|
||||||
export async function loadTranslations(_locale: string, ns: string) {
|
export async function loadTranslations(_locale: string, _ns: string) {
|
||||||
const locale = _locale === "zh" ? "zh-CN" : _locale;
|
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}`;
|
const cacheKey = `${locale}-${ns}`;
|
||||||
|
|
||||||
if (translationCache.has(cacheKey)) {
|
if (translationCache.has(cacheKey)) {
|
||||||
return translationCache.get(cacheKey);
|
return translationCache.get(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
|
||||||
try {
|
try {
|
||||||
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
|
const response = await fetchWithTimeout(
|
||||||
const response = await fetch(url, {
|
url,
|
||||||
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
|
{
|
||||||
});
|
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
|
||||||
|
},
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const translations = await response.json();
|
||||||
translationCache.set(cacheKey, translations);
|
translationCache.set(cacheKey, translations);
|
||||||
return translations;
|
return translations;
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.warn(`Failed to load translations for ${locale}/${ns}, falling back to English:`, error);
|
console.error("loadTranslations Error:", err);
|
||||||
const fallbackTranslations = await loadFallbackTranslations();
|
|
||||||
return fallbackTranslations;
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,4 +79,71 @@ export class PrismaOOORepository {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findUserOOODays({userId, dateTo, dateFrom}: {userId: number, dateTo: string, dateFrom: string}) {
|
||||||
|
return this.prismaClient.outOfOfficeEntry.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
OR: [
|
||||||
|
// outside of range
|
||||||
|
// (start <= 'dateTo' AND end >= 'dateFrom')
|
||||||
|
{
|
||||||
|
start: {
|
||||||
|
lte: dateTo,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
gte: dateFrom,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// start is between dateFrom and dateTo but end is outside of range
|
||||||
|
// (start <= 'dateTo' AND end >= 'dateTo')
|
||||||
|
{
|
||||||
|
start: {
|
||||||
|
lte: dateTo,
|
||||||
|
},
|
||||||
|
|
||||||
|
end: {
|
||||||
|
gte: dateTo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// end is between dateFrom and dateTo but start is outside of range
|
||||||
|
// (start <= 'dateFrom' OR end <= 'dateTo')
|
||||||
|
{
|
||||||
|
start: {
|
||||||
|
lte: dateFrom,
|
||||||
|
},
|
||||||
|
|
||||||
|
end: {
|
||||||
|
lte: dateTo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
start: true,
|
||||||
|
end: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toUser: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
emoji: true,
|
||||||
|
reason: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+432
@@ -0,0 +1,432 @@
|
|||||||
|
import { describe, expect, beforeAll, afterAll, beforeEach, it } from "vitest";
|
||||||
|
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import { PrismaQueuedFormResponseRepository } from "./PrismaQueuedFormResponseRepository";
|
||||||
|
|
||||||
|
const repository = new PrismaQueuedFormResponseRepository(prisma);
|
||||||
|
|
||||||
|
// Test Data Builders
|
||||||
|
const createTestUser = async (overrides?: { email?: string; username?: string }) => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
return prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: overrides?.email || `test-qfr-${timestamp}@example.com`,
|
||||||
|
username: overrides?.username || `test-qfr-${timestamp}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestForm = async (userId: number, overrides?: { name?: string }) => {
|
||||||
|
return prisma.app_RoutingForms_Form.create({
|
||||||
|
data: {
|
||||||
|
name: overrides?.name || `Test Form ${Date.now()}`,
|
||||||
|
userId,
|
||||||
|
fields: [],
|
||||||
|
routes: [],
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFormResponse = async (
|
||||||
|
formId: string,
|
||||||
|
overrides?: {
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
chosenRouteId?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
return prisma.app_RoutingForms_FormResponse.create({
|
||||||
|
data: {
|
||||||
|
formId,
|
||||||
|
response: overrides?.response || { test: "response" },
|
||||||
|
chosenRouteId: overrides?.chosenRouteId || "test-route-id",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createQueuedResponse = async (
|
||||||
|
formId: string,
|
||||||
|
overrides?: {
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
chosenRouteId?: string;
|
||||||
|
actualResponseId?: number | null;
|
||||||
|
createdAt?: Date;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
return prisma.app_RoutingForms_QueuedFormResponse.create({
|
||||||
|
data: {
|
||||||
|
formId,
|
||||||
|
response: overrides?.response || { field1: "value" },
|
||||||
|
chosenRouteId: overrides?.chosenRouteId || "test-route-id",
|
||||||
|
actualResponseId: overrides?.actualResponseId ?? null,
|
||||||
|
...(overrides?.createdAt && { createdAt: overrides.createdAt }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createQueuedResponsesBatch = async (
|
||||||
|
formId: string,
|
||||||
|
count: number,
|
||||||
|
baseOverrides?: Parameters<typeof createQueuedResponse>[1]
|
||||||
|
) => {
|
||||||
|
const responses = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const response = await createQueuedResponse(formId, {
|
||||||
|
...baseOverrides,
|
||||||
|
response: { field1: `value${i}` },
|
||||||
|
});
|
||||||
|
responses.push(response);
|
||||||
|
}
|
||||||
|
return responses;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Date Helpers
|
||||||
|
const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Assertion Helpers
|
||||||
|
const expectQueuedResponseCount = async (formId: string, expectedCount: number) => {
|
||||||
|
const count = await prisma.app_RoutingForms_QueuedFormResponse.count({
|
||||||
|
where: { formId },
|
||||||
|
});
|
||||||
|
expect(count).toBe(expectedCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectQueuedResponseExists = async (responseId: string) => {
|
||||||
|
const response = await prisma.app_RoutingForms_QueuedFormResponse.findUnique({
|
||||||
|
where: { id: responseId },
|
||||||
|
});
|
||||||
|
expect(response).not.toBeNull();
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectQueuedResponseNotExists = async (responseId: string) => {
|
||||||
|
const response = await prisma.app_RoutingForms_QueuedFormResponse.findUnique({
|
||||||
|
where: { id: responseId },
|
||||||
|
});
|
||||||
|
expect(response).toBeNull();
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectResponseFields = (response: unknown) => {
|
||||||
|
const expectedFields = [
|
||||||
|
"id",
|
||||||
|
"formId",
|
||||||
|
"response",
|
||||||
|
"chosenRouteId",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
"actualResponseId",
|
||||||
|
];
|
||||||
|
expectedFields.forEach((field) => {
|
||||||
|
expect(response).toHaveProperty(field);
|
||||||
|
});
|
||||||
|
expect(Object.keys(response as object).sort()).toEqual(expectedFields.sort());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup Helpers
|
||||||
|
const cleanupQueuedResponses = async (formId: string) => {
|
||||||
|
await prisma.app_RoutingForms_QueuedFormResponse.deleteMany({
|
||||||
|
where: { formId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("PrismaQueuedFormResponseRepository Integration Tests", () => {
|
||||||
|
let testForm: { id: string };
|
||||||
|
let testUser: { id: number };
|
||||||
|
let testFormResponse: { id: number };
|
||||||
|
let testResponses: Array<{ id: string; actualResponseId: number | null; createdAt: Date }> = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create test user
|
||||||
|
testUser = await createTestUser();
|
||||||
|
|
||||||
|
// Create test form
|
||||||
|
testForm = await createTestForm(testUser.id);
|
||||||
|
|
||||||
|
// Create test form response for actualResponseId references
|
||||||
|
testFormResponse = await createFormResponse(testForm.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up all test data
|
||||||
|
await prisma.app_RoutingForms_QueuedFormResponse.deleteMany({
|
||||||
|
where: { formId: testForm.id },
|
||||||
|
});
|
||||||
|
await prisma.app_RoutingForms_FormResponse.deleteMany({
|
||||||
|
where: { formId: testForm.id },
|
||||||
|
});
|
||||||
|
await prisma.app_RoutingForms_Form.delete({
|
||||||
|
where: { id: testForm.id },
|
||||||
|
});
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id: testUser.id },
|
||||||
|
});
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear queued responses before each test
|
||||||
|
await cleanupQueuedResponses(testForm.id);
|
||||||
|
testResponses = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findMany", () => {
|
||||||
|
it("should find queued responses by actualResponseId", async () => {
|
||||||
|
// Create additional form responses for testing
|
||||||
|
const formResponse1 = await createFormResponse(testForm.id, {
|
||||||
|
response: { field1: "form-response-1" },
|
||||||
|
});
|
||||||
|
const formResponse2 = await createFormResponse(testForm.id, {
|
||||||
|
response: { field1: "form-response-2" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test queued responses
|
||||||
|
const queuedResponse1 = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "value1" },
|
||||||
|
actualResponseId: formResponse1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedResponse2 = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "value2" },
|
||||||
|
actualResponseId: formResponse2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedResponse3 = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "value3" },
|
||||||
|
actualResponseId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
testResponses.push(queuedResponse1, queuedResponse2, queuedResponse3);
|
||||||
|
|
||||||
|
// Test finding by specific actualResponseId
|
||||||
|
const result = await repository.findMany({
|
||||||
|
where: { actualResponseId: formResponse1.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe(queuedResponse1.id);
|
||||||
|
expect(result[0].actualResponseId).toBe(formResponse1.id);
|
||||||
|
expect(result[0].formId).toBe(testForm.id);
|
||||||
|
expect(result[0].response).toEqual({ field1: "value1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find queued responses by actualResponseId null", async () => {
|
||||||
|
// Create test queued responses
|
||||||
|
await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "value1" },
|
||||||
|
actualResponseId: testFormResponse.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedResponse2 = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "value2" },
|
||||||
|
actualResponseId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedResponse3 = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "value3" },
|
||||||
|
actualResponseId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test finding by actualResponseId null
|
||||||
|
const result = await repository.findMany({
|
||||||
|
where: { actualResponseId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
const resultIds = result.map((r) => r.id).sort();
|
||||||
|
const expectedIds = [queuedResponse2.id, queuedResponse3.id].sort();
|
||||||
|
expect(resultIds).toEqual(expectedIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find queued responses created before a specific date", async () => {
|
||||||
|
// Create test queued responses with different timestamps
|
||||||
|
const now = new Date();
|
||||||
|
const oneDayAgo = daysAgo(1);
|
||||||
|
const twoDaysAgo = daysAgo(2);
|
||||||
|
|
||||||
|
// Create responses with specific timestamps
|
||||||
|
const oldResponse1 = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "old1" },
|
||||||
|
createdAt: twoDaysAgo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldResponse2 = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "old2" },
|
||||||
|
createdAt: twoDaysAgo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentResponse = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "recent" },
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
testResponses.push(oldResponse1, oldResponse2, recentResponse);
|
||||||
|
|
||||||
|
// Test finding responses created before oneDayAgo
|
||||||
|
const result = await repository.findMany({
|
||||||
|
where: { createdAt: { lt: oneDayAgo } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
const resultIds = result.map((r) => r.id).sort();
|
||||||
|
const expectedIds = [oldResponse1.id, oldResponse2.id].sort();
|
||||||
|
expect(resultIds).toEqual(expectedIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply take parameter to limit results", async () => {
|
||||||
|
// Create multiple test queued responses
|
||||||
|
const responses = await createQueuedResponsesBatch(testForm.id, 5, { actualResponseId: null });
|
||||||
|
testResponses.push(...responses);
|
||||||
|
|
||||||
|
// Test with take parameter
|
||||||
|
const result = await repository.findMany({
|
||||||
|
where: { actualResponseId: null },
|
||||||
|
params: { take: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle combined where conditions", async () => {
|
||||||
|
// Create test queued responses
|
||||||
|
const now = new Date();
|
||||||
|
const oneDayAgo = daysAgo(1);
|
||||||
|
const twoDaysAgo = daysAgo(2);
|
||||||
|
|
||||||
|
const oldWithNull = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "old-null" },
|
||||||
|
actualResponseId: null,
|
||||||
|
createdAt: twoDaysAgo,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "old-with-id" },
|
||||||
|
actualResponseId: testFormResponse.id,
|
||||||
|
createdAt: twoDaysAgo,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "recent-null" },
|
||||||
|
actualResponseId: null,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test finding old responses with null actualResponseId
|
||||||
|
const result = await repository.findMany({
|
||||||
|
where: {
|
||||||
|
actualResponseId: null,
|
||||||
|
createdAt: { lt: oneDayAgo },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe(oldWithNull.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when where is empty", async () => {
|
||||||
|
await expect(
|
||||||
|
repository.findMany({
|
||||||
|
where: {},
|
||||||
|
})
|
||||||
|
).rejects.toThrow("where is empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when where has only undefined values", async () => {
|
||||||
|
await expect(
|
||||||
|
repository.findMany({
|
||||||
|
where: {
|
||||||
|
actualResponseId: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrow("where is empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array when no matches found", async () => {
|
||||||
|
const result = await repository.findMany({
|
||||||
|
where: { actualResponseId: 999999 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all fields defined in safeSelect", async () => {
|
||||||
|
const formResponseForTest = await createFormResponse(testForm.id, {
|
||||||
|
response: { field1: "form-response-test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await createQueuedResponse(testForm.id, {
|
||||||
|
response: { field1: "test-value" },
|
||||||
|
actualResponseId: formResponseForTest.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await repository.findMany({
|
||||||
|
where: { actualResponseId: formResponseForTest.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
const foundResponse = result[0];
|
||||||
|
|
||||||
|
// Verify all fields from safeSelect are present
|
||||||
|
expect(foundResponse).toHaveProperty("id", response.id);
|
||||||
|
expect(foundResponse).toHaveProperty("formId", response.formId);
|
||||||
|
expect(foundResponse).toHaveProperty("response", response.response);
|
||||||
|
expect(foundResponse).toHaveProperty("chosenRouteId", response.chosenRouteId);
|
||||||
|
expect(foundResponse).toHaveProperty("createdAt");
|
||||||
|
expect(foundResponse).toHaveProperty("updatedAt");
|
||||||
|
expect(foundResponse).toHaveProperty("actualResponseId", response.actualResponseId);
|
||||||
|
|
||||||
|
// Verify no extra fields are included
|
||||||
|
expectResponseFields(foundResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteByIds", () => {
|
||||||
|
it("should delete queued responses by their ids", async () => {
|
||||||
|
// Create test queued responses
|
||||||
|
const response1 = await createQueuedResponse(testForm.id, { response: { field1: "value1" } });
|
||||||
|
const response2 = await createQueuedResponse(testForm.id, { response: { field1: "value2" } });
|
||||||
|
const response3 = await createQueuedResponse(testForm.id, { response: { field1: "value3" } });
|
||||||
|
|
||||||
|
// Delete response1 and response2
|
||||||
|
const result = await repository.deleteByIds([response1.id, response2.id]);
|
||||||
|
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
|
||||||
|
// Verify only response3 remains
|
||||||
|
await expectQueuedResponseCount(testForm.id, 1);
|
||||||
|
await expectQueuedResponseExists(response3.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return count 0 when deleting non-existent ids", async () => {
|
||||||
|
const result = await repository.deleteByIds(["non-existent-id-1", "non-existent-id-2"]);
|
||||||
|
|
||||||
|
expect(result.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty array of ids", async () => {
|
||||||
|
const result = await repository.deleteByIds([]);
|
||||||
|
|
||||||
|
expect(result.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle single id deletion", async () => {
|
||||||
|
const response = await createQueuedResponse(testForm.id, { response: { field1: "single-delete" } });
|
||||||
|
|
||||||
|
const result = await repository.deleteByIds([response.id]);
|
||||||
|
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
|
||||||
|
// Verify it's deleted
|
||||||
|
await expectQueuedResponseNotExists(response.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mix of existing and non-existing ids", async () => {
|
||||||
|
const response = await createQueuedResponse(testForm.id, { response: { field1: "mixed-delete" } });
|
||||||
|
|
||||||
|
const result = await repository.deleteByIds([response.id, "non-existent-id"]);
|
||||||
|
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
QueuedFormResponseRepositoryFindManyArgs,
|
||||||
|
QueuedFormResponseRepositoryFindManyWhere,
|
||||||
|
QueuedFormResponseRepositoryInterface,
|
||||||
|
} from "./QueuedFormResponseRepository.interface";
|
||||||
|
|
||||||
|
const safeSelect = {
|
||||||
|
id: true,
|
||||||
|
formId: true,
|
||||||
|
response: true,
|
||||||
|
chosenRouteId: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
actualResponseId: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PrismaQueuedFormResponseRepository implements QueuedFormResponseRepositoryInterface {
|
||||||
|
constructor(private readonly prismaClient: PrismaClient = prisma) {}
|
||||||
|
|
||||||
|
private assertWhereIsNotEmpty(where: QueuedFormResponseRepositoryFindManyWhere) {
|
||||||
|
let found = false;
|
||||||
|
Object.entries(where).forEach(([_, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
throw new Error("where is empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMany({ where, params }: QueuedFormResponseRepositoryFindManyArgs) {
|
||||||
|
this.assertWhereIsNotEmpty(where);
|
||||||
|
return await this.prismaClient.app_RoutingForms_QueuedFormResponse.findMany({
|
||||||
|
where,
|
||||||
|
select: safeSelect,
|
||||||
|
...(params?.take && { take: params.take }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByIds(ids: string[]) {
|
||||||
|
return await this.prismaClient.app_RoutingForms_QueuedFormResponse.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user