Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18059e50fa | ||
|
|
852eb346ed | ||
|
|
b7e11dc112 | ||
|
|
35a373e70a | ||
|
|
02c0b721b9 | ||
|
|
29349845f8 | ||
|
|
28cb2cff64 | ||
|
|
9cb2eb3b8c | ||
|
|
6ff20b98f4 | ||
|
|
aa618dbaa2 | ||
|
|
6e3de84067 | ||
|
|
c6b19200a9 | ||
|
|
91932212e3 | ||
|
|
f81bb92beb | ||
|
|
1d1a242a72 | ||
|
|
ad4af64036 | ||
|
+5 |
2cc7827b0e | ||
|
|
ef6618743d | ||
|
|
3601a9adf1 | ||
|
|
0cc2367556 | ||
|
|
f7b201b0b5 | ||
|
|
82063cc9a1 | ||
|
|
4b2f89d6e8 | ||
|
|
5360cb511d | ||
|
|
704bbf2757 | ||
|
|
7fef683abf | ||
|
|
63df3d9c14 | ||
|
|
32d2fd51e5 | ||
|
|
016a966057 | ||
|
|
dc967cffbe | ||
|
|
4489cb8fde | ||
|
|
babd514c64 | ||
|
|
2e5cc11f29 | ||
|
|
84bc330fa8 | ||
|
|
4cb7e4a91a | ||
|
|
d1bd05a791 | ||
|
|
96a6350143 | ||
|
|
c8bea5b143 | ||
|
|
1cdd8ebc53 | ||
|
|
d78ed21a9f | ||
|
|
4e92b6edeb | ||
|
|
7b2f811bfe | ||
|
|
7b6d5d6de3 | ||
|
|
12b418b532 |
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -38,7 +38,7 @@
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
"next-validations": "^0.2.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.9.0-beta",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"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: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: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: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",
|
||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.268",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
@@ -108,7 +108,7 @@
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "../../../packages/prisma/schema.prisma"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ge
|
||||
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
|
||||
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -386,7 +386,7 @@ export class BookingsController_2024_04_15 {
|
||||
if (bearerToken) {
|
||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
return keyData?.userId;
|
||||
} else {
|
||||
|
||||
@@ -31,12 +31,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return (
|
||||
typeof value.firstName === "string" &&
|
||||
typeof value.lastName === "string" &&
|
||||
value.firstName.trim().length > 0 &&
|
||||
value.lastName.trim().length > 0
|
||||
);
|
||||
return typeof value.firstName === "string" && value.firstName.trim().length > 0;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
|
||||
import { safeParse } from "@/lib/safe-parse/safe-parse";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
@@ -675,7 +675,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
if (bearerToken) {
|
||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
return keyData?.userId;
|
||||
} else {
|
||||
|
||||
@@ -101,9 +101,13 @@ export class OutputBookingsService_2024_08_13 {
|
||||
);
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
const rescheduledToUid = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
||||
const rescheduledToInfo = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -116,7 +120,6 @@ export class OutputBookingsService_2024_08_13 {
|
||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
@@ -142,6 +145,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
rescheduledToUid,
|
||||
rescheduledByEmail,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -151,11 +155,15 @@ export class OutputBookingsService_2024_08_13 {
|
||||
return bookingTransformed;
|
||||
}
|
||||
|
||||
async getRescheduledToUid(bookingUid: string) {
|
||||
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
|
||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||
return rescheduledTo?.uid;
|
||||
return {
|
||||
uid: rescheduledTo?.uid,
|
||||
rescheduledBy: rescheduledTo?.rescheduledBy
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||
if (databaseMetadata === null) return {};
|
||||
|
||||
@@ -218,7 +226,6 @@ export class OutputBookingsService_2024_08_13 {
|
||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
@@ -269,9 +276,12 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
const rescheduledToUid = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
||||
const rescheduledToInfo = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -299,6 +309,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
rescheduledToUid,
|
||||
rescheduledByEmail,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
|
||||
@@ -83,32 +83,31 @@ export class CalendarsService {
|
||||
calendarsToLoad,
|
||||
userId
|
||||
);
|
||||
try {
|
||||
const calendarBusyTimes = await getBusyCalendarTimes(
|
||||
this.buildNonDelegationCredentials(credentials),
|
||||
dateFrom,
|
||||
dateTo,
|
||||
composedSelectedCalendars
|
||||
);
|
||||
const calendarBusyTimesConverted = calendarBusyTimes.map(
|
||||
(busyTime: EventBusyDate & { timeZone?: string }) => {
|
||||
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
|
||||
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
|
||||
const busyTimeStartDate = busyTimeStart.toJSDate();
|
||||
const busyTimeEndDate = busyTimeEnd.toJSDate();
|
||||
return {
|
||||
...busyTime,
|
||||
start: busyTimeStartDate,
|
||||
end: busyTimeEndDate,
|
||||
};
|
||||
}
|
||||
);
|
||||
return calendarBusyTimesConverted;
|
||||
} catch (error) {
|
||||
const calendarBusyTimesQuery = await getBusyCalendarTimes(
|
||||
this.buildNonDelegationCredentials(credentials),
|
||||
dateFrom,
|
||||
dateTo,
|
||||
composedSelectedCalendars
|
||||
);
|
||||
if (!calendarBusyTimesQuery.success) {
|
||||
throw new InternalServerErrorException(
|
||||
"Unable to fetch connected calendars events. Please try again later."
|
||||
);
|
||||
}
|
||||
const calendarBusyTimesConverted = calendarBusyTimesQuery.data.map(
|
||||
(busyTime: EventBusyDate & { timeZone?: string }) => {
|
||||
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
|
||||
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
|
||||
const busyTimeStartDate = busyTimeStart.toJSDate();
|
||||
const busyTimeEndDate = busyTimeEnd.toJSDate();
|
||||
return {
|
||||
...busyTime,
|
||||
start: busyTimeStartDate,
|
||||
end: busyTimeEndDate,
|
||||
};
|
||||
}
|
||||
);
|
||||
return calendarBusyTimesConverted;
|
||||
}
|
||||
|
||||
async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
|
||||
export const sha256Hash = (token: string): string => createHash("sha256").update(token).digest("hex");
|
||||
|
||||
export const isApiKey = (authString: string, prefix: string): boolean =>
|
||||
authString?.startsWith(prefix ?? "cal_");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.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 { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||
@@ -7,6 +8,8 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@@ -21,6 +24,9 @@ import { Module } from "@nestjs/common";
|
||||
PrismaEventTypeRepository,
|
||||
PrismaRoutingFormResponseRepository,
|
||||
PrismaTeamRepository,
|
||||
PrismaFeaturesRepository,
|
||||
CheckBookingLimitsService,
|
||||
CacheService,
|
||||
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,14 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.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 { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||
@@ -20,7 +23,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
selectedSlotRepository: PrismaSelectedSlotRepository,
|
||||
eventTypeRepository: PrismaEventTypeRepository,
|
||||
userRepository: PrismaUserRepository
|
||||
userRepository: PrismaUserRepository,
|
||||
featuresRepository: PrismaFeaturesRepository
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
@@ -31,6 +35,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
selectedSlotRepo: selectedSlotRepository,
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
userRepo: userRepository,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CheckBookingAndDurationLimitsService as BaseCheckBookingAndDurationLimitsService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class CheckBookingAndDurationLimitsService extends BaseCheckBookingAndDurationLimitsService {
|
||||
constructor(checkBookingLimitsService: CheckBookingLimitsService) {
|
||||
super({
|
||||
checkBookingLimitsService: checkBookingLimitsService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CheckBookingLimitsService as BaseCheckBookingLimitsService } from "@calcom/platform-libraries/bookings";
|
||||
|
||||
@Injectable()
|
||||
export class CheckBookingLimitsService extends BaseCheckBookingLimitsService {
|
||||
constructor(bookingRepository: PrismaBookingRepository) {
|
||||
super({
|
||||
bookingRepo: bookingRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ThrottlerException,
|
||||
ThrottlerRequest,
|
||||
ThrottlerModuleOptions,
|
||||
seconds,
|
||||
} from "@nestjs/throttler";
|
||||
import { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
@@ -218,18 +217,18 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
if (authorizationHeader) {
|
||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||
return isApiKey(authorizationHeader, apiKeyPrefix)
|
||||
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
|
||||
: `access_token_${authorizationHeader}`;
|
||||
? `api_key_${sha256Hash(stripApiKey(authorizationHeader, apiKeyPrefix))}`
|
||||
: `access_token_${sha256Hash(authorizationHeader)}`;
|
||||
}
|
||||
|
||||
const oauthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
if (oauthClientId) {
|
||||
return `oauth_client_${oauthClientId}`;
|
||||
return `oauth_client_${sha256Hash(oauthClientId)}`;
|
||||
}
|
||||
|
||||
if (IP) {
|
||||
return `ip_${IP}`;
|
||||
return `ip_${sha256Hash(IP.toString())}`;
|
||||
}
|
||||
|
||||
this.logger.verbose(`no tracker found: ${request.url}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
|
||||
import { sha256Hash, stripApiKey } from "@/lib/api-key";
|
||||
import { AuthMethods } from "@/lib/enums/auth-methods";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input";
|
||||
@@ -61,7 +61,7 @@ export class ApiKeysService {
|
||||
|
||||
async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) {
|
||||
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash);
|
||||
if (!apiKeyInDb) {
|
||||
throw new UnauthorizedException("ApiKeysService - provided api key is not valid.");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { AuthMethods } from "@/lib/enums/auth-methods";
|
||||
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||
@@ -226,7 +226,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
);
|
||||
}
|
||||
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
if (!keyData) {
|
||||
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { getEnv } from "@/env";
|
||||
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
|
||||
import { sha256Hash, stripApiKey } from "@/lib/api-key";
|
||||
import { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output";
|
||||
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
|
||||
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
|
||||
@@ -283,7 +283,7 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
expect(managedOrgApiKeys?.length).toEqual(1);
|
||||
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
|
||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||
const hashedApiKey = `${hashAPIKey(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`;
|
||||
const hashedApiKey = `${sha256Hash(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`;
|
||||
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
|
||||
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 30 }).toJSDate();
|
||||
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
|
||||
@@ -484,7 +484,7 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
expect(managedOrgApiKeys?.length).toEqual(1);
|
||||
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
|
||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||
const hashedApiKey = `${hashAPIKey(stripApiKey(newApiKey, apiKeyPrefix))}`;
|
||||
const hashedApiKey = `${sha256Hash(stripApiKey(newApiKey, apiKeyPrefix))}`;
|
||||
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
|
||||
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 60 }).toJSDate();
|
||||
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"],
|
||||
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
|
||||
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
|
||||
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"]
|
||||
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
|
||||
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
"eslint-config-next": "15.1.6",
|
||||
"postcss": "^8",
|
||||
"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 { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
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";
|
||||
|
||||
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"team-booking-page-cache"
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function getEnrichedEventType({
|
||||
}
|
||||
|
||||
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 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 { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
||||
|
||||
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { ApiKeyRepository } from "@calcom/lib/server/repository/apiKey";
|
||||
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -22,7 +22,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const getCachedApiKeys = unstable_cache(
|
||||
async (userId: number) => {
|
||||
return await ApiKeyRepository.findApiKeysFromUserId({ userId });
|
||||
return await PrismaApiKeyRepository.findApiKeysFromUserId({ userId });
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.apiKeys.list"] } // Cache for 1 hour
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -17,7 +18,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
|
||||
|
||||
const getTeamFeatures = unstable_cache(
|
||||
async (teamId: number) => {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
return await featuresRepository.getTeamFeatures(teamId);
|
||||
},
|
||||
["team-features"],
|
||||
|
||||
+39
-1
@@ -1,7 +1,15 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -14,10 +22,40 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Attributes,
|
||||
userRole: session.user.org.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!canRead) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("attributes")} description={t("attribute_meta_description")}>
|
||||
<OrgSettingsAttributesPage />
|
||||
<OrgSettingsAttributesPage permissions={{ canEdit, canDelete, canCreate }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+26
-1
@@ -1,7 +1,15 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-view";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/organizations/general");
|
||||
}
|
||||
|
||||
const { canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: session.user.org.role,
|
||||
fallbackRoles: {
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("directory_sync")} description={t("directory_sync_description")}>
|
||||
<DirectorySyncTeamView />
|
||||
<DirectorySyncTeamView permissions={{ canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+34
-1
@@ -1,7 +1,15 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import PrivacyView from "@calcom/features/ee/organizations/pages/settings/privacy";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -15,9 +23,34 @@ export const generateMetadata = async () =>
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
const { canRead, canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: session.user.org.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!canRead) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
|
||||
<PrivacyView />
|
||||
<PrivacyView permissions={{ canRead, canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+26
-1
@@ -1,7 +1,15 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/organizations/general");
|
||||
}
|
||||
|
||||
const { canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: session.user.org.role,
|
||||
fallbackRoles: {
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("sso_configuration")} description={t("sso_configuration_description_orgs")}>
|
||||
<OrgSSOView />
|
||||
<OrgSSOView permissions={{ canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+30
-1
@@ -1,7 +1,15 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/general";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -15,9 +23,30 @@ export const generateMetadata = async () =>
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
const { canRead, canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: session.user.org.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
|
||||
<LegacyPage />
|
||||
<LegacyPage permissions={{ canRead, canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+43
-1
@@ -1,7 +1,16 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/profile";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import type { Membership } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -13,14 +22,47 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const t = await getTranslate();
|
||||
|
||||
const orgRole = session?.user.profile?.organization.members?.find(
|
||||
(member: Membership) => member.userId === session?.user.id
|
||||
)?.role;
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !orgRole) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
const { canRead, canEdit, canDelete } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session?.user.profile?.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: orgRole,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("profile")}
|
||||
description={t("profile_org_description")}
|
||||
borderInShellHeader={true}>
|
||||
<LegacyPage />
|
||||
<LegacyPage
|
||||
permissions={{
|
||||
canEdit,
|
||||
canRead,
|
||||
canDelete,
|
||||
}}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+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 { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -29,7 +30,7 @@ const getCachedTeamRoles = unstable_cache(
|
||||
|
||||
const getCachedTeamFeature = unstable_cache(
|
||||
async (teamId: number, feature: keyof AppFlags) => {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
||||
return res;
|
||||
},
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache";
|
||||
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
|
||||
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { TeamMembersView } from "~/teams/team-members-view";
|
||||
@@ -37,7 +37,7 @@ const getCachedTeamAttributes = unstable_cache(
|
||||
async (organizationId?: number) => {
|
||||
if (!organizationId) return [];
|
||||
try {
|
||||
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
|
||||
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
|
||||
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -32,7 +33,7 @@ const getCachedTeamRoles = (teamId: number) =>
|
||||
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
||||
return res;
|
||||
},
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
|
||||
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
@@ -19,7 +19,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const getCachedAttributes = unstable_cache(
|
||||
async (orgId: number) => {
|
||||
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId });
|
||||
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId });
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
|
||||
|
||||
@@ -10,6 +10,7 @@ import getIP from "@calcom/lib/getIP";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
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
|
||||
if (token) return;
|
||||
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
||||
|
||||
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
|
||||
const imageResponse = new NextResponse(buffer);
|
||||
const imageResponse = new NextResponse(buffer as BodyInit);
|
||||
|
||||
// Set the appropriate headers
|
||||
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
|
||||
|
||||
@@ -120,7 +120,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
// Check if team has API v2 feature flag enabled (same logic as team pages)
|
||||
let useApiV2 = false;
|
||||
if (isTeamEvent && hashedLink.eventType.team?.id) {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
|
||||
hashedLink.eventType.team.id,
|
||||
"use-api-v2-for-team-slots"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { GetServerSidePropsResult } from "next";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||
// Check if organizations are enabled
|
||||
if (!organizations) {
|
||||
|
||||
@@ -24,7 +24,7 @@ const querySchema = z.object({
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"email-verification"
|
||||
);
|
||||
|
||||
@@ -127,7 +127,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const organizationSettings = getOrganizationSEOSettings(team);
|
||||
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 useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||
|
||||
log.debug("getServerSideProps", {
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const videoReference = getCalVideoReference(bookingObj.references);
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const displayLogInOverlay = profile?.organizationId
|
||||
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
|
||||
: false;
|
||||
|
||||
@@ -1229,8 +1229,21 @@ function RecurringBookings({
|
||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
||||
{formatToLocalizedTime({
|
||||
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">
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||
</span>
|
||||
@@ -1249,14 +1262,19 @@ function RecurringBookings({
|
||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
|
||||
{formatToLocalizedTime(
|
||||
dayjs(dateStr).add(duration, "m"),
|
||||
language,
|
||||
undefined,
|
||||
!is24h,
|
||||
tz
|
||||
)}{" "}
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(dateStr),
|
||||
locale: language,
|
||||
hour12: !is24h,
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
-{" "}
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(dateStr).add(duration, "m"),
|
||||
locale: language,
|
||||
hour12: !is24h,
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||
</span>
|
||||
@@ -1273,8 +1291,13 @@ function RecurringBookings({
|
||||
<div className={classNames(isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(date, language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
||||
{formatToLocalizedTime({ date, locale: language, hour12: !is24h, timeZone: tz })} -{" "}
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(date).add(duration, "m"),
|
||||
locale: language,
|
||||
hour12: !is24h,
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language, tz)})</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -78,9 +78,11 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
|
||||
teams: teamsToCreateFromStore.length ? teamsToCreateFromStore : [{ name: "" }],
|
||||
moveTeams: teams.map((team) => {
|
||||
const teamToMigrateInStore = teamsToMigrateFromStore.find((t) => t.id === team.id);
|
||||
const slugConflictsWithOrg = team.slug === orgSlug;
|
||||
return {
|
||||
id: team.id,
|
||||
shouldMove: !!teamToMigrateInStore,
|
||||
// The team with conflicting slug must be moved
|
||||
shouldMove: slugConflictsWithOrg || !!teamToMigrateInStore,
|
||||
newSlug: teamToMigrateInStore?.slug || getSuggestedSlug({ teamSlug: team.slug, orgSlug }),
|
||||
name: team.name,
|
||||
};
|
||||
@@ -160,6 +162,9 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
|
||||
</label>
|
||||
<ul className="mb-8 space-y-4">
|
||||
{moveTeams.map((team, index) => {
|
||||
const currentTeam = teams.find((t) => t.id === team.id);
|
||||
// If the team slug conflicts with the org slug, this team must be moved
|
||||
const slugConflictsWithOrg = currentTeam?.slug === orgSlug;
|
||||
return (
|
||||
<li key={team.id}>
|
||||
<Controller
|
||||
@@ -167,9 +172,11 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CheckboxField
|
||||
defaultValue={value}
|
||||
checked={value}
|
||||
checked={value || slugConflictsWithOrg}
|
||||
onChange={onChange}
|
||||
description={teams.find((t) => t.id === team.id)?.name ?? ""}
|
||||
description={currentTeam?.name ?? ""}
|
||||
// Must not allow toggling off if the slug conflicts with the org slug
|
||||
disabled={slugConflictsWithOrg}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -288,6 +288,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
||||
|
||||
const endTime = new Date(booking.endTime);
|
||||
const startTime = new Date(booking.startTime);
|
||||
const timeZone = booking.user?.timeZone;
|
||||
|
||||
useDailyEvent("left-meeting", () => {
|
||||
if (rediectAttendeeToOnExit) {
|
||||
@@ -306,11 +307,11 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
||||
<h3>{t("what")}:</h3>
|
||||
<p>{booking.title}</p>
|
||||
<h3>{t("invitee_timezone")}:</h3>
|
||||
<p>{booking.user?.timeZone}</p>
|
||||
<p>{timeZone}</p>
|
||||
<h3>{t("when")}:</h3>
|
||||
<p suppressHydrationWarning={true}>
|
||||
{formatToLocalizedDate(startTime)} <br />
|
||||
{formatToLocalizedTime(startTime)}
|
||||
{formatToLocalizedTime({ date: startTime, timeZone })}
|
||||
</p>
|
||||
<h3>{t("time_left")}</h3>
|
||||
<ProgressBar
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.5.8",
|
||||
"version": "5.5.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -195,7 +195,7 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
},
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default, config } from "@calcom/app-store/btcpayserver/api/webhook";
|
||||
@@ -0,0 +1,444 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page, Browser, Route, Response } from "@playwright/test";
|
||||
import type { Team, EventType, User } from "@prisma/client";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { CalendarCacheRepository } from "@calcom/features/calendar-cache/calendar-cache.repository";
|
||||
import { getTimeMin, getTimeMax } from "@calcom/features/calendar-cache/lib/datesForCache";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import type { Fixtures } from "./lib/fixtures";
|
||||
import { bookTimeSlot, doOnOrgDomain, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
/**
|
||||
* Booking Race Condition Prevention Test
|
||||
*
|
||||
* This test validates that the calendar caching system and booking logic correctly
|
||||
* prevent double-booking race conditions that occurred in production. It serves as
|
||||
* a regression test to ensure the following mechanisms work properly:
|
||||
*
|
||||
* 1. **Race Condition Prevention**: Ensures concurrent booking requests for the same
|
||||
* time slot don't result in double bookings to the same host.
|
||||
*
|
||||
* 2. **Calendar Cache Functionality**: Validates that the calendar cache system works
|
||||
* correctly with proper cache key generation using expanded date ranges.
|
||||
*
|
||||
* 3. **Round-Robin Distribution**: Verifies that when both bookings succeed, they are
|
||||
* distributed to different hosts as expected in round-robin scheduling.
|
||||
*
|
||||
* 4. **Database Constraint Protection**: Confirms that unique constraints prevent
|
||||
* duplicate bookings when race conditions are detected.
|
||||
*
|
||||
* **Test Setup**:
|
||||
* - Creates a round-robin team with multiple hosts
|
||||
* - Populates calendar cache with deterministic availability data
|
||||
* - Mocks Google Calendar API to simulate real-world scenarios
|
||||
* - Executes concurrent booking requests at the same time slot
|
||||
*
|
||||
* **Expected Outcomes**:
|
||||
* - [200, 409]: One booking succeeds, one fails due to constraint violation (preferred)
|
||||
* - [200, 200]: Both succeed but distributed to different hosts (acceptable)
|
||||
* - [200, 200] with same host: Double booking occurred (test failure - race condition)
|
||||
*
|
||||
* **Production Context**:
|
||||
* The original race condition was caused by:
|
||||
* - Stale calendar cache showing hosts as available
|
||||
* - Microsecond timing differences in Date.valueOf() for idempotency keys
|
||||
* - Both requests selecting the same host due to identical cache state
|
||||
*
|
||||
* This test recreates similar conditions but in a controlled way to verify
|
||||
* the prevention mechanisms work correctly.
|
||||
*/
|
||||
|
||||
test.describe("Booking Race Condition Prevention", () => {
|
||||
test.skip("Prevents double-booking race condition and validates cache functionality", async ({
|
||||
page,
|
||||
users,
|
||||
orgs,
|
||||
browser,
|
||||
}) => {
|
||||
const { org, team, teamEvent, teamMembers } = await setupTeamWithRoundRobin(users, orgs);
|
||||
|
||||
await setupGoogleCalendarCredentials(teamMembers);
|
||||
await createIdenticalBookingHistories(teamMembers, teamEvent.id);
|
||||
|
||||
const { selectedDate, selectedDateISO } = await getDynamicBookingDate(page, org, team, teamEvent);
|
||||
|
||||
const { targetHost, calendarCacheHits } = await setupCalendarCache(teamMembers, selectedDateISO);
|
||||
await enableCalendarCacheFeatures(team.id);
|
||||
|
||||
const { firstResponse, secondResponse } = await performConcurrentBookings(
|
||||
page,
|
||||
browser,
|
||||
org,
|
||||
team,
|
||||
teamEvent,
|
||||
selectedDate
|
||||
);
|
||||
|
||||
const bookingResults = await analyzeBookingResults(teamEvent.id, firstResponse, secondResponse);
|
||||
|
||||
expect(bookingResults.isRaceConditionPrevented).toBe(true);
|
||||
|
||||
console.log("Race condition prevention test results:", {
|
||||
totalBookings: bookingResults.bookings.length,
|
||||
responseStatuses: bookingResults.responseStatuses,
|
||||
sameHostSelected: bookingResults.sameHostSelected,
|
||||
preventionMechanism: bookingResults.preventionMechanism,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function setupTeamWithRoundRobin(users: Fixtures["users"], orgs: Fixtures["orgs"]) {
|
||||
const org = await orgs.create({ name: "TestOrg" });
|
||||
const teamMatesObj = [{ name: "teammate-1" }, { name: "teammate-2" }];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
}
|
||||
);
|
||||
|
||||
const { team } = await owner.getFirstTeamMembership();
|
||||
const teamEvent = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
const teamMemberships = await prisma.membership.findMany({
|
||||
where: { teamId: team.id },
|
||||
select: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMembers = teamMemberships.map((membership) => membership.user);
|
||||
|
||||
return { org, team, teamEvent, teamMembers };
|
||||
}
|
||||
|
||||
async function setupGoogleCalendarCredentials(teamMembers: User[]) {
|
||||
const googleCalendarApp = await prisma.app.findFirst({
|
||||
where: { slug: "google-calendar" },
|
||||
});
|
||||
|
||||
if (!googleCalendarApp) {
|
||||
await prisma.app.create({
|
||||
data: {
|
||||
slug: "google-calendar",
|
||||
dirName: "google-calendar",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of teamMembers) {
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "google_calendar",
|
||||
key: {
|
||||
access_token: "test_access_token",
|
||||
refresh_token: "test_refresh_token",
|
||||
scope: "https://www.googleapis.com/auth/calendar.events",
|
||||
token_type: "Bearer",
|
||||
expiry_date: Date.now() + 3600000,
|
||||
},
|
||||
userId: member.id,
|
||||
appId: "google-calendar",
|
||||
invalid: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createIdenticalBookingHistories(teamMembers: User[], eventTypeId: number) {
|
||||
const identicalTimestamp = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const member of teamMembers) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-booking-${member.id}-${i}-${Date.now()}`,
|
||||
title: `Test booking ${i}`,
|
||||
startTime: new Date(Date.now() - (i + 2) * 24 * 60 * 60 * 1000),
|
||||
endTime: new Date(Date.now() - (i + 2) * 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
|
||||
eventTypeId,
|
||||
userId: member.id,
|
||||
status: "ACCEPTED",
|
||||
createdAt: identicalTimestamp,
|
||||
updatedAt: identicalTimestamp,
|
||||
attendees: {
|
||||
create: {
|
||||
email: `test${i}@example.com`,
|
||||
name: `Test Attendee ${i}`,
|
||||
timeZone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getDynamicBookingDate(
|
||||
page: Page,
|
||||
org: any,
|
||||
team: any,
|
||||
teamEvent: EventType
|
||||
): Promise<{ selectedDate: string; selectedDateISO: string }> {
|
||||
await doOnOrgDomain({ orgSlug: org.slug, page }, async () => {
|
||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
|
||||
await page.waitForSelector('[data-testid="day"]');
|
||||
|
||||
await page.getByTestId("incrementMonth").click();
|
||||
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor();
|
||||
});
|
||||
|
||||
const firstAvailableDayText = await page
|
||||
.locator('[data-testid="day"][data-disabled="false"]')
|
||||
.nth(0)
|
||||
.textContent();
|
||||
|
||||
if (!firstAvailableDayText) {
|
||||
throw new Error("No available day found");
|
||||
}
|
||||
|
||||
const currentDate = new Date();
|
||||
const nextMonth = new Date(
|
||||
currentDate.getFullYear(),
|
||||
currentDate.getMonth() + 1,
|
||||
parseInt(firstAvailableDayText)
|
||||
);
|
||||
const selectedDateISO = nextMonth.toISOString();
|
||||
|
||||
return {
|
||||
selectedDate: firstAvailableDayText,
|
||||
selectedDateISO,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupCalendarCache(teamMembers: User[], selectedDateISO: string) {
|
||||
const cacheTimeRange = {
|
||||
timeMin: getTimeMin(selectedDateISO),
|
||||
timeMax: getTimeMax(selectedDateISO),
|
||||
};
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: { in: teamMembers.map((m) => m.id) },
|
||||
type: "google_calendar",
|
||||
},
|
||||
});
|
||||
|
||||
const calendarCacheRepo = new CalendarCacheRepository(null);
|
||||
const targetHost = teamMembers[0];
|
||||
const calendarCacheHits: string[] = [];
|
||||
|
||||
for (let i = 0; i < credentials.length; i++) {
|
||||
const credential = credentials[i];
|
||||
const member = teamMembers[i];
|
||||
|
||||
const cacheArgs = {
|
||||
timeMin: cacheTimeRange.timeMin,
|
||||
timeMax: cacheTimeRange.timeMax,
|
||||
items: [{ id: member.email! }],
|
||||
};
|
||||
|
||||
const availabilityData = {
|
||||
kind: "calendar#freeBusy",
|
||||
calendars: {
|
||||
[member.email!]: {
|
||||
busy:
|
||||
member.id === targetHost.id
|
||||
? []
|
||||
: [
|
||||
{
|
||||
start: `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`,
|
||||
end: `${selectedDateISO.slice(0, 10)}T08:30:00.000Z`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await calendarCacheRepo.upsertCachedAvailability({
|
||||
credentialId: credential.id,
|
||||
userId: member.id,
|
||||
args: cacheArgs,
|
||||
value: availabilityData,
|
||||
});
|
||||
|
||||
calendarCacheHits.push(`${member.email}-${credential.id}`);
|
||||
}
|
||||
|
||||
return { targetHost, calendarCacheHits };
|
||||
}
|
||||
|
||||
async function enableCalendarCacheFeatures(teamId: number) {
|
||||
await prisma.teamFeatures.createMany({
|
||||
data: [
|
||||
{
|
||||
teamId,
|
||||
featureId: "calendar-cache",
|
||||
assignedAt: new Date(),
|
||||
assignedBy: "race-condition-test",
|
||||
},
|
||||
{
|
||||
teamId,
|
||||
featureId: "calendar-cache-serve",
|
||||
assignedAt: new Date(),
|
||||
assignedBy: "race-condition-test",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function mockGoogleCalendarAPI(page: Page, selectedDateISO: string) {
|
||||
const busyStart = `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`;
|
||||
const busyEnd = `${selectedDateISO.slice(0, 10)}T09:00:00.000Z`;
|
||||
await page.route("**/calendar/v3/freeBusy**", async (route: Route) => {
|
||||
const mockResponse = {
|
||||
kind: "calendar#freeBusy",
|
||||
calendars: {
|
||||
"pro-user@example.com": {
|
||||
busy: [
|
||||
{
|
||||
start: busyStart,
|
||||
end: busyEnd,
|
||||
},
|
||||
],
|
||||
},
|
||||
"teammate-1@example.com": {
|
||||
busy: [
|
||||
{
|
||||
start: busyStart,
|
||||
end: busyEnd,
|
||||
},
|
||||
],
|
||||
},
|
||||
"teammate-2@example.com": {
|
||||
busy: [
|
||||
{
|
||||
start: busyStart,
|
||||
end: busyEnd,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(mockResponse),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type TeamWithMetadata = Team & { metadata: z.infer<typeof teamMetadataSchema> };
|
||||
|
||||
async function performConcurrentBookings(
|
||||
page: Page,
|
||||
browser: Browser,
|
||||
org: TeamWithMetadata,
|
||||
team: TeamWithMetadata,
|
||||
teamEvent: EventType,
|
||||
selectedDate: string
|
||||
) {
|
||||
let firstResponse: Response | undefined;
|
||||
let secondResponse: Response | undefined;
|
||||
|
||||
await doOnOrgDomain({ orgSlug: org.slug, page }, async () => {
|
||||
const context1 = await browser.newContext();
|
||||
const context2 = await browser.newContext();
|
||||
const page1 = await context1.newPage();
|
||||
const page2 = await context2.newPage();
|
||||
|
||||
const currentDate = new Date();
|
||||
const nextMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, parseInt(selectedDate));
|
||||
const selectedDateISO = nextMonth.toISOString();
|
||||
|
||||
await mockGoogleCalendarAPI(page1, selectedDateISO);
|
||||
await mockGoogleCalendarAPI(page2, selectedDateISO);
|
||||
|
||||
await page1.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
await page2.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page1);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page2);
|
||||
|
||||
const responsePromise1 = page1.waitForResponse(
|
||||
(response) => response.url().includes("/api/book/event") && response.request().method() === "POST"
|
||||
);
|
||||
const responsePromise2 = page2.waitForResponse(
|
||||
(response) => response.url().includes("/api/book/event") && response.request().method() === "POST"
|
||||
);
|
||||
|
||||
const bookingPromise1 = bookTimeSlot(page1, {
|
||||
name: "Test User 1",
|
||||
email: "test1@example.com",
|
||||
});
|
||||
const bookingPromise2 = bookTimeSlot(page2, {
|
||||
name: "Test User 2",
|
||||
email: "test2@example.com",
|
||||
});
|
||||
|
||||
const [response1, response2] = await Promise.all([responsePromise1, responsePromise2]);
|
||||
await Promise.allSettled([bookingPromise1, bookingPromise2]);
|
||||
|
||||
firstResponse = response1;
|
||||
secondResponse = response2;
|
||||
|
||||
await context1.close();
|
||||
await context2.close();
|
||||
});
|
||||
|
||||
return { firstResponse, secondResponse };
|
||||
}
|
||||
|
||||
async function analyzeBookingResults(
|
||||
eventTypeId: number,
|
||||
firstResponse?: Response,
|
||||
secondResponse?: Response
|
||||
) {
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: { eventTypeId },
|
||||
include: { attendees: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const responseStatuses = [firstResponse?.status(), secondResponse?.status()].filter(Boolean);
|
||||
|
||||
const recentBookings = bookings.slice(0, 2);
|
||||
const sameHostSelected =
|
||||
recentBookings.length === 2 && recentBookings[0].userId === recentBookings[1].userId;
|
||||
|
||||
let preventionMechanism = "unknown";
|
||||
if (responseStatuses.includes(409)) {
|
||||
preventionMechanism = "database-constraint";
|
||||
} else if (responseStatuses.every((status) => status === 200) && !sameHostSelected) {
|
||||
preventionMechanism = "round-robin-distribution";
|
||||
} else if (sameHostSelected) {
|
||||
preventionMechanism = "race-condition-occurred";
|
||||
}
|
||||
|
||||
const isRaceConditionPrevented = preventionMechanism !== "race-condition-occurred";
|
||||
|
||||
return {
|
||||
bookings,
|
||||
responseStatuses,
|
||||
sameHostSelected,
|
||||
preventionMechanism,
|
||||
isRaceConditionPrevented,
|
||||
};
|
||||
}
|
||||
@@ -871,6 +871,10 @@
|
||||
"create_team": "Create Team",
|
||||
"name": "Name",
|
||||
"nameless_team": "Nameless Team",
|
||||
"oauth_clients": "OAuth Clients",
|
||||
"oauth_clients_description": "Manage OAuth clients for your organization",
|
||||
"create_oauth_client": "Create OAuth Client",
|
||||
"create_oauth_client_description": "Create a new OAuth client for third-party integrations",
|
||||
"oauth_client_deletion_message": "OAuth client deleted successfully",
|
||||
"create_new_team_description": "Create a new team to collaborate with users.",
|
||||
"create_new_team": "Create a new team",
|
||||
@@ -1836,6 +1840,8 @@
|
||||
"edit_event_type": "Edit event type",
|
||||
"only_admin_can_see_members_of_org": "This Organization is private, and only the organization's admin or owner can view its members.",
|
||||
"only_admin_can_manage_sso_org": "Only the organization's admin or owner can manage SSO settings",
|
||||
"only_admin_can_manage_directory_sync": "Only the organization's admin or owner can manage directory sync settings",
|
||||
"only_admin_can_manage_oauth_clients": "Only the organization's admin or owner can manage OAuth clients",
|
||||
"collective_scheduling": "Collective Scheduling",
|
||||
"make_it_easy_to_book": "Make it easy to book your team when everyone is available.",
|
||||
"find_the_best_person": "Find the best person available and cycle through your team.",
|
||||
@@ -3302,6 +3308,11 @@
|
||||
"error_creating_role": "Error creating role",
|
||||
"error_updating_role": "Error updating role",
|
||||
"pbac_desc_create_roles": "Create roles",
|
||||
"pbac_resource_attributes": "Attributes",
|
||||
"pbac_desc_view_organization_attributes": "View organization attributes",
|
||||
"pbac_desc_update_organization_attributes": "Update organization attributes",
|
||||
"pbac_desc_delete_organization_attributes": "Delete organization attributes",
|
||||
"pbac_desc_create_organization_attributes": "Create organization attributes",
|
||||
"pbac_desc_view_roles": "View roles",
|
||||
"pbac_desc_update_roles": "Update roles",
|
||||
"pbac_desc_delete_roles": "Delete roles",
|
||||
|
||||
@@ -3,8 +3,8 @@ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||
import { checkBookingLimits, checkBookingLimit } from "@calcom/lib/intervalLimits/server/checkBookingLimits";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
|
||||
|
||||
type Mockdata = {
|
||||
@@ -21,25 +21,27 @@ const MOCK_DATA: Mockdata = {
|
||||
},
|
||||
};
|
||||
|
||||
const checkBookingLimitsService = getCheckBookingLimitsService();
|
||||
|
||||
describe("Check Booking Limits Tests", () => {
|
||||
it("Should return no errors", async () => {
|
||||
prismaMock.booking.count.mockResolvedValue(0);
|
||||
expect(
|
||||
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||
checkBookingLimitsService.checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("Should throw an error", async () => {
|
||||
// Mock there being two a day
|
||||
prismaMock.booking.count.mockResolvedValue(2);
|
||||
expect(
|
||||
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||
checkBookingLimitsService.checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it("Should pass with multiple booking limits", async () => {
|
||||
prismaMock.booking.count.mockResolvedValue(0);
|
||||
expect(
|
||||
checkBookingLimits(
|
||||
checkBookingLimitsService.checkBookingLimits(
|
||||
{
|
||||
PER_DAY: 1,
|
||||
PER_WEEK: 2,
|
||||
@@ -51,8 +53,9 @@ describe("Check Booking Limits Tests", () => {
|
||||
});
|
||||
it("Should pass with multiple booking limits with one undefined", async () => {
|
||||
prismaMock.booking.count.mockResolvedValue(0);
|
||||
|
||||
expect(
|
||||
checkBookingLimits(
|
||||
checkBookingLimitsService.checkBookingLimits(
|
||||
{
|
||||
PER_DAY: 1,
|
||||
PER_WEEK: undefined,
|
||||
@@ -65,7 +68,7 @@ describe("Check Booking Limits Tests", () => {
|
||||
it("Should handle multiple limits correctly", async () => {
|
||||
prismaMock.booking.count.mockResolvedValue(1);
|
||||
expect(
|
||||
checkBookingLimit({
|
||||
checkBookingLimitsService.checkBookingLimit({
|
||||
key: "PER_DAY",
|
||||
limitingNumber: 2,
|
||||
eventStartDate: MOCK_DATA.startDate,
|
||||
@@ -74,7 +77,7 @@ describe("Check Booking Limits Tests", () => {
|
||||
).resolves.not.toThrow();
|
||||
prismaMock.booking.count.mockResolvedValue(3);
|
||||
expect(
|
||||
checkBookingLimit({
|
||||
checkBookingLimitsService.checkBookingLimit({
|
||||
key: "PER_WEEK",
|
||||
limitingNumber: 2,
|
||||
eventStartDate: MOCK_DATA.startDate,
|
||||
|
||||
@@ -1281,12 +1281,15 @@ describe("getSchedule", () => {
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
|
||||
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
|
||||
{
|
||||
start: `${plus3DateString}T04:00:00.000Z`,
|
||||
end: `${plus3DateString}T05:59:59.000Z`,
|
||||
},
|
||||
]);
|
||||
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
start: `${plus3DateString}T04:00:00.000Z`,
|
||||
end: `${plus3DateString}T05:59:59.000Z`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const scenarioData = {
|
||||
eventTypes: [
|
||||
@@ -1347,12 +1350,15 @@ describe("getSchedule", () => {
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||
|
||||
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
|
||||
{
|
||||
start: `${plus3DateString}T04:00:00.000Z`,
|
||||
end: `${plus3DateString}T05:59:59.000Z`,
|
||||
},
|
||||
]);
|
||||
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
start: `${plus3DateString}T04:00:00.000Z`,
|
||||
end: `${plus3DateString}T05:59:59.000Z`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const scenarioData = {
|
||||
eventTypes: [
|
||||
@@ -1421,7 +1427,7 @@ describe("getSchedule", () => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||
|
||||
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
|
||||
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
|
||||
|
||||
const scenarioData = {
|
||||
eventTypes: [
|
||||
|
||||
@@ -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
|
||||
@@ -110,6 +110,11 @@
|
||||
"icon": "code-simple",
|
||||
"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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| isAway | No | Sets the booker component to the away state |
|
||||
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
|
||||
|
||||
+2
-1
@@ -109,7 +109,7 @@
|
||||
"prismock": "^1.33.4",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "5.9.0-beta",
|
||||
"vitest": "^2.1.1",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
"vitest-mock-extended": "^2.0.2"
|
||||
@@ -120,6 +120,7 @@
|
||||
"@next/third-parties": "^14.2.5",
|
||||
"@vercel/functions": "^1.4.0",
|
||||
"city-timezones": "^1.2.1",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"eslint": "^8.34.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"turbo": "^1.10.1"
|
||||
|
||||
@@ -31,6 +31,6 @@
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const AppSetupPageMap = {
|
||||
zapier: import("../../zapier/pages/setup/_getServerSideProps"),
|
||||
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
|
||||
hitpay: import("../../hitpay/pages/setup/_getServerSideProps"),
|
||||
btcpayserver: import("../../btcpayserver/pages/setup/_getServerSideProps"),
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const AppSetupMap = {
|
||||
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
|
||||
paypal: dynamic(() => import("../../paypal/pages/setup")),
|
||||
hitpay: dynamic(() => import("../../hitpay/pages/setup")),
|
||||
btcpayserver: dynamic(() => import("../../btcpayserver/pages/setup")),
|
||||
};
|
||||
|
||||
export const AppSetupPage = (props: { slug: string }) => {
|
||||
|
||||
@@ -49,7 +49,6 @@ export const getCalendar = async (
|
||||
log.warn(`calendar of type ${calendarType} is not implemented`);
|
||||
return null;
|
||||
}
|
||||
log.info("Got calendarApp", calendarApp.lib.CalendarService);
|
||||
const CalendarService = calendarApp.lib.CalendarService;
|
||||
return new CalendarService(credential);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ export const AppSettingsComponentsMap = {
|
||||
export const EventTypeAddonMap = {
|
||||
alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
|
||||
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
|
||||
btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppCardInterface")),
|
||||
closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")),
|
||||
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
|
||||
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
|
||||
@@ -54,6 +55,7 @@ export const EventTypeAddonMap = {
|
||||
export const EventTypeSettingsMap = {
|
||||
alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")),
|
||||
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")),
|
||||
btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppSettingsInterface")),
|
||||
fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")),
|
||||
ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
|
||||
giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
**/
|
||||
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
|
||||
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appKeysSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
|
||||
import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
|
||||
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appKeysSchema as dub_zod_ts } from "./dub/zod";
|
||||
@@ -54,6 +55,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
export const appKeysSchemas = {
|
||||
alby: alby_zod_ts,
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
btcpayserver: btcpayserver_zod_ts,
|
||||
closecom: closecom_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
dub: dub_zod_ts,
|
||||
|
||||
@@ -10,6 +10,7 @@ import autocheckin_config_json from "./autocheckin/config.json";
|
||||
import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
|
||||
import basecamp3_config_json from "./basecamp3/config.json";
|
||||
import bolna_config_json from "./bolna/config.json";
|
||||
import btcpayserver_config_json from "./btcpayserver/config.json";
|
||||
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
|
||||
import campfire_config_json from "./campfire/config.json";
|
||||
import chatbase_config_json from "./chatbase/config.json";
|
||||
@@ -117,6 +118,7 @@ export const appStoreMetadata = {
|
||||
"baa-for-hipaa": baa_for_hipaa_config_json,
|
||||
basecamp3: basecamp3_config_json,
|
||||
bolna: bolna_config_json,
|
||||
btcpayserver: btcpayserver_config_json,
|
||||
caldavcalendar: caldavcalendar__metadata_ts,
|
||||
campfire: campfire_config_json,
|
||||
chatbase: chatbase_config_json,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
**/
|
||||
import { appDataSchema as alby_zod_ts } from "./alby/zod";
|
||||
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
|
||||
import { appDataSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
|
||||
import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
|
||||
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||
import { appDataSchema as dub_zod_ts } from "./dub/zod";
|
||||
@@ -54,6 +55,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
|
||||
export const appDataSchemas = {
|
||||
alby: alby_zod_ts,
|
||||
basecamp3: basecamp3_zod_ts,
|
||||
btcpayserver: btcpayserver_zod_ts,
|
||||
closecom: closecom_zod_ts,
|
||||
dailyvideo: dailyvideo_zod_ts,
|
||||
dub: dub_zod_ts,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const apiHandlers = {
|
||||
"baa-for-hipaa": import("./baa-for-hipaa/api"),
|
||||
basecamp3: import("./basecamp3/api"),
|
||||
bolna: import("./bolna/api"),
|
||||
btcpayserver: import("./btcpayserver/api"),
|
||||
caldavcalendar: import("./caldavcalendar/api"),
|
||||
campfire: import("./campfire/api"),
|
||||
chatbase: import("./chatbase/api"),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
items:
|
||||
- website.png
|
||||
- integrations.png
|
||||
- checkout.png
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
|
||||
|
||||
import { createDefaultInstallation } from "../../_utils/installation";
|
||||
import appConfig from "../config.json";
|
||||
|
||||
const handler: AppDeclarativeHandler = {
|
||||
appType: appConfig.type,
|
||||
variant: appConfig.variant,
|
||||
slug: appConfig.slug,
|
||||
supportsMultipleInstalls: false,
|
||||
handlerType: "add",
|
||||
redirect: {
|
||||
url: "/apps/btcpayserver/setup",
|
||||
},
|
||||
createCredential: ({ appType, user, slug, teamId }) =>
|
||||
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as webhook, config } from "./webhook";
|
||||
@@ -0,0 +1,100 @@
|
||||
import crypto from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import getRawBody from "raw-body";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { HttpError as HttpCode } from "@calcom/lib/http-error";
|
||||
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
|
||||
import { PrismaBookingPaymentRepository as BookingPaymentRepository } from "@calcom/lib/server/repository/PrismaBookingPaymentRepository";
|
||||
|
||||
import appConfig from "../config.json";
|
||||
import { btcpayCredentialKeysSchema } from "../lib/btcpayCredentialKeysSchema";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
function verifyBTCPaySignature(rawBody: Buffer, expectedSignature: string, webhookSecret: string): string {
|
||||
const hmac = crypto.createHmac("sha256", webhookSecret);
|
||||
hmac.update(rawBody);
|
||||
const computedSignature = hmac.digest("hex");
|
||||
const hexRegex = /^[0-9a-fA-F]+$/;
|
||||
if (!hexRegex.test(computedSignature) || !hexRegex.test(expectedSignature)) {
|
||||
throw new HttpCode({ statusCode: 400, message: "signature mismatch" });
|
||||
}
|
||||
return computedSignature;
|
||||
}
|
||||
|
||||
const btcpayWebhookSchema = z.object({
|
||||
deliveryId: z.string(),
|
||||
webhookId: z.string(),
|
||||
originalDeliveryId: z.string().optional(),
|
||||
isRedelivery: z.boolean(),
|
||||
type: z.string(),
|
||||
timestamp: z.number(),
|
||||
storeId: z.string(),
|
||||
invoiceId: z.string(),
|
||||
metadata: z.object({}).optional(),
|
||||
manuallyMarked: z.boolean().optional(),
|
||||
overPaid: z.boolean(),
|
||||
});
|
||||
const SUPPORTED_INVOICE_EVENTS = ["InvoiceSettled", "InvoiceProcessing"];
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== "POST") throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
const rawBody = await getRawBody(req);
|
||||
const bodyAsString = rawBody.toString();
|
||||
|
||||
const signature = req.headers["btcpay-sig"] || req.headers["BTCPay-Sig"];
|
||||
if (!signature || typeof signature !== "string" || !signature.startsWith("sha256="))
|
||||
throw new HttpCode({ statusCode: 401, message: "Missing or invalid signature format" });
|
||||
|
||||
const webhookData = btcpayWebhookSchema.safeParse(JSON.parse(bodyAsString));
|
||||
if (!webhookData.success) return res.status(400).json({ message: "Invalid webhook payload" });
|
||||
|
||||
const data = webhookData.data;
|
||||
if (!SUPPORTED_INVOICE_EVENTS.includes(data.type))
|
||||
return res.status(200).send({ message: "Webhook received but ignored" });
|
||||
|
||||
const bookingPaymentRepository = new BookingPaymentRepository();
|
||||
const payment = await bookingPaymentRepository.findByExternalIdIncludeBookingUserCredentials(
|
||||
data.invoiceId,
|
||||
appConfig.type
|
||||
);
|
||||
if (!payment) throw new HttpCode({ statusCode: 404, message: "Cal.com: payment not found" });
|
||||
if (payment.success) return res.status(200).send({ message: "Payment already registered" });
|
||||
const key = payment.booking?.user?.credentials?.[0].key;
|
||||
if (!key) throw new HttpCode({ statusCode: 404, message: "Cal.com: credentials not found" });
|
||||
|
||||
const parsedKey = btcpayCredentialKeysSchema.safeParse(key);
|
||||
if (!parsedKey.success)
|
||||
throw new HttpCode({ statusCode: 400, message: "Cal.com: Invalid BTCPay credentials" });
|
||||
|
||||
const { webhookSecret, storeId } = parsedKey.data;
|
||||
if (storeId !== data.storeId)
|
||||
throw new HttpCode({ statusCode: 400, message: "Cal.com: Store ID mismatch" });
|
||||
|
||||
const expectedSignature = signature.split("=")[1];
|
||||
const computedSignature = verifyBTCPaySignature(rawBody, expectedSignature, webhookSecret);
|
||||
|
||||
if (computedSignature.length !== expectedSignature.length) {
|
||||
throw new HttpCode({ statusCode: 400, message: "signature mismatch" });
|
||||
}
|
||||
const isValid = crypto.timingSafeEqual(
|
||||
Buffer.from(computedSignature, "hex"),
|
||||
Buffer.from(expectedSignature, "hex")
|
||||
);
|
||||
if (!isValid) throw new HttpCode({ statusCode: 400, message: "signature mismatch" });
|
||||
|
||||
await handlePaymentSuccess(payment.id, payment.bookingId);
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
const statusCode = err instanceof HttpCode ? err.statusCode : 500;
|
||||
return res.status(statusCode).send({
|
||||
message: err.message,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Spinner } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface IPaymentComponentProps {
|
||||
payment: {
|
||||
// Will be parsed on render
|
||||
data: unknown;
|
||||
};
|
||||
paymentPageProps: PaymentPageProps;
|
||||
}
|
||||
|
||||
// Create zod schema for data
|
||||
const PaymentBTCPayDataSchema = z.object({
|
||||
invoice: z.object({ checkoutLink: z.string() }).required(),
|
||||
});
|
||||
|
||||
export const BtcpayPaymentComponent = (props: IPaymentComponentProps) => {
|
||||
const { payment } = props;
|
||||
const { data } = payment;
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const wrongUrl = (
|
||||
<>
|
||||
<p className="mt-3 text-center">Couldn't obtain payment URL</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const parsedData = PaymentBTCPayDataSchema.safeParse(data);
|
||||
if (!parsedData.success || !parsedData.data?.invoice?.checkoutLink) return wrongUrl;
|
||||
const checkoutUrl = parsedData.data.invoice.checkoutLink;
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(checkoutUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 mt-8 flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<PaymentChecker {...props.paymentPageProps} />
|
||||
|
||||
{!iframeLoaded && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Spinner className="mr-2 h-5 w-5" />
|
||||
<p>Loading payment page...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`w-full ${iframeLoaded ? "block" : "hidden"}`}>
|
||||
<iframe
|
||||
src={checkoutUrl}
|
||||
title="BTCPay Payment"
|
||||
className="h-[1000px] w-full rounded-md border-0"
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => copyToClipboard(checkoutUrl)}
|
||||
className="text-subtle rounded-md"
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}>
|
||||
Copy Payment Link
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleOpenInNewTab}>Open in New Tab</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type PaymentCheckerProps = PaymentPageProps;
|
||||
|
||||
function PaymentChecker(props: PaymentCheckerProps) {
|
||||
// TODO: move booking success code to a common lib function
|
||||
// TODO: subscribe rather than polling
|
||||
const searchParams = useCompatSearchParams();
|
||||
const bookingSuccessRedirect = useBookingSuccessRedirect();
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// use closure to ensure non-nullability
|
||||
const sp = searchParams;
|
||||
const interval = setInterval(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (props.booking.status === "ACCEPTED") {
|
||||
return;
|
||||
}
|
||||
const { booking: bookingResult } = await utils.viewer.bookings.find.fetch({
|
||||
bookingUid: props.booking.uid,
|
||||
});
|
||||
|
||||
if (bookingResult?.paid) {
|
||||
showToast("Payment successful", "success");
|
||||
|
||||
const params: {
|
||||
uid: string;
|
||||
email: string | null;
|
||||
location: string;
|
||||
} = {
|
||||
uid: props.booking.uid,
|
||||
email: sp.get("email"),
|
||||
location: t("web_conferencing_details_to_follow"),
|
||||
};
|
||||
|
||||
bookingSuccessRedirect({
|
||||
successRedirectUrl: props.eventType.successRedirectUrl,
|
||||
query: params,
|
||||
booking: props.booking,
|
||||
forwardParamsSuccessRedirect: props.eventType.forwardParamsSuccessRedirect,
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
bookingSuccessRedirect,
|
||||
props.booking,
|
||||
props.booking.id,
|
||||
props.booking.status,
|
||||
props.eventType.id,
|
||||
props.eventType.successRedirectUrl,
|
||||
props.eventType.forwardParamsSuccessRedirect,
|
||||
props.payment.success,
|
||||
searchParams,
|
||||
t,
|
||||
utils.viewer.bookings,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps";
|
||||
import type { appDataSchema } from "../zod";
|
||||
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
eventType,
|
||||
app,
|
||||
eventTypeFormMetadata,
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const pathname = usePathname();
|
||||
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata);
|
||||
const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
|
||||
app={app}
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(e) => {
|
||||
setRequirePayment(e);
|
||||
}}
|
||||
description={<>Add lightning payments to your events and booking</>}
|
||||
disableSwitch={shouldDisableSwitch}
|
||||
switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}>
|
||||
<EventTypeAppSettingsInterface
|
||||
eventType={eventType}
|
||||
slug={app.slug}
|
||||
disabled={disabled}
|
||||
getAppData={getAppData}
|
||||
setAppData={setAppData}
|
||||
/>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppCard;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
|
||||
import {
|
||||
currencyOptions,
|
||||
convertToSmallestCurrencyUnit,
|
||||
convertFromSmallestToPresentableCurrencyUnit,
|
||||
} from "../lib/currencyOptions";
|
||||
import { BTCPayPaymentOptions as paymentOptions } from "../zod";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
|
||||
eventType,
|
||||
getAppData,
|
||||
setAppData,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const price = getAppData("price");
|
||||
const currency = getAppData("currency") || (currencyOptions.length > 0 ? currencyOptions[0].value : "");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState(
|
||||
currencyOptions.find((c) => c.value === currency) ||
|
||||
(currencyOptions.length > 0
|
||||
? {
|
||||
label: currencyOptions[0].label,
|
||||
value: currencyOptions[0].value,
|
||||
}
|
||||
: null)
|
||||
);
|
||||
|
||||
const paymentOption = getAppData("paymentOption");
|
||||
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
|
||||
label: paymentOptions.length > 0 ? paymentOptions[0].label : "",
|
||||
value: paymentOptions.length > 0 ? paymentOptions[0].value : "",
|
||||
};
|
||||
const seatsEnabled = !!eventType.seatsPerTimeSlot;
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
|
||||
|
||||
// make sure a currency is selected
|
||||
useEffect(() => {
|
||||
if (requirePayment && !getAppData("currency")) {
|
||||
setAppData("currency", currencyOptions[0].value);
|
||||
}
|
||||
}, [requirePayment, getAppData, setAppData]);
|
||||
|
||||
const disableDecimalPlace = (value: number) => {
|
||||
return Math.floor(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{recurringEventDefined ? (
|
||||
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
|
||||
) : (
|
||||
requirePayment && (
|
||||
<>
|
||||
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
|
||||
<TextField
|
||||
label={t("price")}
|
||||
className="text-black"
|
||||
addOnClassname="h-[38px]"
|
||||
min="1"
|
||||
type="number"
|
||||
required
|
||||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
|
||||
}}
|
||||
value={
|
||||
price && price > 0
|
||||
? disableDecimalPlace(convertFromSmallestToPresentableCurrencyUnit(price, currency))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
{t("currency")}
|
||||
</label>
|
||||
<Select
|
||||
variant="default"
|
||||
options={currencyOptions}
|
||||
value={selectedCurrency}
|
||||
className="text-black"
|
||||
defaultValue={selectedCurrency}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setSelectedCurrency(e);
|
||||
setAppData("currency", e.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="paymentOption">
|
||||
{t("payment_option")}
|
||||
</label>
|
||||
<Select<Option>
|
||||
defaultValue={
|
||||
paymentOptionSelectValue
|
||||
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
|
||||
: paymentOptions.length > 0
|
||||
? { ...paymentOptions[0], label: t(paymentOptions[0].label) }
|
||||
: undefined
|
||||
}
|
||||
options={paymentOptions.map((option) => {
|
||||
return { ...option, label: t(option.label) || option.label };
|
||||
})}
|
||||
onChange={(input) => {
|
||||
if (input) setAppData("paymentOption", input.value);
|
||||
}}
|
||||
className="mb-1 h-[38px] w-full"
|
||||
isDisabled={seatsEnabled}
|
||||
/>
|
||||
</div>
|
||||
{seatsEnabled && paymentOption === "HOLD" && (
|
||||
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppSettingsInterface;
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import classNames from "classnames";
|
||||
import type { FormEvent } from "react";
|
||||
import React, { forwardRef, useState, useEffect, useId, useCallback } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { Input } from "@calcom/ui/components/form";
|
||||
import type { InputFieldProps } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Skeleton } from "@calcom/ui/components/skeleton";
|
||||
|
||||
type AddonProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
error?: boolean;
|
||||
onClickAddon?: () => void;
|
||||
};
|
||||
|
||||
const Addon = ({ children, className, error }: AddonProps) => (
|
||||
<div
|
||||
className={classNames(
|
||||
"addon-wrapper border-default [input:hover_+_&]:border-emphasis [input:hover_+_&]:border-l-default [&:has(+_input:hover)]:border-emphasis [&:has(+_input:hover)]:border-r-default h-9 border px-3 transition disabled:hover:cursor-not-allowed",
|
||||
className
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"min-h-9 flex flex-col justify-center text-sm leading-7",
|
||||
error ? "text-error" : "text-default"
|
||||
)}>
|
||||
<span
|
||||
className="flex max-w-2xl overflow-y-auto whitespace-nowrap"
|
||||
style={{
|
||||
WebkitOverflowScrolling: "touch",
|
||||
scrollbarWidth: "none",
|
||||
overflow: "-ms-scroll-chaining",
|
||||
msOverflowStyle: "-ms-autohiding-scrollbar",
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const KeyField: React.FC<InputFieldProps & { defaultValue: string }> = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputFieldProps & { defaultValue: string }
|
||||
>(function KeyField(props, ref) {
|
||||
const id = useId();
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [currentValue, setCurrentValue] = useState<string>("");
|
||||
const toggleIsPasswordVisible = useCallback(
|
||||
() => setIsPasswordVisible(!isPasswordVisible),
|
||||
[isPasswordVisible, setIsPasswordVisible]
|
||||
);
|
||||
|
||||
const { t: _t, isLocaleReady, i18n } = useLocale();
|
||||
const t = props.t || _t;
|
||||
const name = props.name || "";
|
||||
const {
|
||||
label = t(name),
|
||||
labelProps,
|
||||
labelClassName,
|
||||
LockedIcon,
|
||||
placeholder = isLocaleReady && i18n.exists(`${name}_placeholder`) ? t(`${name}_placeholder`) : "",
|
||||
className,
|
||||
addOnLeading,
|
||||
addOnClassname,
|
||||
inputIsFullWidth,
|
||||
labelSrOnly,
|
||||
noLabel,
|
||||
containerClassName,
|
||||
readOnly,
|
||||
showAsteriskIndicator,
|
||||
defaultValue,
|
||||
...passThrough
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentValue.trim().length === 0) {
|
||||
setIsPasswordVisible(true);
|
||||
}
|
||||
}, [currentValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentValue(defaultValue);
|
||||
if (defaultValue.length > 0) {
|
||||
setIsPasswordVisible(false);
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
const getHiddenKey = (): string => {
|
||||
let hiddenKey = currentValue;
|
||||
const length = currentValue.length;
|
||||
if (length > 6) {
|
||||
const start = currentValue.slice(0, 3);
|
||||
const end = currentValue.slice(length - 3);
|
||||
hiddenKey = `${start}${"*".repeat(length - 6)}${end}`;
|
||||
}
|
||||
|
||||
return hiddenKey;
|
||||
};
|
||||
|
||||
const onInput = (event: FormEvent<HTMLInputElement>) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const fullValue = target.value;
|
||||
setCurrentValue(fullValue);
|
||||
target.value = fullValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(containerClassName)}>
|
||||
{!!label && !noLabel && (
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor={id}
|
||||
loadingClassName="w-16"
|
||||
{...labelProps}
|
||||
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
|
||||
{label}
|
||||
{showAsteriskIndicator && !readOnly && passThrough.required ? (
|
||||
<span className="text-default ml-1 font-medium">*</span>
|
||||
) : null}
|
||||
{LockedIcon}
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
<div
|
||||
dir="ltr"
|
||||
className="focus-within:ring-brand-default group relative mb-1 flex items-center rounded-md transition focus-within:outline-none focus-within:ring-2">
|
||||
{addOnLeading && (
|
||||
<Addon className={classNames("ltr:rounded-l-md rtl:rounded-r-md", addOnClassname)}>
|
||||
{addOnLeading}
|
||||
</Addon>
|
||||
)}
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
isFullWidth={inputIsFullWidth}
|
||||
className={classNames(
|
||||
className,
|
||||
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
|
||||
addOnLeading && "rounded-l-none border-l-0",
|
||||
isPasswordVisible && "inline-block",
|
||||
!isPasswordVisible && "hidden",
|
||||
"!my-0 !ring-0"
|
||||
)}
|
||||
{...passThrough}
|
||||
ref={ref}
|
||||
onInput={onInput}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
isFullWidth={inputIsFullWidth}
|
||||
className={classNames(
|
||||
className,
|
||||
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
|
||||
addOnLeading && "rounded-l-none border-l-0",
|
||||
!isPasswordVisible && "inline-block",
|
||||
isPasswordVisible && "hidden",
|
||||
"!my-0 !ring-0"
|
||||
)}
|
||||
disabled
|
||||
value={getHiddenKey()}
|
||||
/>
|
||||
<Addon className={classNames("ltr:rounded-r-md rtl:rounded-l-md", addOnClassname)}>
|
||||
<button
|
||||
className="text-emphasis h-9"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onClick={() => toggleIsPasswordVisible()}>
|
||||
{isPasswordVisible ? (
|
||||
<Icon name="eye-off" className="h-4 w-4 stroke-[2.5px]" />
|
||||
) : (
|
||||
<Icon name="eye" className="h-4 w-4 stroke-[2.5px]" />
|
||||
)}
|
||||
</button>
|
||||
</Addon>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default KeyField;
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "BTCPayServer",
|
||||
"slug": "btcpayserver",
|
||||
"type": "btcpayserver_payment",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://btcpayserver.org",
|
||||
"variant": "payment",
|
||||
"categories": ["payment"],
|
||||
"publisher": "BTCPay Server Team",
|
||||
"email": "chat.btcpayserver.org",
|
||||
"description": "BTCPay Server is a self-hosted open source Bitcoin payment processor. Start receiving bitcoin payments for your events and bookings.",
|
||||
"extendsFeature": "EventType",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "event-type-app-card"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as api from "./api";
|
||||
export * as lib from "./lib";
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type z from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { IBookingPaymentRepository } from "@calcom/lib/server/repository/BookingPaymentRepository.interface";
|
||||
import { PrismaBookingPaymentRepository } from "@calcom/lib/server/repository/PrismaBookingPaymentRepository";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
|
||||
|
||||
import appConfig from "../config.json";
|
||||
import { btcpayCredentialKeysSchema } from "./btcpayCredentialKeysSchema";
|
||||
import { convertFromSmallestToPresentableCurrencyUnit } from "./currencyOptions";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["payment-service:btcpayserver"] });
|
||||
|
||||
interface BTCPayInvoice {
|
||||
id: string;
|
||||
checkoutLink: string;
|
||||
status: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
createdTime: number;
|
||||
expirationTime: number;
|
||||
metadata?: Record<string, any>;
|
||||
checkout?: Record<string, any>;
|
||||
receipt?: Record<string, any>;
|
||||
payments?: Array<{
|
||||
id: string;
|
||||
amount: string;
|
||||
paymentMethod: string;
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class PaymentService implements IAbstractPaymentService {
|
||||
private credentials: z.infer<typeof btcpayCredentialKeysSchema> | null;
|
||||
private bookingPaymentRepository: IBookingPaymentRepository;
|
||||
|
||||
constructor(
|
||||
credentials: { key: Prisma.JsonValue },
|
||||
bookingPaymentRepository: IBookingPaymentRepository = new PrismaBookingPaymentRepository()
|
||||
) {
|
||||
const keyParsing = btcpayCredentialKeysSchema.safeParse(credentials.key);
|
||||
if (keyParsing.success) {
|
||||
this.credentials = keyParsing.data;
|
||||
} else {
|
||||
this.credentials = null;
|
||||
}
|
||||
this.bookingPaymentRepository = bookingPaymentRepository;
|
||||
}
|
||||
|
||||
private async BTCPayApiCall(endpoint: string, options: RequestInit = {}) {
|
||||
if (!this.credentials) throw new Error("BTCPay server credentials not found");
|
||||
|
||||
const serverUrl = this.credentials.serverUrl.endsWith("/")
|
||||
? this.credentials.serverUrl.slice(0, -1)
|
||||
: this.credentials.serverUrl;
|
||||
const url = `${serverUrl}${endpoint}`;
|
||||
const headers = {
|
||||
Authorization: `token ${this.credentials.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`BTCPay server API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
bookingId: Booking["id"],
|
||||
userId: Booking["userId"],
|
||||
username: string | null,
|
||||
bookerName: string,
|
||||
paymentOption: PaymentOption,
|
||||
bookerEmail: string,
|
||||
bookerPhoneNumber?: string | null,
|
||||
eventTitle?: string,
|
||||
bookingTitle?: string
|
||||
) {
|
||||
try {
|
||||
if (!this.credentials?.storeId) {
|
||||
throw new Error("BTCPay server: Store ID not found");
|
||||
}
|
||||
|
||||
const uid = uuidv4();
|
||||
const invoiceRequest = {
|
||||
metadata: {
|
||||
orderId: `cal-booking-${bookingId}`,
|
||||
itemDesc: bookingTitle || `Booking #${bookingId}`,
|
||||
appId: "cal.com",
|
||||
referenceId: uid,
|
||||
customerName: bookerName,
|
||||
customerEmail: bookerEmail,
|
||||
bookingDescription: bookingTitle || `Booking with ${bookerName}`,
|
||||
},
|
||||
checkout: {
|
||||
buyerEmail: bookerEmail,
|
||||
},
|
||||
receipt: {
|
||||
enabled: true,
|
||||
},
|
||||
amount: convertFromSmallestToPresentableCurrencyUnit(payment.amount, payment.currency),
|
||||
currency: payment.currency === "BTC" ? "SATS" : payment.currency,
|
||||
additionalSearchTerms: [`cal-booking-${bookingId}`, bookerName, bookerEmail],
|
||||
};
|
||||
const invoiceResponse = (await this.BTCPayApiCall(
|
||||
`/api/v1/stores/${this.credentials.storeId}/invoices`,
|
||||
{ method: "POST", body: JSON.stringify(invoiceRequest) }
|
||||
)) as BTCPayInvoice;
|
||||
|
||||
const paymentData = await this.bookingPaymentRepository.createPaymentRecord({
|
||||
uid,
|
||||
app: { connect: { slug: appConfig.slug } },
|
||||
booking: { connect: { id: bookingId } },
|
||||
amount: payment.amount,
|
||||
externalId: invoiceResponse.id,
|
||||
currency: payment.currency,
|
||||
fee: 0,
|
||||
success: false,
|
||||
refunded: false,
|
||||
data: Object.assign(
|
||||
{},
|
||||
{
|
||||
invoice: {
|
||||
...invoiceResponse,
|
||||
isPaid: false,
|
||||
attendee: { name: bookerName, email: bookerEmail },
|
||||
},
|
||||
}
|
||||
),
|
||||
});
|
||||
if (!paymentData) throw new Error("Failed to store Payment data");
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
log.error("BTCPay server: Payment could not be created", bookingId, safeStringify(error));
|
||||
throw new Error(ErrorCode.PaymentCreationFailure);
|
||||
}
|
||||
}
|
||||
|
||||
async update(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
async refund(): Promise<Payment> {
|
||||
throw new Error("BTCPay Server does not support automatic refunds for Bitcoin payments");
|
||||
}
|
||||
|
||||
async collectCard(
|
||||
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
_bookingId: number,
|
||||
_bookerEmail: string,
|
||||
_paymentOption: PaymentOption
|
||||
): Promise<Payment> {
|
||||
throw new Error("Method not implemented");
|
||||
}
|
||||
|
||||
chargeCard(
|
||||
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
|
||||
_bookingId: number
|
||||
): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async getPaymentPaidStatus(): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async getPaymentDetails(): Promise<Payment> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async afterPayment(
|
||||
_event: CalendarEvent,
|
||||
_booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
startTime: { toISOString: () => string };
|
||||
uid: string;
|
||||
},
|
||||
_paymentData: Payment
|
||||
): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
deletePayment(_paymentId: number): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
isSetupAlready(): boolean {
|
||||
return !!this.credentials;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import z from "zod";
|
||||
|
||||
export const btcpayCredentialKeysSchema = z.object({
|
||||
serverUrl: z.string().url(),
|
||||
storeId: z.string(),
|
||||
apiKey: z.string(),
|
||||
webhookSecret: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export const currencyOptions = [
|
||||
{ label: "SATS", value: "BTC", unit: "SATS" },
|
||||
{ label: "USD", value: "USD", unit: "USD" },
|
||||
];
|
||||
|
||||
const zeroDecimalCurrencies = ["SATS", "BTC"];
|
||||
|
||||
export const convertToSmallestCurrencyUnit = (amount: number, currency: string) => {
|
||||
if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
|
||||
return amount;
|
||||
}
|
||||
return Math.round(amount * 100);
|
||||
};
|
||||
|
||||
export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, currency: string) => {
|
||||
if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
|
||||
return amount;
|
||||
}
|
||||
return amount / 100;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./PaymentService";
|
||||
export * from "./btcpayCredentialKeysSchema";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/btcpayserver",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "BTCPay Server is a self-hosted open source Bitcoin payment processor. Start receiving bitcoin payments for your events and bookings."
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
|
||||
import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
|
||||
import type { IBTCPaySetupProps } from "./index";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
try {
|
||||
const notFound = { notFound: true } as const;
|
||||
if (typeof ctx.params?.slug !== "string") return notFound;
|
||||
|
||||
const { req } = ctx;
|
||||
const session = await getServerSession({ req });
|
||||
if (!session?.user?.id) return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||
|
||||
const credential = await CredentialRepository.findFirstByUserIdAndType({
|
||||
userId: session.user.id,
|
||||
type: "btcpayserver_payment",
|
||||
});
|
||||
|
||||
let props: IBTCPaySetupProps | undefined;
|
||||
if (credential?.key) {
|
||||
const keyParsing = btcpayCredentialKeysSchema.safeParse(credential.key);
|
||||
if (keyParsing.success) {
|
||||
props = keyParsing.data;
|
||||
}
|
||||
}
|
||||
return { props: props ?? {} };
|
||||
} catch (error) {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,354 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Toaster } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import KeyField from "../../components/KeyInput";
|
||||
import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
|
||||
|
||||
export type IBTCPaySetupProps = z.infer<typeof btcpayCredentialKeysSchema>;
|
||||
|
||||
export default function BTCPaySetup(props: IBTCPaySetupProps) {
|
||||
const params = useCompatSearchParams();
|
||||
if (params?.get("callback") === "true") {
|
||||
return <BTCPaySetupCallback />;
|
||||
}
|
||||
return <BTCPaySetupPage {...props} />;
|
||||
}
|
||||
|
||||
enum BTCPayOAuthError {
|
||||
Declined = "declined",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
function BTCPaySetupCallback() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const searchParams = useCompatSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams) {
|
||||
return;
|
||||
}
|
||||
if (!window.opener) {
|
||||
setError("Something went wrong. Opener not available. Please contact support");
|
||||
return;
|
||||
}
|
||||
const code = searchParams?.get("code");
|
||||
const error = searchParams?.get("error");
|
||||
|
||||
if (!code) {
|
||||
setError(BTCPayOAuthError.Declined);
|
||||
}
|
||||
if (error) {
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
window.opener.postMessage({
|
||||
type: "btcpayserver:oauth:success",
|
||||
payload: { code },
|
||||
});
|
||||
window.close();
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <p>Authorization failed: {error}</p>}
|
||||
{!error && <p>Connecting...</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BTCPaySetupPage(props: IBTCPaySetupProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [validating, setValidating] = useState<boolean>(false);
|
||||
const [updatable, setUpdatable] = useState<boolean>(false);
|
||||
const [keyData, setKeyData] = useState<
|
||||
| {
|
||||
storeId: string;
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
webhookSecret: string;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
const settingsSchema = z.object({
|
||||
storeId: z.string().trim(),
|
||||
serverUrl: z.string().trim(),
|
||||
apiKey: z.string().trim(),
|
||||
webhookSecret: z.string().optional(),
|
||||
});
|
||||
const integrations = trpc.viewer.apps.integrations.useQuery({ variant: "payment", appId: "btcpayserver" });
|
||||
const [btcPayPaymentAppCredentials] = integrations.data?.items || [];
|
||||
const [credentialId] = btcPayPaymentAppCredentials?.userCredentialIds || [-1];
|
||||
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
|
||||
|
||||
const saveKeysMutation = trpc.viewer.apps.updateAppCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("keys_have_been_saved"), "success");
|
||||
router.push("/event-types");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
const deleteMutation = trpc.viewer.credentials.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push("/apps/btcpayserver");
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_removing_app"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
reset,
|
||||
} = useForm<z.infer<typeof settingsSchema>>({
|
||||
reValidateMode: "onChange",
|
||||
resolver: zodResolver(settingsSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const _keyData = {
|
||||
storeId: props?.storeId || "",
|
||||
serverUrl: props?.serverUrl || "",
|
||||
apiKey: props?.apiKey || "",
|
||||
webhookSecret: props?.webhookSecret || "",
|
||||
};
|
||||
setKeyData(_keyData);
|
||||
}, [props]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch((value) => {
|
||||
const { serverUrl, storeId, apiKey, webhookSecret } = value;
|
||||
if (
|
||||
serverUrl &&
|
||||
storeId &&
|
||||
apiKey &&
|
||||
(keyData?.serverUrl !== serverUrl || keyData?.storeId !== storeId || keyData?.apiKey !== apiKey)
|
||||
) {
|
||||
setUpdatable(true);
|
||||
} else {
|
||||
setUpdatable(false);
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, keyData]);
|
||||
|
||||
const configureBTCPayWebhook = async (data: z.infer<typeof settingsSchema>) => {
|
||||
setValidating(true);
|
||||
const specificEvents = ["InvoiceSettled", "InvoiceProcessing"];
|
||||
const serverUrl = data.serverUrl.endsWith("/") ? data.serverUrl.slice(0, -1) : data.serverUrl;
|
||||
const endpoint = `${serverUrl}/api/v1/stores/${data.storeId}/webhooks`;
|
||||
const webhookUrl = `${WEBAPP_URL}/api/integrations/btcpayserver/webhook`;
|
||||
const requestBody = {
|
||||
enabled: true,
|
||||
automaticRedelivery: false,
|
||||
url: webhookUrl,
|
||||
authorizedEvents: {
|
||||
everything: false,
|
||||
specificEvents: specificEvents,
|
||||
},
|
||||
secret: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `token ${data.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
showToast(`Failed to configure webhook: ${errorBody}`, "error");
|
||||
return false;
|
||||
}
|
||||
const webhookResponse = await response.json();
|
||||
saveKeysMutation.mutate({
|
||||
credentialId,
|
||||
key: btcpayCredentialKeysSchema.parse({
|
||||
...data,
|
||||
webhookSecret: webhookResponse.secret,
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
showToast(error.message || "Failed to configure BTCPay webhook", "error");
|
||||
} else {
|
||||
showToast("An unknown error occurred while configuring BTCPay webhook", "error");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const isValid = await configureBTCPayWebhook(data);
|
||||
if (!isValid) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
let message = "";
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
showToast(message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const onCancel = () => {
|
||||
deleteMutation.mutate({ id: credentialId });
|
||||
};
|
||||
|
||||
const btcpayIcon = (
|
||||
<>
|
||||
<img className="h-5 w-5" src="/app-store/btcpayserver/btcpay.svg" alt="BTCPay Server Icon" />
|
||||
</>
|
||||
);
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
|
||||
if (integrations.isPending) {
|
||||
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
|
||||
}
|
||||
|
||||
const isNewCredential = !props.serverUrl && !props.storeId && !props.webhookSecret && !props.apiKey;
|
||||
const webhookUri = `${WEBAPP_URL}/api/integrations/btcpayserver/webhook`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-default flex h-screen items-center justify-center">
|
||||
{showContent ? (
|
||||
<div className="flex w-full w-full max-w-[43em] flex-col items-center justify-center space-y-4 p-4 lg:space-y-5">
|
||||
<form className="w-full space-y-4" onSubmit={onSubmit}>
|
||||
<div className="bg-default border-subtle overflow-auto rounded border">
|
||||
<div className="border-subtle flex items-center justify-between border-b-[1px] p-4 md:p-5">
|
||||
<h2 className="text-2xl font-semibold">BTCPay Server Information</h2>
|
||||
</div>
|
||||
<div className="w-full space-y-4 p-4 md:p-5">
|
||||
<div className="w-full">
|
||||
<KeyField
|
||||
{...register("serverUrl", { required: true })}
|
||||
id="serverUrl"
|
||||
name="serverUrl"
|
||||
containerClassName="w-full"
|
||||
label="BTCPay Server Url"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
defaultValue={keyData?.serverUrl || ""}
|
||||
/>
|
||||
{errors.serverUrl && (
|
||||
<p data-testid="required" className="py-2 text-xs text-red-500">
|
||||
{errors.serverUrl?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<KeyField
|
||||
{...register("storeId", { required: true })}
|
||||
id="storeId"
|
||||
name="storeId"
|
||||
containerClassName="w-full"
|
||||
label="BTCPay Store Id"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
defaultValue={keyData?.storeId || ""}
|
||||
/>
|
||||
{errors.storeId && (
|
||||
<p data-testid="required" className="py-2 text-xs text-red-500">
|
||||
{errors.storeId?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<KeyField
|
||||
{...register("apiKey", { required: true })}
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
containerClassName="w-full"
|
||||
label="API Key"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
defaultValue={keyData?.apiKey || ""}
|
||||
/>
|
||||
{errors.apiKey && (
|
||||
<p data-testid="required" className="py-2 text-xs text-red-500">
|
||||
{errors.apiKey?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-4">
|
||||
{isNewCredential ? (
|
||||
<>
|
||||
<Button color="secondary" className="h-10" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={loading || validating || !updatable}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Icon name="loader" className="mr-2 animate-spin" />
|
||||
{t("connecting")}
|
||||
</>
|
||||
) : (
|
||||
t("connect")
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/apps/btcpayserver" className="inline-block">
|
||||
<Button color="secondary">Go to App Store</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<AppNotInstalledMessage appName="btcpayserver" />
|
||||
)}
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 252 KiB |
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
viewBox="0 0 1706.6667 1066.6667"
|
||||
height="1066.6667"
|
||||
width="1706.6667"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
version="1.1"
|
||||
sodipodi:docname="BTCPay Server Icon.svg"
|
||||
inkscape:version="0.92.3 (2405546, 2018-03-11)"><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
id="namedview82"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.16640624"
|
||||
inkscape:cx="853.33337"
|
||||
inkscape:cy="533.33337"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g10" /><metadata
|
||||
id="metadata8"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs6"><clipPath
|
||||
id="clipPath18"
|
||||
clipPathUnits="userSpaceOnUse"><path
|
||||
id="path16"
|
||||
d="M 0,800 H 1280 V 0 H 0 Z" /></clipPath><clipPath
|
||||
id="clipPath74"
|
||||
clipPathUnits="userSpaceOnUse"><path
|
||||
id="path72"
|
||||
d="M 0,800 H 1280 V 0 H 0 Z" /></clipPath><clipPath
|
||||
id="clipPath128"
|
||||
clipPathUnits="userSpaceOnUse"><path
|
||||
id="path126"
|
||||
d="M 0,800 H 1280 V 0 H 0 Z" /></clipPath><clipPath
|
||||
id="clipPath140"
|
||||
clipPathUnits="userSpaceOnUse"><path
|
||||
id="path138"
|
||||
d="M 0,800 H 1280 V 0 H 0 Z" /></clipPath></defs><g
|
||||
transform="matrix(1.3333333,0,0,-1.3333333,955.493,249.38967)"
|
||||
id="g10"><g
|
||||
transform="matrix(0.29765316,0,0,0.29765316,413.69235,44.782893)"
|
||||
id="g134"><g
|
||||
transform="matrix(3.6585926,0,0,3.6585926,-4809.6898,-2104.6753)"
|
||||
clip-path="url(#clipPath140)"
|
||||
id="g136"><g
|
||||
transform="matrix(4.7732059,0,0,4.7732059,779.20458,11.551291)"
|
||||
id="g146"><path
|
||||
id="path148"
|
||||
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 0,0 c -4.573,0 -8.281,3.707 -8.281,8.28 v 124.622 c 0,4.573 3.708,8.281 8.281,8.281 4.573,0 8.281,-3.708 8.281,-8.281 V 8.28 C 8.281,3.707 4.573,0 0,0"
|
||||
inkscape:connector-curvature="0" /></g><g
|
||||
transform="matrix(4.7732059,0,0,4.7732059,779.22987,11.542212)"
|
||||
id="g150"><path
|
||||
id="path152"
|
||||
style="fill:#51b13e;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 0,0 c -3.099,0 -6.069,1.746 -7.487,4.731 -1.96,4.132 -0.201,9.072 3.931,11.033 l 49.934,23.698 -51.295,37.79 c -3.682,2.713 -4.468,7.897 -1.754,11.58 2.711,3.681 7.898,4.467 11.577,1.754 L 67.347,44.583 c 2.345,-1.728 3.611,-4.56 3.331,-7.46 -0.279,-2.898 -2.06,-5.438 -4.693,-6.687 L 3.545,0.802 C 2.399,0.258 1.19,0 0,0"
|
||||
inkscape:connector-curvature="0" /></g><g
|
||||
transform="matrix(4.7732059,0,0,4.7732059,779.23704,245.35911)"
|
||||
id="g154"><path
|
||||
id="path156"
|
||||
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 0,0 c -2.542,0 -5.05,1.166 -6.673,3.369 -2.713,3.683 -1.928,8.867 1.755,11.579 L 46.376,52.739 -3.557,76.438 c -4.132,1.96 -5.891,6.899 -3.931,11.031 1.962,4.132 6.902,5.893 11.031,3.93 L 65.984,61.766 c 2.632,-1.249 4.414,-3.789 4.693,-6.688 0.279,-2.9 -0.987,-5.733 -3.332,-7.46 L 4.905,1.614 C 3.426,0.525 1.705,0 0,0"
|
||||
inkscape:connector-curvature="0" /></g><g
|
||||
transform="matrix(4.7732059,0,0,4.7732059,818.73005,432.0807)"
|
||||
id="g158"><path
|
||||
id="path160"
|
||||
style="fill:#1e7a44;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 0,0 v -35.022 l 23.757,17.503 z"
|
||||
inkscape:connector-curvature="0" /></g><path
|
||||
id="path162"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.77320623"
|
||||
d="m 818.73151,309.4809 h -79.05385 v 192.68958 h 79.05385 z"
|
||||
inkscape:connector-curvature="0" /><g
|
||||
transform="matrix(4.7732059,0,0,4.7732059,779.20458,685.44488)"
|
||||
id="g164"><path
|
||||
id="path166"
|
||||
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 0,0 C -4.573,0 -8.281,-3.708 -8.281,-8.281 V -112.496 H 8.281 V -8.281 C 8.281,-3.708 4.573,0 0,0"
|
||||
inkscape:connector-curvature="0" /></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 555 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 521 KiB |
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
|
||||
|
||||
const paymentOptionSchema = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const paymentOptionsSchema = z.array(paymentOptionSchema);
|
||||
|
||||
export const BTCPayPaymentOptions = [
|
||||
{
|
||||
label: "on_booking_option",
|
||||
value: "ON_BOOKING",
|
||||
},
|
||||
];
|
||||
|
||||
type PaymentOption = (typeof BTCPayPaymentOptions)[number]["value"];
|
||||
const VALUES: [PaymentOption, ...PaymentOption[]] = [
|
||||
BTCPayPaymentOptions[0].value,
|
||||
...BTCPayPaymentOptions.slice(1).map((option) => option.value),
|
||||
];
|
||||
export const paymentOptionEnum = z.enum(VALUES);
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod.merge(
|
||||
z.object({
|
||||
price: z.number(),
|
||||
currency: z.string(),
|
||||
paymentOption: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
credentialId: z.number().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
export const appKeysSchema = z.object({});
|
||||
@@ -42,6 +42,7 @@ const appStore = {
|
||||
telegramvideo: createCachedImport(() => import("./telegram")),
|
||||
shimmervideo: createCachedImport(() => import("./shimmervideo")),
|
||||
hitpay: createCachedImport(() => import("./hitpay")),
|
||||
btcpayserver: createCachedImport(() => import("./btcpayserver")),
|
||||
};
|
||||
|
||||
function createCachedImport<T>(importFunc: () => Promise<T>): () => Promise<T> {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Toaster } from "sonner";
|
||||
import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import { RetryableError } from "@calcom/lib/crmManager/errors";
|
||||
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { PrismaAssignmentReasonRepository } from "@calcom/lib/server/repository/PrismaAssignmentReasonRepository";
|
||||
import { PrismaRoutingFormResponseRepository as RoutingFormResponseRepository } from "@calcom/lib/server/repository/PrismaRoutingFormResponseRepository";
|
||||
import { AssignmentReasonRepository } from "@calcom/lib/server/repository/assignmentReason";
|
||||
import { RoutingFormResponseDataFactory } from "@calcom/lib/server/service/routingForm/RoutingFormResponseDataFactory";
|
||||
import { findFieldValueByIdentifier } from "@calcom/lib/server/service/routingForm/responseData/findFieldValueByIdentifier";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
@@ -1342,7 +1342,7 @@ export default class SalesforceCRMService implements CRM {
|
||||
}
|
||||
|
||||
private async getAssignmentReason(bookingId: string) {
|
||||
const assignmentReason = await AssignmentReasonRepository.findLatestReasonFromBookingUid(bookingId);
|
||||
const assignmentReason = await PrismaAssignmentReasonRepository.findLatestReasonFromBookingUid(bookingId);
|
||||
return assignmentReason?.reasonString ?? "";
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@calcom/eslint-plugin-eslint": "*",
|
||||
"@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-turbo": "^0.0.7",
|
||||
"eslint-plugin-playwright": "^0.12.0",
|
||||
@@ -33,6 +33,6 @@
|
||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||
"tailwind-scrollbar": "^2.0.1",
|
||||
"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 { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import { setTestEmail } from "@calcom/lib/testEmails";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { sanitizeDisplayName } from "../lib/sanitizeDisplayName";
|
||||
|
||||
@@ -29,7 +30,7 @@ export default class BaseEmail {
|
||||
return {};
|
||||
}
|
||||
public async sendEmail() {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const emailsDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("emails");
|
||||
/** If email kill switch exists and is active, we prevent emails being sent. */
|
||||
if (emailsDisabled) {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.18",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.9.0-beta",
|
||||
"vite": "^4.1.2",
|
||||
"vite-plugin-environment": "^1.1.3"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user