Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2965450929 | ||
|
|
db9b9c45dc | ||
|
|
37f500526c | ||
|
|
006aecb066 | ||
|
|
0829929e98 | ||
|
|
a61148cb51 | ||
|
|
593c48c8b4 | ||
|
|
fb364971fa | ||
|
|
a2205d497d | ||
|
|
cf4e7a5d2c | ||
|
|
0e2ee3adb2 | ||
|
|
dbe8a85cf5 | ||
|
|
84210dc1b4 | ||
|
|
4061bfb3fe | ||
|
|
18059e50fa | ||
|
|
852eb346ed | ||
|
|
b7e11dc112 | ||
|
|
35a373e70a | ||
|
|
02c0b721b9 | ||
|
|
29349845f8 | ||
|
|
28cb2cff64 | ||
|
|
9cb2eb3b8c | ||
|
|
6ff20b98f4 | ||
|
|
aa618dbaa2 | ||
|
|
6e3de84067 | ||
|
|
c6b19200a9 | ||
|
|
91932212e3 | ||
|
|
f81bb92beb | ||
|
|
1d1a242a72 | ||
|
|
ad4af64036 | ||
|
+5 |
2cc7827b0e | ||
|
|
ef6618743d | ||
|
|
3601a9adf1 |
@@ -1,6 +0,0 @@
|
|||||||
---
|
|
||||||
"@calcom/atoms": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
This change fixes an issue in the CalendarSettings atom where the redirect urls were getting only one search param
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"@calcom/atoms": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
This change fixes date overrides breaking for availability settings atom
|
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"next-axiom": "^0.17.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
"next-validations": "^0.2.0",
|
"next-validations": "^0.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.9.0-beta",
|
||||||
"tzdata": "^1.0.30",
|
"tzdata": "^1.0.30",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
|
||||||
"rootDir": ".",
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"@/(.*)": "<rootDir>/src/$1",
|
|
||||||
"test/(.*)": "<rootDir>/test/$1"
|
|
||||||
},
|
|
||||||
"testEnvironment": "node",
|
|
||||||
"testRegex": ".e2e-spec.ts$",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
|
||||||
},
|
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
|
||||||
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
|
||||||
"reporters": ["default", "jest-summarizing-reporter"],
|
|
||||||
"workerIdleMemoryLimit": "512MB",
|
|
||||||
"maxWorkers": 8
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Config } from "jest";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
moduleFileExtensions: ["js", "json", "ts"],
|
||||||
|
rootDir: ".",
|
||||||
|
moduleNameMapper: {
|
||||||
|
"@/(.*)": "<rootDir>/src/$1",
|
||||||
|
"test/(.*)": "<rootDir>/test/$1",
|
||||||
|
},
|
||||||
|
testEnvironment: "node",
|
||||||
|
testRegex: ".e2e-spec.ts$",
|
||||||
|
transform: {
|
||||||
|
"^.+\\.ts$": "ts-jest",
|
||||||
|
},
|
||||||
|
setupFiles: ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||||
|
setupFilesAfterEnv: ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||||
|
reporters: ["default", "jest-summarizing-reporter"],
|
||||||
|
workerIdleMemoryLimit: "512MB",
|
||||||
|
maxWorkers: 8,
|
||||||
|
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||||
|
transformIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
|
||||||
"rootDir": ".",
|
|
||||||
"moduleNameMapper": {
|
|
||||||
"@/(.*)": "<rootDir>/src/$1",
|
|
||||||
"test/(.*)": "<rootDir>/test/$1"
|
|
||||||
},
|
|
||||||
"testEnvironment": "node",
|
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
|
||||||
"transform": {
|
|
||||||
"^.+\\.ts$": "ts-jest"
|
|
||||||
},
|
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Config } from "jest";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
moduleFileExtensions: ["ts", "js", "json"],
|
||||||
|
rootDir: ".",
|
||||||
|
moduleNameMapper: {
|
||||||
|
"@/(.*)": "<rootDir>/src/$1",
|
||||||
|
"test/(.*)": "<rootDir>/test/$1",
|
||||||
|
},
|
||||||
|
testEnvironment: "node",
|
||||||
|
testRegex: ".*\\.spec\\.ts$",
|
||||||
|
transform: {
|
||||||
|
"^.+\\.ts$": "ts-jest",
|
||||||
|
},
|
||||||
|
setupFiles: ["<rootDir>/test/setEnvVars.ts"],
|
||||||
|
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||||
|
transformIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
"test:watch": "yarn dev:build && jest --watch",
|
"test:watch": "yarn dev:build && jest --watch",
|
||||||
"test:cov": "yarn dev:build && jest --coverage",
|
"test:cov": "yarn dev:build && jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
|
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.ts",
|
||||||
"test:e2e:local": "yarn test:e2e --maxWorkers=4",
|
"test:e2e:local": "yarn test:e2e --maxWorkers=4",
|
||||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
|
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
|
||||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
||||||
@@ -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.271",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "^4.1.0",
|
"tsconfig-paths": "^4.1.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.9.0-beta"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"schema": "../../../packages/prisma/schema.prisma"
|
"schema": "../../../packages/prisma/schema.prisma"
|
||||||
|
|||||||
@@ -1834,6 +1834,88 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return who rescheduled the booking, in the new booking", async () => {
|
||||||
|
const rescheduledByEmail = `user-bookings-rescheduler-${randomString()}@rescheduler.com`;
|
||||||
|
// Create the original booking that will be rescheduled
|
||||||
|
const originalBooking = await bookingsRepositoryFixture.create({
|
||||||
|
uid: `original-booking-uid-${eventTypeId}`,
|
||||||
|
title: "original booking title",
|
||||||
|
startTime: "2050-09-05T10:00:00.000Z",
|
||||||
|
endTime: "2050-09-05T11:00:00.000Z",
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: "CANCELLED",
|
||||||
|
rescheduledBy: rescheduledByEmail,
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "original tester",
|
||||||
|
email: "original@example.com",
|
||||||
|
guests: [],
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the new booking that is the result of the reschedule
|
||||||
|
const newBooking = await bookingsRepositoryFixture.create({
|
||||||
|
uid: `new-booking-uid-${eventTypeId}`,
|
||||||
|
title: "rescheduled booking title",
|
||||||
|
startTime: "2050-09-05T14:00:00.000Z",
|
||||||
|
endTime: "2050-09-05T15:00:00.000Z",
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: eventTypeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: "ACCEPTED",
|
||||||
|
fromReschedule: originalBooking.uid,
|
||||||
|
metadata: {},
|
||||||
|
responses: {
|
||||||
|
name: "new tester",
|
||||||
|
email: "newtester@example.com",
|
||||||
|
guests: [],
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/bookings/${newBooking.uid}`)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(200)
|
||||||
|
.then(async (response) => {
|
||||||
|
// Fetch the original booking to get its rescheduledBy value
|
||||||
|
const originalBookingFromDb = await bookingsRepositoryFixture.getByUid(originalBooking.uid);
|
||||||
|
const expectedRescheduledBy = originalBookingFromDb?.rescheduledBy;
|
||||||
|
|
||||||
|
await bookingsRepositoryFixture.deleteById(originalBooking.id);
|
||||||
|
await bookingsRepositoryFixture.deleteById(newBooking.id);
|
||||||
|
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.uid).toEqual(newBooking.uid);
|
||||||
|
expect(rescheduledByEmail).toEqual(expectedRescheduledBy);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("book by username and event type slug", () => {
|
describe("book by username and event type slug", () => {
|
||||||
|
|||||||
@@ -101,10 +101,14 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
);
|
);
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
const rescheduledToUid = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
|
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
uid: databaseBooking.uid,
|
uid: databaseBooking.uid,
|
||||||
@@ -116,7 +120,6 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
@@ -142,6 +145,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
rating: databaseBooking.rating,
|
rating: databaseBooking.rating,
|
||||||
icsUid: databaseBooking.iCalUID,
|
icsUid: databaseBooking.iCalUID,
|
||||||
rescheduledToUid,
|
rescheduledToUid,
|
||||||
|
rescheduledByEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
@@ -151,11 +155,15 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
return bookingTransformed;
|
return bookingTransformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRescheduledToUid(bookingUid: string) {
|
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 rescheduledTo?.uid;
|
return {
|
||||||
|
uid: rescheduledTo?.uid,
|
||||||
|
rescheduledBy: rescheduledTo?.rescheduledBy
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||||
if (databaseMetadata === null) return {};
|
if (databaseMetadata === null) return {};
|
||||||
|
|
||||||
@@ -218,7 +226,6 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
@@ -269,10 +276,13 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
const rescheduledToUid = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
|
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
uid: databaseBooking.uid,
|
uid: databaseBooking.uid,
|
||||||
@@ -299,6 +309,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
rating: databaseBooking.rating,
|
rating: databaseBooking.rating,
|
||||||
icsUid: databaseBooking.iCalUID,
|
icsUid: databaseBooking.iCalUID,
|
||||||
rescheduledToUid,
|
rescheduledToUid,
|
||||||
|
rescheduledByEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||||
@@ -7,6 +8,7 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
|
|||||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||||
|
import { 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 { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
@@ -22,7 +24,9 @@ import { Module } from "@nestjs/common";
|
|||||||
PrismaEventTypeRepository,
|
PrismaEventTypeRepository,
|
||||||
PrismaRoutingFormResponseRepository,
|
PrismaRoutingFormResponseRepository,
|
||||||
PrismaTeamRepository,
|
PrismaTeamRepository,
|
||||||
|
PrismaFeaturesRepository,
|
||||||
CheckBookingLimitsService,
|
CheckBookingLimitsService,
|
||||||
|
CacheService,
|
||||||
AvailableSlotsService,
|
AvailableSlotsService,
|
||||||
],
|
],
|
||||||
exports: [AvailableSlotsService],
|
exports: [AvailableSlotsService],
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PrismaFeaturesRepository as PrismaFeaturesRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaFeaturesRepository extends PrismaFeaturesRepositoryLib {
|
||||||
|
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||||
|
super(dbWrite.prisma as unknown as PrismaClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||||
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
||||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
|
import { 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 { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
@@ -21,7 +23,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
bookingRepository: PrismaBookingRepository,
|
bookingRepository: PrismaBookingRepository,
|
||||||
selectedSlotRepository: PrismaSelectedSlotRepository,
|
selectedSlotRepository: PrismaSelectedSlotRepository,
|
||||||
eventTypeRepository: PrismaEventTypeRepository,
|
eventTypeRepository: PrismaEventTypeRepository,
|
||||||
userRepository: PrismaUserRepository
|
userRepository: PrismaUserRepository,
|
||||||
|
featuresRepository: PrismaFeaturesRepository
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
oooRepo: oooRepoDependency,
|
oooRepo: oooRepoDependency,
|
||||||
@@ -32,7 +35,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
selectedSlotRepo: selectedSlotRepository,
|
selectedSlotRepo: selectedSlotRepository,
|
||||||
eventTypeRepo: eventTypeRepository,
|
eventTypeRepo: eventTypeRepository,
|
||||||
userRepo: userRepository,
|
userRepo: userRepository,
|
||||||
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
|
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
|
||||||
|
cacheService: new CacheService(featuresRepository),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CacheService as BaseCacheService } from "@calcom/platform-libraries";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CacheService extends BaseCacheService {
|
||||||
|
constructor(featuresRepository: PrismaFeaturesRepository) {
|
||||||
|
super({ featuresRepository });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,6 @@
|
|||||||
"eslint-config-next": "15.1.6",
|
"eslint-config-next": "15.1.6",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5.9.0-beta"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cookies, headers } from "next/headers";
|
|||||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||||
@@ -17,7 +18,7 @@ import type { PageProps as LegacyPageProps } from "~/team/type-view";
|
|||||||
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
|
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
|
||||||
|
|
||||||
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
|
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||||
"team-booking-page-cache"
|
"team-booking-page-cache"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function getEnrichedEventType({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
|
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
|
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
|
||||||
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
|
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { notFound } from "next/navigation";
|
|||||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
import Shell from "@calcom/features/shell/Shell";
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
||||||
|
|
||||||
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||||
|
|
||||||
if (!insightsEnabled) {
|
if (!insightsEnabled) {
|
||||||
|
|||||||
+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} />;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
|||||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||||
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
|
|||||||
|
|
||||||
const getTeamFeatures = unstable_cache(
|
const getTeamFeatures = unstable_cache(
|
||||||
async (teamId: number) => {
|
async (teamId: number) => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
return await featuresRepository.getTeamFeatures(teamId);
|
return await featuresRepository.getTeamFeatures(teamId);
|
||||||
},
|
},
|
||||||
["team-features"],
|
["team-features"],
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permiss
|
|||||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ const getCachedTeamRoles = unstable_cache(
|
|||||||
|
|
||||||
const getCachedTeamFeature = unstable_cache(
|
const getCachedTeamFeature = unstable_cache(
|
||||||
async (teamId: number, feature: keyof AppFlags) => {
|
async (teamId: number, feature: keyof AppFlags) => {
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -12,6 +12,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
|
|||||||
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ const getCachedTeamRoles = (teamId: number) =>
|
|||||||
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
|
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
|
||||||
unstable_cache(
|
unstable_cache(
|
||||||
async () => {
|
async () => {
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import getIP from "@calcom/lib/getIP";
|
|||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
async function ensureSignupIsEnabled(body: Record<string, string>) {
|
async function ensureSignupIsEnabled(body: Record<string, string>) {
|
||||||
@@ -22,7 +23,7 @@ async function ensureSignupIsEnabled(body: Record<string, string>) {
|
|||||||
// Still allow signups if there is a team invite
|
// Still allow signups if there is a team invite
|
||||||
if (token) return;
|
if (token) return;
|
||||||
|
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
|
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ async function getHandler(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a new response with the image buffer
|
// Create a new response with the image buffer
|
||||||
const imageResponse = new NextResponse(buffer);
|
const imageResponse = new NextResponse(buffer as BodyInit);
|
||||||
|
|
||||||
// Set the appropriate headers
|
// Set the appropriate headers
|
||||||
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
|
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
|
||||||
|
|||||||
@@ -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"
|
||||||
: ""
|
: ""
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||||||
// Check if team has API v2 feature flag enabled (same logic as team pages)
|
// Check if team has API v2 feature flag enabled (same logic as team pages)
|
||||||
let useApiV2 = false;
|
let useApiV2 = false;
|
||||||
if (isTeamEvent && hashedLink.eventType.team?.id) {
|
if (isTeamEvent && hashedLink.eventType.team?.id) {
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
|
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
|
||||||
hashedLink.eventType.team.id,
|
hashedLink.eventType.team.id,
|
||||||
"use-api-v2-for-team-slots"
|
"use-api-v2-for-team-slots"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { GetServerSidePropsResult } from "next";
|
import type { GetServerSidePropsResult } from "next";
|
||||||
|
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
|
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||||
// Check if organizations are enabled
|
// Check if organizations are enabled
|
||||||
if (!organizations) {
|
if (!organizations) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const querySchema = z.object({
|
|||||||
|
|
||||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||||
"email-verification"
|
"email-verification"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
const organizationSettings = getOrganizationSEOSettings(team);
|
const organizationSettings = getOrganizationSEOSettings(team);
|
||||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||||
|
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||||
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
|
|
||||||
// Provided by Rewrite from next.config.js
|
// Provided by Rewrite from next.config.js
|
||||||
const isOrgProfile = context.query?.isOrgProfile === "1";
|
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||||
|
|
||||||
log.debug("getServerSideProps", {
|
log.debug("getServerSideProps", {
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -247,7 +247,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
const videoReference = getCalVideoReference(bookingObj.references);
|
const videoReference = getCalVideoReference(bookingObj.references);
|
||||||
|
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const displayLogInOverlay = profile?.organizationId
|
const displayLogInOverlay = profile?.organizationId
|
||||||
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
|
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
|
||||||
: false;
|
: false;
|
||||||
|
|||||||
@@ -1229,8 +1229,21 @@ function RecurringBookings({
|
|||||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||||
<br />
|
<br />
|
||||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
|
{formatToLocalizedTime({
|
||||||
{formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
date: dayjs(dateStr),
|
||||||
|
locale: language,
|
||||||
|
timeStyle: undefined,
|
||||||
|
hour12: !is24h,
|
||||||
|
timeZone: tz,
|
||||||
|
})}{" "}
|
||||||
|
-{" "}
|
||||||
|
{formatToLocalizedTime({
|
||||||
|
date: dayjs(dateStr).add(duration, "m"),
|
||||||
|
locale: language,
|
||||||
|
timeStyle: undefined,
|
||||||
|
hour12: !is24h,
|
||||||
|
timeZone: tz,
|
||||||
|
})}{" "}
|
||||||
<span className="text-bookinglight">
|
<span className="text-bookinglight">
|
||||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||||
</span>
|
</span>
|
||||||
@@ -1249,14 +1262,19 @@ function RecurringBookings({
|
|||||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||||
<br />
|
<br />
|
||||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
|
{formatToLocalizedTime({
|
||||||
{formatToLocalizedTime(
|
date: dayjs(dateStr),
|
||||||
dayjs(dateStr).add(duration, "m"),
|
locale: language,
|
||||||
language,
|
hour12: !is24h,
|
||||||
undefined,
|
timeZone: tz,
|
||||||
!is24h,
|
})}{" "}
|
||||||
tz
|
-{" "}
|
||||||
)}{" "}
|
{formatToLocalizedTime({
|
||||||
|
date: dayjs(dateStr).add(duration, "m"),
|
||||||
|
locale: language,
|
||||||
|
hour12: !is24h,
|
||||||
|
timeZone: tz,
|
||||||
|
})}{" "}
|
||||||
<span className="text-bookinglight">
|
<span className="text-bookinglight">
|
||||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||||
</span>
|
</span>
|
||||||
@@ -1273,8 +1291,13 @@ function RecurringBookings({
|
|||||||
<div className={classNames(isCancelled ? "line-through" : "")}>
|
<div className={classNames(isCancelled ? "line-through" : "")}>
|
||||||
{formatToLocalizedDate(date, language, "full", tz)}
|
{formatToLocalizedDate(date, language, "full", tz)}
|
||||||
<br />
|
<br />
|
||||||
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "}
|
{formatToLocalizedTime({ date, locale: language, hour12: !is24h, timeZone: tz })} -{" "}
|
||||||
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
{formatToLocalizedTime({
|
||||||
|
date: dayjs(date).add(duration, "m"),
|
||||||
|
locale: language,
|
||||||
|
hour12: !is24h,
|
||||||
|
timeZone: tz,
|
||||||
|
})}{" "}
|
||||||
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language, tz)})</span>
|
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language, tz)})</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
|||||||
|
|
||||||
const endTime = new Date(booking.endTime);
|
const endTime = new Date(booking.endTime);
|
||||||
const startTime = new Date(booking.startTime);
|
const startTime = new Date(booking.startTime);
|
||||||
|
const timeZone = booking.user?.timeZone;
|
||||||
|
|
||||||
useDailyEvent("left-meeting", () => {
|
useDailyEvent("left-meeting", () => {
|
||||||
if (rediectAttendeeToOnExit) {
|
if (rediectAttendeeToOnExit) {
|
||||||
@@ -306,11 +307,11 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
|||||||
<h3>{t("what")}:</h3>
|
<h3>{t("what")}:</h3>
|
||||||
<p>{booking.title}</p>
|
<p>{booking.title}</p>
|
||||||
<h3>{t("invitee_timezone")}:</h3>
|
<h3>{t("invitee_timezone")}:</h3>
|
||||||
<p>{booking.user?.timeZone}</p>
|
<p>{timeZone}</p>
|
||||||
<h3>{t("when")}:</h3>
|
<h3>{t("when")}:</h3>
|
||||||
<p suppressHydrationWarning={true}>
|
<p suppressHydrationWarning={true}>
|
||||||
{formatToLocalizedDate(startTime)} <br />
|
{formatToLocalizedDate(startTime)} <br />
|
||||||
{formatToLocalizedTime(startTime)}
|
{formatToLocalizedTime({ date: startTime, timeZone })}
|
||||||
</p>
|
</p>
|
||||||
<h3>{t("time_left")}</h3>
|
<h3>{t("time_left")}</h3>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.5.9",
|
"version": "5.5.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -76,7 +76,6 @@
|
|||||||
"@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.21.0",
|
||||||
@@ -195,7 +194,7 @@
|
|||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"tailwindcss-animate": "^1.0.6",
|
"tailwindcss-animate": "^1.0.6",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.9.0-beta"
|
||||||
},
|
},
|
||||||
"nextBundleAnalysis": {
|
"nextBundleAnalysis": {
|
||||||
"budget": 358400,
|
"budget": 358400,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
---
|
||||||
|
title: "How to Add a New Booking Chart to Cal.com Insights Page"
|
||||||
|
---
|
||||||
|
|
||||||
|
This guide walks you through creating a new booking chart component for the insights page, covering the entire stack from UI component to backend service.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The insights booking system follows this architecture:
|
||||||
|
|
||||||
|
```
|
||||||
|
UI Component → tRPC Handler → Insights Service → Database Query → Response
|
||||||
|
```
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Create the UI Component">
|
||||||
|
|
||||||
|
Create your chart component in `packages/features/insights/components/booking/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/features/insights/components/booking/MyNewChart.tsx
|
||||||
|
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer } from "recharts";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc";
|
||||||
|
|
||||||
|
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||||
|
import { ChartCard } from "../ChartCard";
|
||||||
|
import { LoadingInsight } from "../LoadingInsights";
|
||||||
|
|
||||||
|
export const MyNewChart = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const insightsBookingParams = useInsightsBookingParameters();
|
||||||
|
|
||||||
|
const { data, isSuccess, isPending } = trpc.viewer.insights.myNewChartData.useQuery(insightsBookingParams, {
|
||||||
|
staleTime: 180000, // 3 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
trpc: { context: { skipBatch: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPending) return <LoadingInsight />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title={t("my_new_chart_title")}>
|
||||||
|
{isSuccess && data?.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Line type="monotone" dataKey="value" stroke="#8884d8" strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<p className="text-gray-500">{t("no_data_yet")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
<Step title="Add Component to Barrel Export">
|
||||||
|
Update the booking components index file:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/features/insights/components/booking/index.ts
|
||||||
|
export { AverageEventDurationChart } from "./AverageEventDurationChart";
|
||||||
|
export { BookingKPICards } from "./BookingKPICards";
|
||||||
|
// ... existing exports
|
||||||
|
export { MyNewChart } from "./MyNewChart"; // Add this line
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
<Step title="Add Component to Insights View">
|
||||||
|
|
||||||
|
Add your component to the main insights page:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/web/modules/insights/insights-view.tsx
|
||||||
|
import {
|
||||||
|
AverageEventDurationChart,
|
||||||
|
BookingKPICards, // ... existing imports
|
||||||
|
MyNewChart, // Add this import
|
||||||
|
} from "@calcom/features/insights/components/booking";
|
||||||
|
|
||||||
|
export default function InsightsPage() {
|
||||||
|
// ... existing code
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Existing components */}
|
||||||
|
<BookingKPICards />
|
||||||
|
<EventTrendsChart />
|
||||||
|
|
||||||
|
{/* Add your new chart */}
|
||||||
|
<MyNewChart />
|
||||||
|
|
||||||
|
{/* Other existing components */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
<Step title="Create tRPC Handler">
|
||||||
|
|
||||||
|
Add the tRPC endpoint in the insights router using the `getInsightsBookingService()` DI container function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/features/insights/server/trpc-router.ts
|
||||||
|
import { bookingRepositoryBaseInputSchema } from "@calcom/features/insights/server/raw-data.schema";
|
||||||
|
import { userBelongsToTeamProcedure } from "@calcom/trpc/server/procedures/authedProcedure";
|
||||||
|
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
export const insightsRouter = router({
|
||||||
|
// ... existing procedures
|
||||||
|
|
||||||
|
myNewChartData: userBelongsToTeamProcedure
|
||||||
|
.input(bookingRepositoryBaseInputSchema)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// `createInsightsBookingService` is defined at the root level in this file
|
||||||
|
const insightsBookingService = createInsightsBookingService(ctx, input);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await insightsBookingService.getMyNewChartData();
|
||||||
|
} catch (e) {
|
||||||
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
<Step title="Add Service Method to InsightsBookingBaseService">
|
||||||
|
Add your new method to the `InsightsBookingBaseService` class:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/lib/server/service/InsightsBookingBaseService.ts
|
||||||
|
export class InsightsBookingBaseService {
|
||||||
|
// ... existing methods
|
||||||
|
|
||||||
|
async getMyNewChartData() {
|
||||||
|
const baseConditions = await this.getBaseConditions();
|
||||||
|
|
||||||
|
// Example: Get booking counts by day using raw SQL for performance
|
||||||
|
const data = await this.prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
date: Date;
|
||||||
|
bookingsCount: number;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
DATE("createdAt") as date,
|
||||||
|
COUNT(*)::int as "bookingsCount"
|
||||||
|
FROM "BookingTimeStatusDenormalized"
|
||||||
|
WHERE ${baseConditions}
|
||||||
|
GROUP BY DATE("createdAt")
|
||||||
|
ORDER BY date ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Transform the data for the chart
|
||||||
|
return data.map((item) => ({
|
||||||
|
date: item.date.toISOString().split("T")[0], // Format as YYYY-MM-DD
|
||||||
|
value: item.bookingsCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use `getInsightsBookingService()`**: Always use the DI container function for consistent service creation
|
||||||
|
2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance
|
||||||
|
3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions
|
||||||
|
4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError`
|
||||||
|
5. **Loading States**: Always show loading indicators with `LoadingInsight`
|
||||||
|
6. **Consistent Styling**: Use `recharts` for new charts.
|
||||||
|
7. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts
|
||||||
@@ -109,6 +109,11 @@
|
|||||||
"group": "Embeds",
|
"group": "Embeds",
|
||||||
"icon": "code-simple",
|
"icon": "code-simple",
|
||||||
"pages": ["developing/guides/embeds/embed-events"]
|
"pages": ["developing/guides/embeds/embed-events"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "Insights",
|
||||||
|
"icon": "chart-bar",
|
||||||
|
"pages": ["developing/guides/insights/add-new-booking-charts"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ Below is a list of props that can be passed to the booker atom.
|
|||||||
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
|
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
|
||||||
| month | No | The exact month for displaying a user's availability; defaults to the current month |
|
| month | No | The exact month for displaying a user's availability; defaults to the current month |
|
||||||
| selectedDate | No | Default selected date for which the slot picker opens |
|
| selectedDate | No | Default selected date for which the slot picker opens |
|
||||||
|
| startTime | No | Custom start time for the Booker that allows users to decide the first available date. Accepts JavaScript Date object or date string in format `YYYY-MM-DD` (e.g., `"2025-08-20"` or `new Date("2025-08-20")`) |
|
||||||
| hideBranding | No | For hiding any branding on the booker |
|
| hideBranding | No | For hiding any branding on the booker |
|
||||||
| isAway | No | Sets the booker component to the away state |
|
| isAway | No | Sets the booker component to the away state |
|
||||||
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
|
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@
|
|||||||
"prismock": "^1.33.4",
|
"prismock": "^1.33.4",
|
||||||
"resize-observer-polyfill": "^1.5.1",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"tsc-absolute": "^1.0.0",
|
"tsc-absolute": "^1.0.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "5.9.0-beta",
|
||||||
"vitest": "^2.1.1",
|
"vitest": "^2.1.1",
|
||||||
"vitest-fetch-mock": "^0.3.0",
|
"vitest-fetch-mock": "^0.3.0",
|
||||||
"vitest-mock-extended": "^2.0.2"
|
"vitest-mock-extended": "^2.0.2"
|
||||||
|
|||||||
@@ -31,6 +31,6 @@
|
|||||||
"eslint-plugin-react": "^7.30.1",
|
"eslint-plugin-react": "^7.30.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.9.0-beta"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "^13.2.1",
|
"eslint-config-next": "^15.4.5",
|
||||||
"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",
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||||
"tailwind-scrollbar": "^2.0.1",
|
"tailwind-scrollbar": "^2.0.1",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.9.0-beta"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getErrorFromUnknown } from "@calcom/lib/errors";
|
|||||||
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
||||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||||
import { setTestEmail } from "@calcom/lib/testEmails";
|
import { setTestEmail } from "@calcom/lib/testEmails";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { sanitizeDisplayName } from "../lib/sanitizeDisplayName";
|
import { sanitizeDisplayName } from "../lib/sanitizeDisplayName";
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export default class BaseEmail {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
public async sendEmail() {
|
public async sendEmail() {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const emailsDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("emails");
|
const emailsDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("emails");
|
||||||
/** If email kill switch exists and is active, we prevent emails being sent. */
|
/** If email kill switch exists and is active, we prevent emails being sent. */
|
||||||
if (emailsDisabled) {
|
if (emailsDisabled) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"postcss": "^8.4.18",
|
"postcss": "^8.4.18",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.9.0-beta",
|
||||||
"vite": "^4.1.2",
|
"vite": "^4.1.2",
|
||||||
"vite-plugin-environment": "^1.1.3"
|
"vite-plugin-environment": "^1.1.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"@vitejs/plugin-react": "^2.2.0",
|
"@vitejs/plugin-react": "^2.2.0",
|
||||||
"eslint": "^8.34.0",
|
"eslint": "^8.34.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.9.0-beta",
|
||||||
"vite": "^4.5.2"
|
"vite": "^4.5.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.9.0-beta",
|
||||||
"vite": "^4.1.2"
|
"vite": "^4.1.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"@typescript-eslint/parser": "^5.52.0",
|
"@typescript-eslint/parser": "^5.52.0",
|
||||||
"@typescript-eslint/utils": "^5.52.0",
|
"@typescript-eslint/utils": "^5.52.0",
|
||||||
"ts-node-maintained": "^10.9.5",
|
"ts-node-maintained": "^10.9.5",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.9.0-beta"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/eslint": "^8.4.5"
|
"@types/eslint": "^8.4.5"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const sendEmailVerification = async ({
|
|||||||
}: VerifyEmailType) => {
|
}: VerifyEmailType) => {
|
||||||
const token = randomBytes(32).toString("hex");
|
const token = randomBytes(32).toString("hex");
|
||||||
const translation = await getTranslation(language ?? "en", "common");
|
const translation = await getTranslation(language ?? "en", "common");
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
|
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
|
||||||
|
|
||||||
if (!emailVerification) {
|
if (!emailVerification) {
|
||||||
@@ -127,7 +127,7 @@ interface ChangeOfEmail {
|
|||||||
export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => {
|
export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => {
|
||||||
const token = randomBytes(32).toString("hex");
|
const token = randomBytes(32).toString("hex");
|
||||||
const translation = await getTranslation(language ?? "en", "common");
|
const translation = await getTranslation(language ?? "en", "common");
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
|
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
|
||||||
|
|
||||||
if (!emailVerification) {
|
if (!emailVerification) {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const DatePicker = ({
|
|||||||
moveToNextMonthOnNoAvailability();
|
moveToNextMonthOnNoAvailability();
|
||||||
|
|
||||||
// Determine if this is a compact sidebar view based on layout
|
// Determine if this is a compact sidebar view based on layout
|
||||||
const isCompact = layout !== "month_view";
|
const isCompact = layout !== "month_view" && layout !== "mobile";
|
||||||
|
|
||||||
const periodData: PeriodData = {
|
const periodData: PeriodData = {
|
||||||
...{
|
...{
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import getICalUID from "@calcom/emails/lib/getICalUID";
|
|||||||
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
|
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
|
||||||
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
||||||
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
|
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
|
||||||
import { getShouldServeCache } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
|
|
||||||
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
||||||
import {
|
import {
|
||||||
allowDisablingAttendeeConfirmationEmails,
|
allowDisablingAttendeeConfirmationEmails,
|
||||||
@@ -52,6 +51,7 @@ import {
|
|||||||
getFirstDelegationConferencingCredentialAppLocation,
|
getFirstDelegationConferencingCredentialAppLocation,
|
||||||
} from "@calcom/lib/delegationCredential/server";
|
} from "@calcom/lib/delegationCredential/server";
|
||||||
import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
||||||
|
import { getCacheService } from "@calcom/lib/di/containers/cache";
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||||
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
|
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
|
||||||
@@ -387,6 +387,23 @@ export type BookingHandlerInput = {
|
|||||||
forcedSlug?: string;
|
forcedSlug?: string;
|
||||||
} & PlatformParams;
|
} & PlatformParams;
|
||||||
|
|
||||||
|
function formatAvailabilitySnapshot(data: {
|
||||||
|
dateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
|
||||||
|
oooExcludedDateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
dateRanges: data.dateRanges.map(({ start, end }) => ({
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
})),
|
||||||
|
oooExcludedDateRanges: data.oooExcludedDateRanges.map(({ start, end }) => ({
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handler(
|
async function handler(
|
||||||
input: BookingHandlerInput,
|
input: BookingHandlerInput,
|
||||||
bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema
|
bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema
|
||||||
@@ -539,7 +556,8 @@ async function handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldServeCache = await getShouldServeCache(_shouldServeCache, eventType.team?.id);
|
const cacheService = getCacheService();
|
||||||
|
const shouldServeCache = await cacheService.getShouldServeCache(_shouldServeCache, eventType.team?.id);
|
||||||
|
|
||||||
const isTeamEventType =
|
const isTeamEventType =
|
||||||
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
|
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
|
||||||
@@ -1396,7 +1414,10 @@ async function handler(
|
|||||||
|
|
||||||
logger.info(`Booking created`, {
|
logger.info(`Booking created`, {
|
||||||
bookingUid: booking.uid,
|
bookingUid: booking.uid,
|
||||||
availabilitySnapshot: organizerUserAvailability?.availabilityData,
|
selectedCalendarIds: organizerUser.allSelectedCalendars?.map((c) => c.id) ?? [],
|
||||||
|
availabilitySnapshot: organizerUserAvailability?.availabilityData
|
||||||
|
? formatAvailabilitySnapshot(organizerUserAvailability.availabilityData)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
|||||||
import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server";
|
import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
import type { Calendar } from "@calcom/types/Calendar";
|
import type { Calendar } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { CalendarCacheRepository } from "./calendar-cache.repository";
|
import { CalendarCacheRepository } from "./calendar-cache.repository";
|
||||||
@@ -20,7 +21,7 @@ export class CalendarCache {
|
|||||||
return await CalendarCache.init(calendarForCalendarCache);
|
return await CalendarCache.init(calendarForCalendarCache);
|
||||||
}
|
}
|
||||||
static async init(calendar: Calendar | null): Promise<ICalendarCacheRepository> {
|
static async init(calendar: Calendar | null): Promise<ICalendarCacheRepository> {
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const isCalendarCacheEnabledGlobally = await featureRepo.checkIfFeatureIsEnabledGlobally(
|
const isCalendarCacheEnabledGlobally = await featureRepo.checkIfFeatureIsEnabledGlobally(
|
||||||
"calendar-cache"
|
"calendar-cache"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
|
||||||
|
|
||||||
export async function getShouldServeCache(shouldServeCache?: boolean | undefined, teamId?: number) {
|
export interface ICacheService {
|
||||||
|
featuresRepository: IFeaturesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CacheService {
|
||||||
|
constructor(private readonly dependencies: ICacheService) {}
|
||||||
|
|
||||||
|
async getShouldServeCache(shouldServeCache?: boolean | undefined, teamId?: number) {
|
||||||
if (typeof shouldServeCache === "boolean") return shouldServeCache;
|
if (typeof shouldServeCache === "boolean") return shouldServeCache;
|
||||||
if (!teamId) return undefined;
|
if (!teamId) return undefined;
|
||||||
const featureRepo = new FeaturesRepository();
|
return await this.dependencies.featuresRepository.checkIfTeamHasFeature(teamId, "calendar-cache-serve");
|
||||||
return await featureRepo.checkIfTeamHasFeature(teamId, "calendar-cache-serve");
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,8 +194,10 @@ const Days = ({
|
|||||||
const getPadding = (day: number) => (browsingDate.set("date", day).day() - weekStart + 7) % 7;
|
const getPadding = (day: number) => (browsingDate.set("date", day).day() - weekStart + 7) % 7;
|
||||||
const totalDays = daysInMonth(browsingDate);
|
const totalDays = daysInMonth(browsingDate);
|
||||||
|
|
||||||
|
const showNextMonthDays = isSecondWeekOver && !isCompact;
|
||||||
|
|
||||||
// Only apply end-of-month logic for main monthly view (not compact sidebar)
|
// Only apply end-of-month logic for main monthly view (not compact sidebar)
|
||||||
if (isSecondWeekOver && !isCompact) {
|
if (showNextMonthDays) {
|
||||||
const startDay = 8;
|
const startDay = 8;
|
||||||
const pad = getPadding(startDay);
|
const pad = getPadding(startDay);
|
||||||
days = Array(pad).fill(null);
|
days = Array(pad).fill(null);
|
||||||
@@ -337,7 +339,7 @@ const Days = ({
|
|||||||
active={isActive(day)}
|
active={isActive(day)}
|
||||||
away={away}
|
away={away}
|
||||||
emoji={emoji}
|
emoji={emoji}
|
||||||
showMonthTooltip={isSecondWeekOver && !isCompact}
|
showMonthTooltip={showNextMonthDays && !disabled && day.month() !== browsingDate.month()}
|
||||||
isFirstDayOfNextMonth={isFirstDayOfNextMonth}
|
isFirstDayOfNextMonth={isFirstDayOfNextMonth}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { MembershipRole } from "@calcom/prisma/client";
|
|||||||
|
|
||||||
export const getServerSideProps = async ({ req }: GetServerSidePropsContext) => {
|
export const getServerSideProps = async ({ req }: GetServerSidePropsContext) => {
|
||||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||||
// Check if organizations are enabled
|
// Check if organizations are enabled
|
||||||
if (!organizationsEnabled) {
|
if (!organizationsEnabled) {
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
|||||||
|
|
||||||
const isSendgridEnabled = !!(process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL);
|
const isSendgridEnabled = !!(process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL);
|
||||||
|
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
|
|
||||||
const isWorkflowSmtpEmailsEnabled = teamId
|
const isWorkflowSmtpEmailsEnabled = teamId
|
||||||
? await featureRepo.checkIfTeamHasFeature(teamId, "workflow-smtp-emails")
|
? await featureRepo.checkIfTeamHasFeature(teamId, "workflow-smtp-emails")
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -1103,7 +1103,6 @@ describe("Workflow SMTP Emails Feature Flag", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should use SMTP when team has workflow-smtp-emails feature", async () => {
|
test("should use SMTP when team has workflow-smtp-emails feature", async () => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
|
||||||
vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(true);
|
vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(true);
|
||||||
|
|
||||||
await scheduleEmailReminder({
|
await scheduleEmailReminder({
|
||||||
@@ -1115,7 +1114,6 @@ describe("Workflow SMTP Emails Feature Flag", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should use SMTP when user has workflow-smtp-emails feature", async () => {
|
test("should use SMTP when user has workflow-smtp-emails feature", async () => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
|
||||||
vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(true);
|
vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(true);
|
||||||
|
|
||||||
await scheduleEmailReminder({
|
await scheduleEmailReminder({
|
||||||
@@ -1127,7 +1125,6 @@ describe("Workflow SMTP Emails Feature Flag", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should use SendGrid when workflow-smtp-emails feature is not enabled for team", async () => {
|
test("should use SendGrid when workflow-smtp-emails feature is not enabled for team", async () => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
|
||||||
vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(false);
|
vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(false);
|
||||||
|
|
||||||
await scheduleEmailReminder({
|
await scheduleEmailReminder({
|
||||||
@@ -1140,7 +1137,6 @@ describe("Workflow SMTP Emails Feature Flag", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should use SendGrid when workflow-smtp-emails feature is not enabled for user", async () => {
|
test("should use SendGrid when workflow-smtp-emails feature is not enabled for user", async () => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
|
||||||
vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(false);
|
vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(false);
|
||||||
|
|
||||||
await scheduleEmailReminder({
|
await scheduleEmailReminder({
|
||||||
@@ -1153,9 +1149,9 @@ describe("Workflow SMTP Emails Feature Flag", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should use SMTP when SendGrid is not configured", async () => {
|
test("should use SMTP when SendGrid is not configured", async () => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(false);
|
||||||
vi.spyOn(featuresRepository, "checkIfTeamHasFeature").mockResolvedValue(false);
|
|
||||||
vi.spyOn(featuresRepository, "checkIfUserHasFeature").mockResolvedValue(false);
|
vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(false);
|
||||||
|
|
||||||
delete process.env.SENDGRID_API_KEY;
|
delete process.env.SENDGRID_API_KEY;
|
||||||
delete process.env.SENDGRID_EMAIL;
|
delete process.env.SENDGRID_EMAIL;
|
||||||
|
|||||||
@@ -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 : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { describe, expect, beforeAll, afterAll, beforeEach, it } from "vitest";
|
import { describe, expect, beforeAll, afterAll, beforeEach, it } from "vitest";
|
||||||
|
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import type { AppFlags } from "./config";
|
import type { AppFlags } from "./config";
|
||||||
import { FeaturesRepository } from "./features.repository";
|
import { FeaturesRepository } from "./features.repository";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const featuresRepository = new FeaturesRepository();
|
|
||||||
|
|
||||||
// Access private clearCache method through type assertion
|
// Access private clearCache method through type assertion
|
||||||
const clearCache = () => {
|
const clearCache = () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { captureException } from "@sentry/nextjs";
|
import { captureException } from "@sentry/nextjs";
|
||||||
|
|
||||||
import db from "@calcom/prisma";
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
import type { AppFlags, TeamFeatures } from "./config";
|
import type { AppFlags, TeamFeatures } from "./config";
|
||||||
import type { IFeaturesRepository } from "./features.repository.interface";
|
import type { IFeaturesRepository } from "./features.repository.interface";
|
||||||
@@ -18,6 +18,8 @@ interface CacheOptions {
|
|||||||
export class FeaturesRepository implements IFeaturesRepository {
|
export class FeaturesRepository implements IFeaturesRepository {
|
||||||
private static featuresCache: { data: any[]; expiry: number } | null = null;
|
private static featuresCache: { data: any[]; expiry: number } | null = null;
|
||||||
|
|
||||||
|
constructor(private prismaClient: PrismaClient) {}
|
||||||
|
|
||||||
private clearCache() {
|
private clearCache() {
|
||||||
FeaturesRepository.featuresCache = null;
|
FeaturesRepository.featuresCache = null;
|
||||||
}
|
}
|
||||||
@@ -32,7 +34,7 @@ export class FeaturesRepository implements IFeaturesRepository {
|
|||||||
return FeaturesRepository.featuresCache.data;
|
return FeaturesRepository.featuresCache.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const features = await db.feature.findMany({
|
const features = await this.prismaClient.feature.findMany({
|
||||||
orderBy: { slug: "asc" },
|
orderBy: { slug: "asc" },
|
||||||
cacheStrategy: { swr: 300, ttl: 300 },
|
cacheStrategy: { swr: 300, ttl: 300 },
|
||||||
});
|
});
|
||||||
@@ -64,7 +66,7 @@ export class FeaturesRepository implements IFeaturesRepository {
|
|||||||
* @returns Promise<{ [slug: string]: boolean } | null>
|
* @returns Promise<{ [slug: string]: boolean } | null>
|
||||||
*/
|
*/
|
||||||
public async getTeamFeatures(teamId: number) {
|
public async getTeamFeatures(teamId: number) {
|
||||||
const result = await db.teamFeatures.findMany({
|
const result = await this.prismaClient.teamFeatures.findMany({
|
||||||
where: {
|
where: {
|
||||||
teamId,
|
teamId,
|
||||||
},
|
},
|
||||||
@@ -122,7 +124,7 @@ export class FeaturesRepository implements IFeaturesRepository {
|
|||||||
* FIXME refactor when upgrading prismock
|
* FIXME refactor when upgrading prismock
|
||||||
* https://github.com/morintd/prismock/issues/592
|
* https://github.com/morintd/prismock/issues/592
|
||||||
*/
|
*/
|
||||||
const userHasFeature = await db.userFeatures.findFirst({
|
const userHasFeature = await this.prismaClient.userFeatures.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
featureId: slug,
|
featureId: slug,
|
||||||
@@ -180,7 +182,7 @@ export class FeaturesRepository implements IFeaturesRepository {
|
|||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await db.$queryRaw<unknown[]>(query);
|
const result = await this.prismaClient.$queryRaw<unknown[]>(query);
|
||||||
return result.length > 0;
|
return result.length > 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
captureException(err);
|
captureException(err);
|
||||||
@@ -199,7 +201,7 @@ export class FeaturesRepository implements IFeaturesRepository {
|
|||||||
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags): Promise<boolean> {
|
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Early return if team has feature directly assigned
|
// Early return if team has feature directly assigned
|
||||||
const teamHasFeature = await db.teamFeatures.findUnique({
|
const teamHasFeature = await this.prismaClient.teamFeatures.findUnique({
|
||||||
where: {
|
where: {
|
||||||
teamId_featureId: {
|
teamId_featureId: {
|
||||||
teamId,
|
teamId,
|
||||||
@@ -238,7 +240,7 @@ export class FeaturesRepository implements IFeaturesRepository {
|
|||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await db.$queryRaw<unknown[]>(query);
|
const result = await this.prismaClient.$queryRaw<unknown[]>(query);
|
||||||
return result.length > 0;
|
return result.length > 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
captureException(err);
|
captureException(err);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { startSpan } from "@sentry/nextjs";
|
import { startSpan } from "@sentry/nextjs";
|
||||||
|
|
||||||
import { FeaturesRepository } from "../features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Cases represent individual operations, like "Create Feature" or "Sign In" or "Toggle Feature".
|
* Use Cases represent individual operations, like "Create Feature" or "Sign In" or "Toggle Feature".
|
||||||
@@ -11,7 +12,7 @@ import { FeaturesRepository } from "../features.repository";
|
|||||||
*/
|
*/
|
||||||
export function checkIfUserHasFeatureUseCase(userId: number, slug: string): Promise<boolean> {
|
export function checkIfUserHasFeatureUseCase(userId: number, slug: string): Promise<boolean> {
|
||||||
return startSpan({ name: "checkIfUserHasFeature UseCase", op: "function" }, async () => {
|
return startSpan({ name: "checkIfUserHasFeature UseCase", op: "function" }, async () => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
|
|
||||||
return await featuresRepository.checkIfUserHasFeature(userId, slug);
|
return await featuresRepository.checkIfUserHasFeature(userId, slug);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
|
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
|
||||||
|
|
||||||
import { FeaturesRepository } from "../../features.repository";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TRPC procedure that returns a map of all feature flags and their enabled status.
|
* TRPC procedure that returns a map of all feature flags and their enabled status.
|
||||||
* Uses the FeaturesRepository to handle caching and database access.
|
* Uses the FeaturesRepository to handle caching and database access.
|
||||||
*/
|
*/
|
||||||
export const map = publicProcedure.query(async () => {
|
export const map = publicProcedure.query(async () => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
return featuresRepository.getFeatureFlagMap();
|
return featuresRepository.getFeatureFlagMap();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { AppFlags } from "@calcom/features/flags/config";
|
import type { AppFlags } from "@calcom/features/flags/config";
|
||||||
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
|
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
|
||||||
import { router } from "@calcom/trpc/server/trpc";
|
import { router } from "@calcom/trpc/server/trpc";
|
||||||
|
|
||||||
import { FeaturesRepository } from "../features.repository";
|
|
||||||
import { map } from "./procedures/map";
|
import { map } from "./procedures/map";
|
||||||
|
|
||||||
export const featureFlagRouter = router({
|
export const featureFlagRouter = router({
|
||||||
list: publicProcedure.query(async () => {
|
list: publicProcedure.query(async () => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
return featuresRepository.getAllFeatures();
|
return featuresRepository.getAllFeatures();
|
||||||
}),
|
}),
|
||||||
checkTeamFeature: publicProcedure
|
checkTeamFeature: publicProcedure
|
||||||
@@ -20,7 +21,7 @@ export const featureFlagRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
return featuresRepository.checkIfTeamHasFeature(input.teamId, input.feature as keyof AppFlags);
|
return featuresRepository.checkIfTeamHasFeature(input.teamId, input.feature as keyof AppFlags);
|
||||||
}),
|
}),
|
||||||
map,
|
map,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Controller, useFormContext } from "react-hook-form";
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { InfoBadge } from "@calcom/ui/components/badge";
|
import { InfoBadge } from "@calcom/ui/components/badge";
|
||||||
import { Label } from "@calcom/ui/components/form";
|
import { Label } from "@calcom/ui/components/form";
|
||||||
@@ -19,6 +20,19 @@ import {
|
|||||||
} from "./useShouldBeDisabledDueToPrefill";
|
} from "./useShouldBeDisabledDueToPrefill";
|
||||||
import { getTranslatedConfig as getTranslatedVariantsConfig } from "./utils/variantsConfig";
|
import { getTranslatedConfig as getTranslatedVariantsConfig } from "./utils/variantsConfig";
|
||||||
|
|
||||||
|
// helper to render markdown label safely
|
||||||
|
const renderLabel = (field: Partial<RhfFormField>) => {
|
||||||
|
if (field.labelAsSafeHtml) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(field.labelAsSafeHtml) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{field.label}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
type RhfForm = {
|
type RhfForm = {
|
||||||
fields: z.infer<typeof fieldsSchema>;
|
fields: z.infer<typeof fieldsSchema>;
|
||||||
};
|
};
|
||||||
@@ -155,7 +169,7 @@ const WithLabel = ({
|
|||||||
field.label && (
|
field.label && (
|
||||||
<div className="mb-2 flex items-center">
|
<div className="mb-2 flex items-center">
|
||||||
<Label className="!mb-0 flex items-center" htmlFor={htmlFor}>
|
<Label className="!mb-0 flex items-center" htmlFor={htmlFor}>
|
||||||
<span>{field.label}</span>
|
{renderLabel(field)}
|
||||||
<span className="text-emphasis -mb-1 ml-1 text-sm font-medium leading-none">
|
<span className="text-emphasis -mb-1 ml-1 text-sm font-medium leading-none">
|
||||||
{!readOnly && field.required ? "*" : ""}
|
{!readOnly && field.required ? "*" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ import type { FieldType } from "./schema";
|
|||||||
* Once a component supports `labelAsSafeHtml`, add it's field's type here
|
* Once a component supports `labelAsSafeHtml`, add it's field's type here
|
||||||
* A whitelist is needed because unless we explicitly use dangerouslySetInnerHTML, React will escape the HTML
|
* A whitelist is needed because unless we explicitly use dangerouslySetInnerHTML, React will escape the HTML
|
||||||
*/
|
*/
|
||||||
export const fieldsThatSupportLabelAsSafeHtml: FieldType[] = ["boolean"];
|
export const fieldsThatSupportLabelAsSafeHtml: FieldType[] = ["boolean", "radio"];
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{typeof title === "string" ? (
|
||||||
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
|
<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%">
|
||||||
|
<BarChart
|
||||||
data={selectedFieldData}
|
data={selectedFieldData}
|
||||||
valueFormatter={(value: number) => value.toString()}
|
layout="vertical"
|
||||||
className="mt-2"
|
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,18 +38,20 @@ 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,
|
|
||||||
isSuccess: isHeadersSuccess,
|
|
||||||
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
isAll,
|
isAll,
|
||||||
routingFormId,
|
routingFormId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
|
const getInsightsFacetedUniqueValues = useInsightsRoutingFacetedUniqueValues({
|
||||||
|
headers,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
isAll,
|
||||||
|
});
|
||||||
|
|
||||||
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
|
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
|
||||||
|
|
||||||
@@ -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")}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
{categories.map((item) => (
|
{categories.map((item) => (
|
||||||
<CardInsights key={item.title}>
|
<div
|
||||||
<div className="animate-pulse">
|
key={item.title}
|
||||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
className={classNames(
|
||||||
<div className="mt-4 flex items-baseline space-x-3">
|
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
|
||||||
<div className="h-8 w-16 rounded bg-gray-200" />
|
)}>
|
||||||
|
<div>
|
||||||
|
<SkeletonText className="mt-1 h-5 w-32" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SkeletonText className="h-6 w-12" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardInsights>
|
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</div>
|
||||||
|
</ChartCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user