Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6838133865 | ||
|
|
4b2bb2271a | ||
|
|
20b7ac8c0d | ||
|
|
ca7c432728 | ||
|
|
3b966191e3 | ||
|
|
a7889984a0 | ||
|
|
3c72d7aaa0 | ||
|
|
ece0030978 | ||
|
|
a26a769119 | ||
|
|
3313de1eec | ||
|
|
7a6e16631b | ||
|
|
ce4d71ba2e | ||
|
|
300cd31efb | ||
|
|
f5bb734441 | ||
|
|
e33560ef82 | ||
|
|
84f34b2244 | ||
|
|
086bae03d3 | ||
|
|
4a0a429774 | ||
|
|
c4142ab4e7 | ||
|
|
95f8046db9 | ||
|
|
d8a87e9f04 | ||
|
|
86dcd6f0ff | ||
|
|
411a14dd3d | ||
|
|
03bc8db64a | ||
|
|
fe7fd8bc79 | ||
|
|
92eb41c117 | ||
|
|
a1fca56aac | ||
|
|
f3630399ee | ||
|
|
5375c656af | ||
|
|
1792d49634 | ||
|
|
de98578fdd | ||
|
|
c0a59a2837 | ||
|
|
29201006d5 | ||
|
|
7a1c780088 | ||
|
|
550f34c128 | ||
|
|
d6b17b76db | ||
|
|
c42e803cfa | ||
|
|
0233c3a11a | ||
|
|
71a2b6956f | ||
|
|
8282557ebe | ||
|
|
083a162d0e | ||
|
|
60487ea02e | ||
|
|
60a8841439 | ||
|
|
184d1eb751 | ||
|
|
9fea30a44d | ||
|
|
04efcbcdb6 | ||
|
|
2b29b5607d | ||
|
|
20f19c2509 | ||
|
|
5d0e66763e | ||
|
|
bcfbc24e93 | ||
|
|
37d805b007 | ||
|
|
6a246e6c57 | ||
|
|
3a0229f60d | ||
|
|
0bf95ec568 | ||
|
|
6f05142447 | ||
|
|
e02108d535 | ||
|
|
b7e668217d | ||
|
|
472ffef5cf | ||
|
|
5acdb208f5 | ||
|
|
7bf5d9cf64 | ||
|
|
34a80180ef | ||
|
|
3bbba9c6cb | ||
|
|
b052c2e029 | ||
|
|
4eb884e2a6 | ||
|
|
3f8ce8ff50 | ||
|
|
67a2371c05 | ||
|
|
80250d32bd | ||
|
|
0fadbe1ce9 | ||
|
|
9c3ee262b4 | ||
|
|
b62eba8128 | ||
|
|
ef114d7727 | ||
|
|
be7d127c9e | ||
|
|
1209150785 | ||
|
|
31940b919e | ||
|
|
c3c990db43 | ||
|
|
84a742336c | ||
|
|
ada8ffd3b9 | ||
|
|
2277d90326 | ||
|
|
cbc27a78ee | ||
|
|
a2189643bd | ||
|
|
a6101b3da9 | ||
|
|
216fb2141b | ||
|
|
afb54ca048 | ||
|
|
f4b23b3a03 | ||
|
|
d6cd593b95 | ||
|
|
2d9c1b3fd9 | ||
|
|
f2f10a06d9 | ||
|
|
e1def884f3 |
+8
-8
@@ -103,19 +103,14 @@ NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
# Secret to enable Intercom Identity Verification
|
||||
INTERCOM_SECRET=
|
||||
|
||||
# Intercom api token for free users
|
||||
INTERCOM_API_TOKEN=
|
||||
|
||||
# Posthog Config
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
NEXT_PUBLIC_POSTHOG_HOST=
|
||||
|
||||
# plain.com config
|
||||
|
||||
PLAIN_API_KEY=
|
||||
PLAIN_API_URL=https://api.plain.com/v1
|
||||
PLAIN_HMAC_SECRET_KEY=
|
||||
NEXT_PUBLIC_PLAIN_CHAT_ID=
|
||||
PLAIN_CHAT_HMAC_SECRET_KEY=
|
||||
NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS=
|
||||
|
||||
|
||||
# Dub Config
|
||||
@@ -386,6 +381,11 @@ UNKEY_ROOT_KEY=
|
||||
# Used for Cal.ai Voice AI Agents
|
||||
# https://retellai.com
|
||||
RETELL_AI_KEY=
|
||||
# Local testing overrides for Retell AI
|
||||
RETELL_AI_TEST_MODE=false
|
||||
# JSON mapping of local to production event type IDs (e.g. {"50": 747280} without any string)
|
||||
RETELL_AI_TEST_EVENT_TYPE_MAP=
|
||||
RETELL_AI_TEST_CAL_API_KEY=
|
||||
|
||||
# Used for buying phone number for cal ai voice agent
|
||||
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID=
|
||||
|
||||
@@ -19,5 +19,17 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
paths: ["@calcom/trpc"],
|
||||
patterns: ["@calcom/trpc/*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
has-files-requiring-all-checks:
|
||||
- "!(**.md|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json)"
|
||||
- "!(**.md|**.mdx|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json|i18n.lock)"
|
||||
- name: Get Latest Commit SHA
|
||||
id: get_sha
|
||||
run: |
|
||||
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
@@ -43,7 +43,7 @@ export const bookingCancelSchema = z.object({
|
||||
id: z.number(),
|
||||
allRemainingBookings: z.boolean().optional(),
|
||||
cancelSubsequentBookings: z.boolean().optional(),
|
||||
cancellationReason: z.string().optional().default("Not Provided"),
|
||||
cancellationReason: z.string().min(1).optional(),
|
||||
seatReferenceUid: z.string().optional(),
|
||||
cancelledBy: z.string().email({ message: "Invalid email" }).optional(),
|
||||
internalNote: z
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NextApiRequest } from "next";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
|
||||
import { canUserAccessTeamWithRole } from "~/pages/api/teams/[teamId]/_auth-middleware";
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.335",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.347",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
@@ -55,9 +55,9 @@
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "6.2.1",
|
||||
"@sentry/nestjs": "^8.37.1",
|
||||
"@sentry/node": "^8.8.0",
|
||||
"@sentry/profiling-node": "^8.37.1",
|
||||
"@sentry/nestjs": "^9.15.0",
|
||||
"@sentry/node": "^9.15.0",
|
||||
"@sentry/profiling-node": "^9.15.0",
|
||||
"@snyk/protect": "latest",
|
||||
"body-parser": "^1.20.2",
|
||||
"bull": "^4.12.4",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BookingsController_2024_04_15 } from "@/ee/bookings/2024-04-15/controllers/bookings.controller";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
@@ -41,6 +42,7 @@ import { Module } from "@nestjs/common";
|
||||
OAuthClientUsersService,
|
||||
PlatformBookingsService,
|
||||
CalendarsService,
|
||||
CalendarsCacheService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { CreationSource } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { NextApiRequest } from "next/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -58,6 +57,7 @@ import {
|
||||
getBookingForReschedule,
|
||||
ErrorCode,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { CreationSource } from "@calcom/platform-libraries";
|
||||
import {
|
||||
GetBookingsInput_2024_04_15,
|
||||
CancelBookingInput_2024_04_15,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
@@ -71,6 +72,7 @@ import { Module } from "@nestjs/common";
|
||||
ApiKeysRepository,
|
||||
PlatformBookingsService,
|
||||
CalendarsService,
|
||||
CalendarsCacheService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
|
||||
@@ -117,6 +117,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
slug: seatedEventTypeSlug,
|
||||
length: 60,
|
||||
seatsPerTimeSlot: 3,
|
||||
seatsShowAttendees: true,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
@@ -53,8 +53,10 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let apiKeyString: string;
|
||||
|
||||
let seatedEventTypeId: number;
|
||||
let seatedEventTypeIdAttendeesDisabledId: number;
|
||||
|
||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
const seatedEventSlugAttendeesDisabled = `seated-bookings-event-type-attendees-disabled-${randomString()}`;
|
||||
|
||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||
let createdSeatedBooking2: CreateSeatedBookingOutput_2024_08_13;
|
||||
@@ -121,6 +123,28 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
);
|
||||
seatedEventTypeId = seatedEvent.id;
|
||||
|
||||
const seatedEventAttendeesDisabled = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `seated-bookings-2024-08-13-event-type-attendees-disabled-${randomString()}`,
|
||||
slug: seatedEventSlugAttendeesDisabled,
|
||||
length: 60,
|
||||
seatsPerTimeSlot: 5,
|
||||
seatsShowAttendees: false,
|
||||
seatsShowAvailabilityCount: false,
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
metadata: {
|
||||
disableStandardEmails: {
|
||||
all: {
|
||||
attendee: true,
|
||||
host: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user.id
|
||||
);
|
||||
seatedEventTypeIdAttendeesDisabledId = seatedEventAttendeesDisabled.id;
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -414,6 +438,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should book an event type with attendees disabled", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
|
||||
attendee: {
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
|
||||
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.seatUid).toBeDefined();
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeIdAttendeesDisabledId,
|
||||
slug: seatedEventSlugAttendeesDisabled,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected seated booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel seated booking", () => {
|
||||
describe("cancel seated booking as attendee", () => {
|
||||
it("should cancel seated booking", async () => {
|
||||
|
||||
@@ -576,10 +576,13 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputRecurringBooking(booking);
|
||||
}
|
||||
if (isRecurring && isSeated) {
|
||||
return this.outputService.getOutputRecurringSeatedBooking(booking);
|
||||
return this.outputService.getOutputRecurringSeatedBooking(
|
||||
booking,
|
||||
!!booking.eventType?.seatsShowAttendees
|
||||
);
|
||||
}
|
||||
if (isSeated) {
|
||||
return this.outputService.getOutputSeatedBooking(booking);
|
||||
return this.outputService.getOutputSeatedBooking(booking, !!booking.eventType?.seatsShowAttendees);
|
||||
}
|
||||
return this.outputService.getOutputBooking(booking);
|
||||
}
|
||||
@@ -591,7 +594,10 @@ export class BookingsService_2024_08_13 {
|
||||
const ids = recurringBooking.map((booking) => booking.id);
|
||||
const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot;
|
||||
if (isRecurringSeated) {
|
||||
return this.outputService.getOutputRecurringSeatedBookings(ids);
|
||||
return this.outputService.getOutputRecurringSeatedBookings(
|
||||
ids,
|
||||
!!recurringBooking[0].eventType?.seatsShowAttendees
|
||||
);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
@@ -657,9 +663,19 @@ export class BookingsService_2024_08_13 {
|
||||
if (isRecurring && !isSeated) {
|
||||
formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted));
|
||||
} else if (isRecurring && isSeated) {
|
||||
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted));
|
||||
formattedBookings.push(
|
||||
this.outputService.getOutputRecurringSeatedBooking(
|
||||
formatted,
|
||||
!!formatted.eventType?.seatsShowAttendees
|
||||
)
|
||||
);
|
||||
} else if (isSeated) {
|
||||
formattedBookings.push(await this.outputService.getOutputSeatedBooking(formatted));
|
||||
formattedBookings.push(
|
||||
await this.outputService.getOutputSeatedBooking(
|
||||
formatted,
|
||||
!!formatted.eventType?.seatsShowAttendees
|
||||
)
|
||||
);
|
||||
} else {
|
||||
formattedBookings.push(await this.outputService.getOutputBooking(formatted));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { CreationSource } from "@prisma/client";
|
||||
import { isURL, isPhoneNumber } from "class-validator";
|
||||
import { Request } from "express";
|
||||
import { DateTime } from "luxon";
|
||||
@@ -30,6 +29,7 @@ import { NextApiRequest } from "next/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CreationSource } from "@calcom/platform-libraries";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CancelBookingInput,
|
||||
|
||||
@@ -68,6 +68,7 @@ type DatabaseBooking = Booking & {
|
||||
eventType: {
|
||||
id: number;
|
||||
slug: string;
|
||||
seatsShowAttendees?: boolean | null;
|
||||
} | null;
|
||||
attendees: {
|
||||
name: string;
|
||||
@@ -167,6 +168,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||
if (databaseMetadata === null) return {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { videoCallUrl, ...userDefinedMetadata } = databaseMetadata;
|
||||
|
||||
return userDefinedMetadata;
|
||||
@@ -266,11 +268,14 @@ export class OutputBookingsService_2024_08_13 {
|
||||
databaseBooking: DatabaseBooking,
|
||||
seatUid: string
|
||||
): Promise<CreateSeatedBookingOutput_2024_08_13> {
|
||||
const getSeatedBookingOutput = await this.getOutputSeatedBooking(databaseBooking);
|
||||
const getSeatedBookingOutput = await this.getOutputSeatedBooking(
|
||||
databaseBooking,
|
||||
!!databaseBooking.eventType?.seatsShowAttendees
|
||||
);
|
||||
return { ...getSeatedBookingOutput, seatUid };
|
||||
}
|
||||
|
||||
async getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
|
||||
async getOutputSeatedBooking(databaseBooking: DatabaseBooking, showAttendees: boolean) {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
@@ -317,41 +322,43 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
|
||||
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
|
||||
parsed.attendees = databaseBooking.attendees.map((attendee) => {
|
||||
const { responses } = safeParse(
|
||||
seatedBookingDataSchema,
|
||||
attendee.bookingSeat?.data,
|
||||
defaultSeatedBookingData,
|
||||
false
|
||||
);
|
||||
parsed.attendees = showAttendees
|
||||
? databaseBooking.attendees.map((attendee) => {
|
||||
const { responses } = safeParse(
|
||||
seatedBookingDataSchema,
|
||||
attendee.bookingSeat?.data,
|
||||
defaultSeatedBookingData,
|
||||
false
|
||||
);
|
||||
|
||||
const attendeeData = {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
absent: !!attendee.noShow,
|
||||
seatUid: attendee.bookingSeat?.referenceUid,
|
||||
bookingFieldsResponses: {},
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
attendeeParsed.metadata = safeParse(
|
||||
seatedBookingMetadataSchema,
|
||||
attendee.bookingSeat?.metadata,
|
||||
defaultSeatedBookingMetadata,
|
||||
false
|
||||
);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
const attendeeData = {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
absent: !!attendee.noShow,
|
||||
seatUid: attendee.bookingSeat?.referenceUid,
|
||||
bookingFieldsResponses: {},
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
attendeeParsed.metadata = safeParse(
|
||||
seatedBookingMetadataSchema,
|
||||
attendee.bookingSeat?.metadata,
|
||||
defaultSeatedBookingMetadata,
|
||||
false
|
||||
);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
|
||||
return attendeeParsed;
|
||||
});
|
||||
return attendeeParsed;
|
||||
})
|
||||
: [];
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async getOutputRecurringSeatedBookings(bookingsIds: number[]) {
|
||||
async getOutputRecurringSeatedBookings(bookingsIds: number[], showAttendees: boolean) {
|
||||
const transformed = [];
|
||||
|
||||
for (const bookingId of bookingsIds) {
|
||||
@@ -361,7 +368,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
throw new Error(`Booking with id=${bookingId} was not found in the database`);
|
||||
}
|
||||
|
||||
transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking));
|
||||
transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking, showAttendees));
|
||||
}
|
||||
|
||||
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
@@ -386,11 +393,14 @@ export class OutputBookingsService_2024_08_13 {
|
||||
databaseBooking: DatabaseBooking,
|
||||
seatUid: string
|
||||
): CreateRecurringSeatedBookingOutput_2024_08_13 {
|
||||
const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(databaseBooking);
|
||||
const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(
|
||||
databaseBooking,
|
||||
!!databaseBooking.eventType?.seatsShowAttendees
|
||||
);
|
||||
return { ...getRecurringSeatedBookingOutput, seatUid };
|
||||
}
|
||||
|
||||
getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking) {
|
||||
getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking, showAttendees: boolean) {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
@@ -430,35 +440,37 @@ export class OutputBookingsService_2024_08_13 {
|
||||
});
|
||||
|
||||
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
|
||||
parsed.attendees = databaseBooking.attendees.map((attendee) => {
|
||||
const { responses } = safeParse(
|
||||
seatedBookingDataSchema,
|
||||
attendee.bookingSeat?.data,
|
||||
defaultSeatedBookingData,
|
||||
false
|
||||
);
|
||||
parsed.attendees = showAttendees
|
||||
? databaseBooking.attendees.map((attendee) => {
|
||||
const { responses } = safeParse(
|
||||
seatedBookingDataSchema,
|
||||
attendee.bookingSeat?.data,
|
||||
defaultSeatedBookingData,
|
||||
false
|
||||
);
|
||||
|
||||
const attendeeData = {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
absent: !!attendee.noShow,
|
||||
seatUid: attendee.bookingSeat?.referenceUid,
|
||||
bookingFieldsResponses: {},
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
attendeeParsed.metadata = safeParse(
|
||||
seatedBookingMetadataSchema,
|
||||
attendee.bookingSeat?.metadata,
|
||||
defaultSeatedBookingMetadata,
|
||||
false
|
||||
);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
return attendeeParsed;
|
||||
});
|
||||
const attendeeData = {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
absent: !!attendee.noShow,
|
||||
seatUid: attendee.bookingSeat?.referenceUid,
|
||||
bookingFieldsResponses: {},
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
attendeeParsed.metadata = safeParse(
|
||||
seatedBookingMetadataSchema,
|
||||
attendee.bookingSeat?.metadata,
|
||||
defaultSeatedBookingMetadata,
|
||||
false
|
||||
);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
return attendeeParsed;
|
||||
})
|
||||
: [];
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
||||
@@ -10,16 +11,18 @@ import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule, TokensModule],
|
||||
imports: [PrismaModule, UsersModule, TokensModule, RedisModule],
|
||||
providers: [
|
||||
CredentialsRepository,
|
||||
CalendarsService,
|
||||
CalendarsCacheService,
|
||||
OutlookService,
|
||||
GoogleCalendarService,
|
||||
AppleCalendarService,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DeletedCalendarCredentialsOutputDto,
|
||||
} from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
||||
@@ -79,6 +80,7 @@ const calendarStateSchema = z.object({
|
||||
export class CalendarsController {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly calendarsCacheService: CalendarsCacheService,
|
||||
private readonly outlookService: OutlookService,
|
||||
private readonly googleCalendarService: GoogleCalendarService,
|
||||
private readonly appleCalendarService: AppleCalendarService,
|
||||
@@ -307,6 +309,8 @@ export class CalendarsController {
|
||||
credentialId
|
||||
);
|
||||
|
||||
this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(user.id);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import type { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/platform-libraries";
|
||||
|
||||
export const REDIS_CALENDARS_CACHE_KEY = (userId: number) => `apiv2:user:${userId}:calendars`;
|
||||
export const CALENDARS_CACHE_TTL_MS = 10_000;
|
||||
|
||||
type ConnectedDestinationCalendars = Awaited<
|
||||
ReturnType<typeof getConnectedDestinationCalendarsAndEnsureDefaultsInDb>
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsCacheService {
|
||||
constructor(private readonly redisService: RedisService) {}
|
||||
|
||||
async deleteConnectedAndDestinationCalendarsCache(userId: number) {
|
||||
await this.redisService.del(REDIS_CALENDARS_CACHE_KEY(userId));
|
||||
}
|
||||
|
||||
async getConnectedAndDestinationCalendarsCache(userId: number) {
|
||||
const cachedResult = await this.redisService.get<ConnectedDestinationCalendars>(
|
||||
REDIS_CALENDARS_CACHE_KEY(userId)
|
||||
);
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
async setConnectedAndDestinationCalendarsCache(
|
||||
userId: number,
|
||||
calendars: ConnectedDestinationCalendars
|
||||
): Promise<void> {
|
||||
await this.redisService.set<ConnectedDestinationCalendars>(REDIS_CALENDARS_CACHE_KEY(userId), calendars, {
|
||||
ttl: CALENDARS_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import {
|
||||
CredentialsRepository,
|
||||
@@ -37,7 +38,8 @@ export class CalendarsService {
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly calendarsRepository: CalendarsRepository,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly calendarsCacheService: CalendarsCacheService
|
||||
) {}
|
||||
|
||||
private buildNonDelegationCredentials<TCredential>(credentials: TCredential[]) {
|
||||
@@ -52,11 +54,17 @@ export class CalendarsService {
|
||||
}
|
||||
|
||||
async getCalendars(userId: number) {
|
||||
const cachedResult = await this.calendarsCacheService.getConnectedAndDestinationCalendarsCache(userId);
|
||||
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const userWithCalendars = await this.usersRepository.findByIdWithCalendars(userId);
|
||||
if (!userWithCalendars) {
|
||||
throw new NotFoundException("User not found");
|
||||
}
|
||||
return getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
|
||||
const result = await getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
|
||||
user: {
|
||||
...userWithCalendars,
|
||||
allSelectedCalendars: userWithCalendars.selectedCalendars,
|
||||
@@ -68,6 +76,10 @@ export class CalendarsService {
|
||||
eventTypeId: null,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
console.log("saving cache", JSON.stringify(result));
|
||||
await this.calendarsCacheService.setConnectedAndDestinationCalendarsCache(userId, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getBusyTimes(
|
||||
@@ -187,6 +199,8 @@ export class CalendarsService {
|
||||
userId,
|
||||
calendarType
|
||||
);
|
||||
|
||||
await this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(userId);
|
||||
}
|
||||
|
||||
async checkCalendarCredentialValidity(userId: number, credentialId: number, type: string) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ICSFeedCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { BadRequestException, UnauthorizedException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@@ -13,7 +15,9 @@ import { IcsFeedCalendarService } from "@calcom/platform-libraries/app-store";
|
||||
export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly credentialRepository: CredentialsRepository
|
||||
private readonly calendarsCacheService: CalendarsCacheService,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
private logger = new Logger("IcsFeedService");
|
||||
@@ -58,6 +62,9 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
data.key,
|
||||
userId
|
||||
);
|
||||
|
||||
await this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(userId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
|
||||
+1
-1
@@ -30,6 +30,7 @@ import {
|
||||
NoticeThresholdUnitEnum,
|
||||
FrequencyInput,
|
||||
} from "@calcom/platform-enums";
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
@@ -39,7 +40,6 @@ import {
|
||||
NotesDefaultFieldInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
||||
if (a.slug < b.slug) return -1;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesController_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/controllers/event-types.controller";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
@@ -11,6 +12,7 @@ import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
@@ -18,7 +20,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, RedisModule],
|
||||
providers: [
|
||||
EventTypesRepository_2024_06_14,
|
||||
EventTypesService_2024_06_14,
|
||||
@@ -29,6 +31,7 @@ import { Module } from "@nestjs/common";
|
||||
SchedulesRepository_2024_06_11,
|
||||
EventTypeResponseTransformPipe,
|
||||
CalendarsService,
|
||||
CalendarsCacheService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GcalController } from "@/ee/gcal/gcal.controller";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
@@ -6,6 +7,7 @@ import { GCalService } from "@/modules/apps/services/gcal.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -13,7 +15,7 @@ import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TokensModule, OAuthClientModule],
|
||||
imports: [PrismaModule, TokensModule, OAuthClientModule, RedisModule],
|
||||
providers: [
|
||||
AppsRepository,
|
||||
ConfigService,
|
||||
@@ -21,6 +23,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
SelectedCalendarsRepository,
|
||||
GCalService,
|
||||
CalendarsService,
|
||||
CalendarsCacheService,
|
||||
UsersRepository,
|
||||
CalendarsRepository,
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export const SYSTEM_ADMIN_ROLE = "SYSADMIN";
|
||||
export const ORG_ROLES = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export const MembershipRoles = Reflector.createDecorator<MembershipRole[]>();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationRolesGuard implements CanActivate {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
@@ -8,13 +9,14 @@ import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [TokensModule],
|
||||
imports: [TokensModule, RedisModule],
|
||||
providers: [
|
||||
GCalService,
|
||||
GoogleCalendarService,
|
||||
@@ -22,6 +24,7 @@ import { Module } from "@nestjs/common";
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CredentialsRepository,
|
||||
CalendarsService,
|
||||
CalendarsCacheService,
|
||||
SelectedCalendarsRepository,
|
||||
PrismaReadService,
|
||||
PrismaWriteService,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
@@ -6,15 +7,17 @@ import { DestinationCalendarsController } from "@/modules/destination-calendars/
|
||||
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
|
||||
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, RedisModule],
|
||||
providers: [
|
||||
CalendarsRepository,
|
||||
CalendarsService,
|
||||
CalendarsCacheService,
|
||||
DestinationCalendarsService,
|
||||
DestinationCalendarsRepository,
|
||||
UsersRepository,
|
||||
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
import { ConnectedCalendar, Calendar } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
@@ -7,6 +8,7 @@ import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
export class DestinationCalendarsService {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly calendarsCacheService: CalendarsCacheService,
|
||||
private readonly destinationCalendarsRepository: DestinationCalendarsRepository
|
||||
) {}
|
||||
|
||||
@@ -60,6 +62,7 @@ export class DestinationCalendarsService {
|
||||
delegatedCalendar ? delegationCredentialId : undefined
|
||||
);
|
||||
|
||||
this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(userId);
|
||||
return {
|
||||
userId: updatedCalendarUserId,
|
||||
integration: updatedCalendarIntegration,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class MembershipsRepository {
|
||||
|
||||
+2
-4
@@ -1,5 +1,4 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
@@ -7,7 +6,6 @@ import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { TOKENS_DOCS } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
|
||||
@@ -34,10 +32,10 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||
import { User, MembershipRole } from "@prisma/client";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/oauth-clients/:clientId/users",
|
||||
|
||||
+2
-1
@@ -17,10 +17,11 @@ import { OAuthClientWebhooksService } from "@/modules/webhooks/services/oauth-cl
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { Controller, Post, Body, UseGuards, Get, Param, Query, Delete, Patch } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Webhook, MembershipRole } from "@prisma/client";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
import { OAuthClientGuard } from "../../guards/oauth-client-guard";
|
||||
|
||||
+1
-3
@@ -5,12 +5,10 @@ import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-role
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.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";
|
||||
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
|
||||
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
|
||||
import { OAuthClientsService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
@@ -37,9 +35,9 @@ import {
|
||||
ApiExcludeEndpoint,
|
||||
ApiHeader,
|
||||
} from "@nestjs/swagger";
|
||||
import { User, MembershipRole } from "@prisma/client";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
import { CreateOAuthClientInput, UpdateOAuthClientInput, Pagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
@@ -49,6 +50,7 @@ import { JwtService } from "@nestjs/jwt";
|
||||
TokensRepository,
|
||||
OAuthFlowService,
|
||||
CalendarsService,
|
||||
CalendarsCacheService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
|
||||
@@ -8,9 +8,10 @@ import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-us
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ConflictException, Injectable, Logger } from "@nestjs/common";
|
||||
import { User, CreationSource, PlatformOAuthClient } from "@prisma/client";
|
||||
import { User, PlatformOAuthClient } from "@prisma/client";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
|
||||
import { CreationSource } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersService {
|
||||
|
||||
+2
-1
@@ -1,6 +1,5 @@
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { AttributeType } from "@prisma/client";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
@@ -12,6 +11,8 @@ import {
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { AttributeType } from "@calcom/platform-libraries";
|
||||
|
||||
export class CreateOrganizationAttributeInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { AttributeType } from "@prisma/client";
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
|
||||
|
||||
import { AttributeType } from "@calcom/platform-libraries";
|
||||
|
||||
export class UpdateOrganizationAttributeInput {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { SchedulingType, User } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ConfirmationPolicyEnum,
|
||||
NoticeThresholdUnitEnum,
|
||||
} from "@calcom/platform-enums";
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
|
||||
@@ -7,12 +7,12 @@ import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
HostPriority,
|
||||
} from "@calcom/platform-types";
|
||||
import { SchedulingType } from "@calcom/prisma/client";
|
||||
|
||||
export type TransformedCreateTeamEventTypeInput = Awaited<
|
||||
ReturnType<InstanceType<typeof InputOrganizationsEventTypesService>["transformInputCreateTeamEventType"]>
|
||||
|
||||
@@ -2,9 +2,17 @@ import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EventType, User, Schedule, Host, DestinationCalendar, CalVideoSettings } from "@prisma/client";
|
||||
import { SchedulingType, Team } from "@prisma/client";
|
||||
import type {
|
||||
Team,
|
||||
EventType,
|
||||
User,
|
||||
Schedule,
|
||||
Host,
|
||||
DestinationCalendar,
|
||||
CalVideoSettings,
|
||||
} from "@prisma/client";
|
||||
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
|
||||
|
||||
type EventTypeRelations = {
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export class CreateOrgMembershipDto {
|
||||
@IsInt()
|
||||
@ApiProperty()
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export class UpdateOrgMembershipDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
+1
-1
@@ -1,9 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
import { OrgTeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export class CreateOrgTeamMembershipDto {
|
||||
@IsInt()
|
||||
@ApiProperty()
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export class UpdateOrgTeamMembershipDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsString, IsOptional, IsBoolean, IsEnum } from "class-validator";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export class CreateOrganizationUserInput extends CreateUserInput {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
+2
-1
@@ -4,10 +4,11 @@ import { UpdateOrganizationUserInput } from "@/modules/organizations/users/index
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
|
||||
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
|
||||
import { Injectable, ConflictException, ForbiddenException } from "@nestjs/common";
|
||||
import { Team, CreationSource } from "@prisma/client";
|
||||
import { Team } from "@prisma/client";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries";
|
||||
import { CreationSource } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsUsersService {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
@@ -7,6 +8,7 @@ import { OrganizationsMembershipRepository } from "@/modules/organizations/membe
|
||||
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { SelectedCalendarsController } from "@/modules/selected-calendars/controllers/selected-calendars.controller";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { SelectedCalendarsService } from "@/modules/selected-calendars/services/selected-calendars.service";
|
||||
@@ -14,11 +16,12 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, RedisModule],
|
||||
providers: [
|
||||
SelectedCalendarsRepository,
|
||||
CalendarsRepository,
|
||||
CalendarsService,
|
||||
CalendarsCacheService,
|
||||
UsersRepository,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
@@ -23,6 +24,7 @@ type SelectedCalendarsInputDelegationCredential = SelectedCalendarsInputDto & {
|
||||
export class SelectedCalendarsService {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly calendarsCacheService: CalendarsCacheService,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly organizationsMembershipService: OrganizationsMembershipService,
|
||||
private readonly organizationsDelegationCredentialRepository: OrganizationsDelegationCredentialRepository
|
||||
@@ -50,6 +52,8 @@ export class SelectedCalendarsService {
|
||||
credentialId
|
||||
);
|
||||
|
||||
await this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(user.id);
|
||||
|
||||
return userSelectedCalendar;
|
||||
}
|
||||
|
||||
@@ -122,7 +126,7 @@ export class SelectedCalendarsService {
|
||||
externalId,
|
||||
delegationCredentialId
|
||||
);
|
||||
|
||||
await this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(user.id);
|
||||
return removedCalendarEntry;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export class CreateTeamMembershipInput {
|
||||
@IsInt()
|
||||
@ApiProperty({ type: Number })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export class UpdateTeamMembershipInput {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { Expose, Transform, Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsObject, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
class MembershipUserOutputDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { TeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { TeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, IsString, ValidateNested } from "class-validator";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { TeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
@@ -4,7 +4,8 @@ import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-us
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import type { Profile, User, Team, Prisma } from "@prisma/client";
|
||||
import { CreationSource } from "@prisma/client";
|
||||
|
||||
import { CreationSource } from "@calcom/platform-libraries";
|
||||
|
||||
export type UserWithProfile = User & {
|
||||
movedToProfile?: (Profile & { organization: Pick<Team, "isPlatform" | "id" | "slug" | "name"> }) | null;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||
import { WebhookTriggerEvents } from "@prisma/client";
|
||||
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
|
||||
|
||||
import { WebhookTriggerEvents } from "@calcom/platform-libraries";
|
||||
|
||||
export class CreateWebhookInputDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { WebhookTriggerEvents } from "@prisma/client";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsBoolean, IsEnum, IsInt, IsString, ValidateNested, IsArray } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { WebhookTriggerEvents } from "@calcom/platform-libraries";
|
||||
|
||||
export class WebhookOutputDto {
|
||||
@IsInt()
|
||||
|
||||
@@ -33,6 +33,11 @@ import {
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
BaseWorkflowTriggerDto,
|
||||
BEFORE_EVENT,
|
||||
BOOKING_NO_SHOW_UPDATED,
|
||||
BOOKING_PAID,
|
||||
BOOKING_PAYMENT_INITIATED,
|
||||
BOOKING_REJECTED,
|
||||
BOOKING_REQUESTED,
|
||||
EVENT_CANCELLED,
|
||||
NEW_EVENT,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
@@ -41,6 +46,11 @@ import {
|
||||
OnBeforeEventTriggerDto,
|
||||
OnCancelTriggerDto,
|
||||
OnCreationTriggerDto,
|
||||
OnNoShowUpdateTriggerDto,
|
||||
OnPaidTriggerDto,
|
||||
OnPaymentInitiatedTriggerDto,
|
||||
OnRejectedTriggerDto,
|
||||
OnRequestedTriggerDto,
|
||||
OnRescheduleTriggerDto,
|
||||
RESCHEDULE_EVENT,
|
||||
} from "./workflow-trigger.input";
|
||||
@@ -73,6 +83,11 @@ export type TriggerDtoType =
|
||||
| OnRescheduleTriggerDto
|
||||
| OnCancelTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnRejectedTriggerDto
|
||||
| OnRequestedTriggerDto
|
||||
| OnPaymentInitiatedTriggerDto
|
||||
| OnPaidTriggerDto
|
||||
| OnNoShowUpdateTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
@ApiExtraModels(
|
||||
@@ -81,6 +96,11 @@ export type TriggerDtoType =
|
||||
OnCancelTriggerDto,
|
||||
OnCreationTriggerDto,
|
||||
OnRescheduleTriggerDto,
|
||||
OnNoShowUpdateTriggerDto,
|
||||
OnRejectedTriggerDto,
|
||||
OnRequestedTriggerDto,
|
||||
OnPaymentInitiatedTriggerDto,
|
||||
OnPaidTriggerDto,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
OnAfterCalVideoHostsNoShowTriggerDto,
|
||||
WorkflowEmailAddressStepDto,
|
||||
@@ -112,6 +132,11 @@ export class CreateWorkflowDto {
|
||||
{ $ref: getSchemaPath(OnRescheduleTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnRejectedTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnRequestedTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnPaidTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnPaymentInitiatedTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnNoShowUpdateTriggerDto) },
|
||||
],
|
||||
})
|
||||
@ValidateNested()
|
||||
@@ -126,6 +151,11 @@ export class CreateWorkflowDto {
|
||||
{ value: OnRescheduleTriggerDto, name: RESCHEDULE_EVENT },
|
||||
{ value: OnAfterCalVideoGuestsNoShowTriggerDto, name: AFTER_GUESTS_CAL_VIDEO_NO_SHOW },
|
||||
{ value: OnAfterCalVideoHostsNoShowTriggerDto, name: AFTER_HOSTS_CAL_VIDEO_NO_SHOW },
|
||||
{ value: OnRequestedTriggerDto, name: BOOKING_REQUESTED },
|
||||
{ value: OnRejectedTriggerDto, name: BOOKING_REJECTED },
|
||||
{ value: OnPaymentInitiatedTriggerDto, name: BOOKING_PAYMENT_INITIATED },
|
||||
{ value: OnPaidTriggerDto, name: BOOKING_PAID },
|
||||
{ value: OnNoShowUpdateTriggerDto, name: BOOKING_NO_SHOW_UPDATED },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -135,6 +165,11 @@ export class CreateWorkflowDto {
|
||||
| OnCreationTriggerDto
|
||||
| OnRescheduleTriggerDto
|
||||
| OnCancelTriggerDto
|
||||
| OnRejectedTriggerDto
|
||||
| OnRequestedTriggerDto
|
||||
| OnPaidTriggerDto
|
||||
| OnPaymentInitiatedTriggerDto
|
||||
| OnNoShowUpdateTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
|
||||
@@ -36,6 +36,16 @@ import {
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
OnAfterCalVideoHostsNoShowTriggerDto,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
OnNoShowUpdateTriggerDto,
|
||||
OnRejectedTriggerDto,
|
||||
OnRequestedTriggerDto,
|
||||
OnPaymentInitiatedTriggerDto,
|
||||
OnPaidTriggerDto,
|
||||
BOOKING_REQUESTED,
|
||||
BOOKING_REJECTED,
|
||||
BOOKING_PAYMENT_INITIATED,
|
||||
BOOKING_PAID,
|
||||
BOOKING_NO_SHOW_UPDATED,
|
||||
} from "./workflow-trigger.input";
|
||||
|
||||
export type UpdateWorkflowStepDto =
|
||||
@@ -119,6 +129,11 @@ export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWha
|
||||
OnCancelTriggerDto,
|
||||
OnCreationTriggerDto,
|
||||
OnRescheduleTriggerDto,
|
||||
OnNoShowUpdateTriggerDto,
|
||||
OnRejectedTriggerDto,
|
||||
OnRequestedTriggerDto,
|
||||
OnPaymentInitiatedTriggerDto,
|
||||
OnPaidTriggerDto,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
OnAfterCalVideoHostsNoShowTriggerDto,
|
||||
UpdateEmailAddressWorkflowStepDto,
|
||||
@@ -155,6 +170,11 @@ export class UpdateWorkflowDto {
|
||||
{ $ref: getSchemaPath(OnRescheduleTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnRejectedTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnRequestedTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnPaidTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnPaymentInitiatedTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnNoShowUpdateTriggerDto) },
|
||||
],
|
||||
})
|
||||
@IsOptional()
|
||||
@@ -170,6 +190,11 @@ export class UpdateWorkflowDto {
|
||||
{ value: OnRescheduleTriggerDto, name: RESCHEDULE_EVENT },
|
||||
{ value: OnAfterCalVideoGuestsNoShowTriggerDto, name: AFTER_GUESTS_CAL_VIDEO_NO_SHOW },
|
||||
{ value: OnAfterCalVideoHostsNoShowTriggerDto, name: AFTER_HOSTS_CAL_VIDEO_NO_SHOW },
|
||||
{ value: OnRequestedTriggerDto, name: BOOKING_REQUESTED },
|
||||
{ value: OnRejectedTriggerDto, name: BOOKING_REJECTED },
|
||||
{ value: OnPaymentInitiatedTriggerDto, name: BOOKING_PAYMENT_INITIATED },
|
||||
{ value: OnPaidTriggerDto, name: BOOKING_PAID },
|
||||
{ value: OnNoShowUpdateTriggerDto, name: BOOKING_NO_SHOW_UPDATED },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -179,6 +204,11 @@ export class UpdateWorkflowDto {
|
||||
| OnCreationTriggerDto
|
||||
| OnRescheduleTriggerDto
|
||||
| OnCancelTriggerDto
|
||||
| OnRejectedTriggerDto
|
||||
| OnRequestedTriggerDto
|
||||
| OnPaidTriggerDto
|
||||
| OnPaymentInitiatedTriggerDto
|
||||
| OnNoShowUpdateTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { WorkflowActions, WorkflowTemplates } from "@prisma/client";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNumber, IsBoolean, IsString, ValidateNested, IsIn } from "class-validator";
|
||||
|
||||
import { WorkflowActions, WorkflowTemplates } from "@calcom/platform-libraries";
|
||||
|
||||
export const EMAIL_HOST = "email_host";
|
||||
export const EMAIL_ATTENDEE = "email_attendee";
|
||||
export const EMAIL_ADDRESS = "email_address";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { TimeUnit, WorkflowTriggerEvents } from "@prisma/client";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, IsNumber, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { TimeUnit, WorkflowTriggerEvents } from "@calcom/platform-libraries";
|
||||
|
||||
export const BEFORE_EVENT = "beforeEvent";
|
||||
export const EVENT_CANCELLED = "eventCancelled";
|
||||
export const NEW_EVENT = "newEvent";
|
||||
@@ -10,6 +11,11 @@ export const AFTER_EVENT = "afterEvent";
|
||||
export const RESCHEDULE_EVENT = "rescheduleEvent";
|
||||
export const AFTER_HOSTS_CAL_VIDEO_NO_SHOW = "afterHostsCalVideoNoShow";
|
||||
export const AFTER_GUESTS_CAL_VIDEO_NO_SHOW = "afterGuestsCalVideoNoShow";
|
||||
export const BOOKING_REJECTED = "bookingRejected";
|
||||
export const BOOKING_REQUESTED = "bookingRequested";
|
||||
export const BOOKING_PAYMENT_INITIATED = "bookingPaymentInitiated";
|
||||
export const BOOKING_PAID = "bookingPaid";
|
||||
export const BOOKING_NO_SHOW_UPDATED = "bookingNoShowUpdated";
|
||||
export const WORKFLOW_TRIGGER_TYPES = [
|
||||
BEFORE_EVENT,
|
||||
EVENT_CANCELLED,
|
||||
@@ -18,6 +24,11 @@ export const WORKFLOW_TRIGGER_TYPES = [
|
||||
RESCHEDULE_EVENT,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
BOOKING_REJECTED,
|
||||
BOOKING_REQUESTED,
|
||||
BOOKING_PAYMENT_INITIATED,
|
||||
BOOKING_PAID,
|
||||
BOOKING_NO_SHOW_UPDATED,
|
||||
] as const;
|
||||
|
||||
export const WORKFLOW_TRIGGER_TO_ENUM = {
|
||||
@@ -28,6 +39,11 @@ export const WORKFLOW_TRIGGER_TO_ENUM = {
|
||||
[RESCHEDULE_EVENT]: WorkflowTriggerEvents.RESCHEDULE_EVENT,
|
||||
[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
[AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
[BOOKING_REJECTED]: WorkflowTriggerEvents.BOOKING_REJECTED,
|
||||
[BOOKING_REQUESTED]: WorkflowTriggerEvents.BOOKING_REQUESTED,
|
||||
[BOOKING_PAYMENT_INITIATED]: WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
||||
[BOOKING_NO_SHOW_UPDATED]: WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED,
|
||||
[BOOKING_PAID]: WorkflowTriggerEvents.BOOKING_PAID,
|
||||
} as const;
|
||||
|
||||
export const ENUM_TO_WORKFLOW_TRIGGER = {
|
||||
@@ -38,6 +54,11 @@ export const ENUM_TO_WORKFLOW_TRIGGER = {
|
||||
[WorkflowTriggerEvents.RESCHEDULE_EVENT]: RESCHEDULE_EVENT,
|
||||
[WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
[WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
[WorkflowTriggerEvents.BOOKING_REJECTED]: BOOKING_REJECTED,
|
||||
[WorkflowTriggerEvents.BOOKING_REQUESTED]: BOOKING_REQUESTED,
|
||||
[WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED]: BOOKING_PAYMENT_INITIATED,
|
||||
[WorkflowTriggerEvents.BOOKING_PAID]: BOOKING_PAID,
|
||||
[WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED]: BOOKING_NO_SHOW_UPDATED,
|
||||
} as const;
|
||||
|
||||
export const HOUR = "hour";
|
||||
@@ -108,6 +129,41 @@ export class OnCancelTriggerDto {
|
||||
type: typeof EVENT_CANCELLED = EVENT_CANCELLED;
|
||||
}
|
||||
|
||||
export class OnRejectedTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
})
|
||||
type: typeof BOOKING_REJECTED = BOOKING_REJECTED;
|
||||
}
|
||||
|
||||
export class OnRequestedTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
})
|
||||
type: typeof BOOKING_REQUESTED = BOOKING_REQUESTED;
|
||||
}
|
||||
|
||||
export class OnPaymentInitiatedTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
})
|
||||
type: typeof BOOKING_PAYMENT_INITIATED = BOOKING_PAYMENT_INITIATED;
|
||||
}
|
||||
|
||||
export class OnPaidTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
})
|
||||
type: typeof BOOKING_PAID = BOOKING_PAID;
|
||||
}
|
||||
|
||||
export class OnNoShowUpdateTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
})
|
||||
type: typeof BOOKING_NO_SHOW_UPDATED = BOOKING_NO_SHOW_UPDATED;
|
||||
}
|
||||
|
||||
export class TriggerOffsetDTO {
|
||||
@ApiProperty({
|
||||
description: "Offset before/after the trigger time; required for BEFORE_EVENT and AFTER_EVENT only",
|
||||
|
||||
@@ -2,11 +2,12 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { TimeUnit, Workflow, WorkflowStep, WorkflowTriggerEvents } from "@prisma/client";
|
||||
|
||||
import { TimeUnit, WorkflowTriggerEvents } from "@calcom/platform-libraries";
|
||||
import { TUpdateInputSchema } from "@calcom/platform-libraries/workflows";
|
||||
import { updateWorkflow } from "@calcom/platform-libraries/workflows";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
import type { Workflow, WorkflowStep } from "@calcom/prisma/client";
|
||||
|
||||
export type WorkflowType = Workflow & { activeOn: { eventTypeId: number }[]; steps: WorkflowStep[] };
|
||||
|
||||
|
||||
@@ -22554,7 +22554,12 @@
|
||||
"afterEvent",
|
||||
"rescheduleEvent",
|
||||
"afterHostsCalVideoNoShow",
|
||||
"afterGuestsCalVideoNoShow"
|
||||
"afterGuestsCalVideoNoShow",
|
||||
"bookingRejected",
|
||||
"bookingRequested",
|
||||
"bookingPaymentInitiated",
|
||||
"bookingPaid",
|
||||
"bookingNoShowUpdated"
|
||||
]
|
||||
},
|
||||
"offset": {
|
||||
@@ -22923,6 +22928,71 @@
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"OnNoShowUpdateTriggerDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"default": "bookingNoShowUpdated",
|
||||
"description": "Trigger type for the workflow"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"OnRejectedTriggerDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"default": "bookingRejected",
|
||||
"description": "Trigger type for the workflow"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"OnRequestedTriggerDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"default": "bookingRequested",
|
||||
"description": "Trigger type for the workflow"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"OnPaymentInitiatedTriggerDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"default": "bookingPaymentInitiated",
|
||||
"description": "Trigger type for the workflow"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"OnPaidTriggerDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"default": "bookingPaid",
|
||||
"description": "Trigger type for the workflow"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"OnAfterCalVideoGuestsNoShowTriggerDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23568,7 +23638,12 @@
|
||||
"afterEvent",
|
||||
"rescheduleEvent",
|
||||
"afterHostsCalVideoNoShow",
|
||||
"afterGuestsCalVideoNoShow"
|
||||
"afterGuestsCalVideoNoShow",
|
||||
"bookingRejected",
|
||||
"bookingRequested",
|
||||
"bookingPaymentInitiated",
|
||||
"bookingPaid",
|
||||
"bookingNoShowUpdated"
|
||||
]
|
||||
],
|
||||
"description": "Trigger type for the workflow"
|
||||
@@ -23642,6 +23717,21 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnAfterCalVideoHostsNoShowTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnRejectedTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnRequestedTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnPaidTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnPaymentInitiatedTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnNoShowUpdateTriggerDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -24298,6 +24388,21 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnAfterCalVideoHostsNoShowTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnRejectedTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnRequestedTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnPaidTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnPaymentInitiatedTriggerDto"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/OnNoShowUpdateTriggerDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Membership, MembershipRole, Prisma, Team, User } from "@prisma/client";
|
||||
import { Membership, Prisma, Team, User } from "@prisma/client";
|
||||
|
||||
import { MembershipRole } from "@calcom/platform-libraries";
|
||||
|
||||
export class MembershipRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import Link from "next/link";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IdentityProvider } from "@calcom/prisma/client";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { PageProps as _PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { z } from "zod";
|
||||
|
||||
import { StripeService } from "@calcom/lib/server/service/stripe";
|
||||
|
||||
import VerifyPage from "~/auth/verify-view";
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -14,18 +12,10 @@ const querySchema = z.object({
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
|
||||
const p = { ...(await params), ...(await searchParams) };
|
||||
const { sessionId, stripeCustomerId } = querySchema.parse(p);
|
||||
|
||||
const data = await StripeService.getCheckoutSession({
|
||||
stripeCustomerId,
|
||||
checkoutSessionId: sessionId,
|
||||
});
|
||||
|
||||
const { hasPaymentFailed } = data;
|
||||
const { sessionId } = querySchema.parse(p);
|
||||
|
||||
return await _generateMetadata(
|
||||
() =>
|
||||
hasPaymentFailed ? "Your payment failed" : sessionId ? "Payment successful!" : `Verify your email`,
|
||||
() => (sessionId ? "Payment Page" : `Verify your email`),
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
+1
@@ -117,6 +117,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
{
|
||||
name: "admin_api",
|
||||
href: "https://cal.com/docs/enterprise-features/api/api-reference/bookings#admin-access",
|
||||
isExternalLink: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ const resolveEndpoint = (links: any) => {
|
||||
// to the correct API endpoints.
|
||||
// - viewer.me - 2 segment paths like this are for logged in requests
|
||||
// - viewer.public.i18n - 3 segments paths can be public or authed
|
||||
// - viewer.eventTypes.heavy.create - 4 segments paths for heavy sub-router
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (ctx: any) => {
|
||||
const parts = ctx.op.path.split(".");
|
||||
@@ -23,7 +24,11 @@ const resolveEndpoint = (links: any) => {
|
||||
if (parts.length == 2) {
|
||||
endpoint = parts[0] as keyof typeof links;
|
||||
path = parts[1];
|
||||
} else {
|
||||
} else if (parts.length >= 3 && parts[2] === "heavy") {
|
||||
endpoint = parts[1] + "/heavy" as keyof typeof links;
|
||||
path = parts[3];
|
||||
}
|
||||
else {
|
||||
endpoint = parts[1] as keyof typeof links;
|
||||
path = parts.splice(2, parts.length - 2).join(".");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/client";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
export async function GET() {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
|
||||
const res = NextResponse.json({ csrfToken: token });
|
||||
|
||||
// We need this cookie to be accessible from embeds where the booking flow is displayed within an iframe on a different origin.
|
||||
// For third‑party iframe contexts (embeds on other sites), browsers require SameSite=None and Secure to make the cookie available.
|
||||
// For local development on http://localhost we fall back to SameSite=Lax to avoid requiring https during development.
|
||||
const useSecureCookies = WEBAPP_URL.startsWith("https://");
|
||||
res.cookies.set("calcom.csrf_token", token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
secure: useSecureCookies,
|
||||
sameSite: useSecureCookies ? "none" : "lax",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { createHmac } from "crypto";
|
||||
import { headers } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { CardComponent } from "@lib/plain/card-components";
|
||||
|
||||
const customerCardDisplay = (
|
||||
name: string,
|
||||
email: string,
|
||||
id: string,
|
||||
username: string,
|
||||
timeZone: string,
|
||||
emailVerified: Date | null,
|
||||
twoFactorEnabled: boolean | null,
|
||||
identityProvider: string | null,
|
||||
lastActiveAt: Date | null,
|
||||
teamName: string | null,
|
||||
teamSlug: string | null,
|
||||
isOrganization: boolean | null,
|
||||
stripeCustomerId: string | null
|
||||
): Card => {
|
||||
return {
|
||||
key: "customer-card",
|
||||
timeToLiveSeconds: null,
|
||||
components: [
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
text: "Basic Information",
|
||||
textColor: "NORMAL",
|
||||
textSize: "L",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Name",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: name || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Email",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: email || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Username",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: username || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "User ID",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: id || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentDivider: {
|
||||
dividerSpacingSize: "M",
|
||||
},
|
||||
},
|
||||
// Account Settings Section Title
|
||||
{
|
||||
componentText: {
|
||||
text: "Account Settings",
|
||||
textColor: "NORMAL",
|
||||
textSize: "L",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Time Zone",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: timeZone || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Identity Provider",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: identityProvider || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Last Active At",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: lastActiveAt ? dayjs(lastActiveAt).fromNow() : "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentDivider: {
|
||||
dividerSpacingSize: "M",
|
||||
},
|
||||
},
|
||||
// Team Information Section Title
|
||||
{
|
||||
componentText: {
|
||||
text: "Team Information",
|
||||
textColor: "NORMAL",
|
||||
textSize: "L",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Team Name",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: teamName || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Team Slug",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: teamSlug || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Is Organization",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel: isOrganization ? "Yes" : "No",
|
||||
badgeColor: isOrganization ? "GREEN" : "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentDivider: {
|
||||
dividerSpacingSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
text: "Security & Billing",
|
||||
textColor: "NORMAL",
|
||||
textSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Stripe Customer ID",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: stripeCustomerId || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Email Verified?",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel: emailVerified === null ? "No" : emailVerified ? "Yes" : "No",
|
||||
badgeColor: emailVerified === null ? "RED" : emailVerified ? "GREEN" : "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Two Factor Enabled?",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel: twoFactorEnabled === null ? "No" : twoFactorEnabled ? "Yes" : "No",
|
||||
badgeColor: twoFactorEnabled === null ? "RED" : twoFactorEnabled ? "GREEN" : "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const Card = z.object({
|
||||
key: z.string(),
|
||||
timeToLiveSeconds: z.number().int().min(0).nullish().default(null),
|
||||
components: z.array(CardComponent).nullable(),
|
||||
});
|
||||
|
||||
type Card = z.infer<typeof Card>;
|
||||
|
||||
const cardExamples: ((
|
||||
name: string,
|
||||
email: string,
|
||||
id: string,
|
||||
username: string,
|
||||
timeZone: string,
|
||||
emailVerified: Date | null,
|
||||
twoFactorEnabled: boolean | null,
|
||||
identityProvider: string | null,
|
||||
lastActiveAt: Date | null,
|
||||
teamName: string | null,
|
||||
teamSlug: string | null,
|
||||
isOrganization: boolean | null,
|
||||
stripeCustomerId: string | null
|
||||
) => Card)[] = [customerCardDisplay];
|
||||
|
||||
const inputSchema = z.object({
|
||||
customer: z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
username: z.string().optional(),
|
||||
timeZone: timeZoneSchema.optional(),
|
||||
emailVerified: z.boolean().optional(),
|
||||
identityProvider: z.string().optional(),
|
||||
twoFactorEnabled: z.boolean().optional(),
|
||||
lastActiveAt: z.string().optional(),
|
||||
teamName: z.string().optional(),
|
||||
teamSlug: z.string().optional(),
|
||||
isOrganization: z.boolean().optional(),
|
||||
stripeCustomerId: z.string().optional(),
|
||||
}),
|
||||
cardKeys: z.array(z.string()),
|
||||
});
|
||||
|
||||
async function handler(request: NextRequest) {
|
||||
const headersList = await headers();
|
||||
const requestBody = await request.json();
|
||||
|
||||
// HMAC verification
|
||||
const incomingSignature = headersList.get("plain-request-signature");
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const expectedSignature = createHmac("sha-256", process.env.PLAIN_HMAC_SECRET_KEY!)
|
||||
.update(JSON.stringify(requestBody))
|
||||
.digest("hex");
|
||||
if (incomingSignature !== expectedSignature) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const { cardKeys, customer } = inputSchema.parse(requestBody);
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const user = await userRepo.findByEmail({ email: customer.email });
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({
|
||||
cards: [
|
||||
{
|
||||
key: "customer-card",
|
||||
timeToLiveSeconds: null,
|
||||
components: [
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
textSize: "L",
|
||||
textColor: "MUTED",
|
||||
text: "User does not exist!",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch team details including userId and team name
|
||||
const teamMemberships = await userRepo.findTeamsByUserId({ userId: user.id });
|
||||
const firstTeam = teamMemberships.teams[0] ?? null;
|
||||
|
||||
// Parse user metadata
|
||||
const parsedMetadata = userMetadata.parse(user.metadata);
|
||||
|
||||
const cards = await Promise.all(
|
||||
cardExamples.map(async (cardFn) => {
|
||||
return cardFn(
|
||||
user.name || "Unknown",
|
||||
user.email,
|
||||
user.id.toString(),
|
||||
user.username || "Unknown",
|
||||
user.timeZone,
|
||||
user.emailVerified,
|
||||
user.twoFactorEnabled,
|
||||
user.identityProvider,
|
||||
user.lastActiveAt,
|
||||
firstTeam?.name || "Unknown",
|
||||
firstTeam?.slug || "Unknown",
|
||||
firstTeam?.isOrganization || false,
|
||||
(user.metadata as { stripeCustomerId?: string })?.stripeCustomerId || "Unknown"
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const filteredCards = cards.filter((card) => {
|
||||
return cardKeys.length === 0 || cardKeys.includes(card.key);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
cards: filteredCards,
|
||||
user: {
|
||||
...user,
|
||||
metadata: parsedMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
@@ -1,88 +0,0 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { createHmac } from "crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const responseSchema = z.object({
|
||||
hash: z.string(),
|
||||
email: z.string().email(),
|
||||
shortName: z.string(),
|
||||
appId: z.string(),
|
||||
fullName: z.string(),
|
||||
chatAvatarUrl: z.string(),
|
||||
userTier: z.enum(["free", "teams", "enterprise"]),
|
||||
});
|
||||
|
||||
async function handler() {
|
||||
// Early return if Plain Chat is not enabled
|
||||
if (!IS_PLAIN_CHAT_ENABLED) {
|
||||
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized - No session email found" }, { status: 401 });
|
||||
}
|
||||
|
||||
const secret = process.env.PLAIN_CHAT_HMAC_SECRET_KEY;
|
||||
if (!secret) {
|
||||
return NextResponse.json({ error: "Missing Plain Chat secret" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get user's team membership info
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
teams: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
isOrganization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check if user is part of a team and determine tier
|
||||
let userTier = "free";
|
||||
|
||||
if (user?.teams.length) {
|
||||
const teamMemberships = user.teams;
|
||||
|
||||
const isEnterprise = teamMemberships.some((membership) => membership.team.isOrganization === true);
|
||||
const isTeams = user?.teams.length > 0;
|
||||
|
||||
userTier = isEnterprise ? "enterprise" : isTeams ? "teams" : "free";
|
||||
}
|
||||
|
||||
const hmac = createHmac("sha256", secret);
|
||||
hmac.update(session.user.email.toLowerCase().trim());
|
||||
const hash = hmac.digest("hex");
|
||||
|
||||
const shortName =
|
||||
(session.user.name?.split(" ")[0] || session.user.email).charAt(0).toUpperCase() +
|
||||
(session.user.name?.split(" ")[0] || session.user.email).slice(1) || "User";
|
||||
|
||||
const response = responseSchema.parse({
|
||||
hash,
|
||||
email: session.user.email || "user@example.com",
|
||||
shortName,
|
||||
appId: process.env.NEXT_PUBLIC_PLAIN_CHAT_ID,
|
||||
fullName: session.user.name || "User",
|
||||
chatAvatarUrl: session.user.avatarUrl || "",
|
||||
userTier,
|
||||
});
|
||||
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
@@ -1,358 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/constants", async () => {
|
||||
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
|
||||
return {
|
||||
...actual,
|
||||
IS_PRODUCTION: true,
|
||||
IS_PLAIN_CHAT_ENABLED: true,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn(() => ({ headers: {}, cookies: {} })),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(() => new Map()),
|
||||
cookies: vi.fn(() => ({ getAll: () => [] })),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: vi.fn((data, options) => ({
|
||||
json: () => Promise.resolve(data),
|
||||
status: options?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@lib/plain/plain", () => {
|
||||
const mockGetCustomerByEmail = vi.fn();
|
||||
const mockCreateThread = vi.fn();
|
||||
const mockUpsertPlainCustomer = vi.fn();
|
||||
|
||||
return {
|
||||
plain: {
|
||||
getCustomerByEmail: mockGetCustomerByEmail,
|
||||
createThread: mockCreateThread,
|
||||
},
|
||||
upsertPlainCustomer: mockUpsertPlainCustomer,
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetServerSession = vi.mocked(getServerSession);
|
||||
|
||||
describe("/api/support", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PLAIN_API_KEY = "test-api-key";
|
||||
});
|
||||
|
||||
it("should return 404 when Plain Chat is disabled", async () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("should return 401 when user is not authenticated", async () => {
|
||||
mockGetServerSession.mockResolvedValue(null);
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should return 500 for invalid form data", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("should return 500 when Plain API key is not configured", async () => {
|
||||
delete process.env.PLAIN_API_KEY;
|
||||
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("should successfully create customer and thread", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const { plain } = vi.mocked(await import("@lib/plain/plain"));
|
||||
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
|
||||
data: {
|
||||
id: "customer-123",
|
||||
__typename: "Customer",
|
||||
fullName: "Test User",
|
||||
shortName: "Test",
|
||||
externalId: "123",
|
||||
email: {
|
||||
email: "test@example.com",
|
||||
isVerified: true,
|
||||
verifiedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
},
|
||||
company: null,
|
||||
createdBy: { __typename: "SystemActor" as const, systemId: "system" },
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
markedAsSpamAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(plain.createThread).mockResolvedValue({
|
||||
data: {
|
||||
id: "thread-123",
|
||||
__typename: "Thread",
|
||||
externalId: null,
|
||||
title: "Test message",
|
||||
description: null,
|
||||
status: "Todo" as any,
|
||||
statusChangedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
statusDetail: null,
|
||||
customer: { id: "customer-123" },
|
||||
priority: 0,
|
||||
previewText: "Test message",
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toBeDefined();
|
||||
|
||||
expect(vi.mocked(plain.getCustomerByEmail)).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
expect(vi.mocked(plain.createThread)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle form submission with file attachments", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const { plain, upsertPlainCustomer } = vi.mocked(await import("@lib/plain/plain"));
|
||||
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
|
||||
data: null,
|
||||
});
|
||||
|
||||
vi.mocked(upsertPlainCustomer).mockResolvedValue({
|
||||
data: {
|
||||
result: "Created" as any,
|
||||
customer: {
|
||||
id: "customer-456",
|
||||
__typename: "Customer",
|
||||
fullName: "Test User",
|
||||
shortName: "Test",
|
||||
externalId: "123",
|
||||
email: {
|
||||
email: "test@example.com",
|
||||
isVerified: true,
|
||||
verifiedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
},
|
||||
company: null,
|
||||
createdBy: { __typename: "SystemActor" as const, systemId: "system" },
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
markedAsSpamAt: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(plain.createThread).mockResolvedValue({
|
||||
data: {
|
||||
id: "thread-456",
|
||||
__typename: "Thread",
|
||||
externalId: null,
|
||||
title: "Test message",
|
||||
description: null,
|
||||
status: "Todo" as any,
|
||||
statusChangedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
statusDetail: null,
|
||||
customer: { id: "customer-456" },
|
||||
priority: 0,
|
||||
previewText: "Test message",
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: ["attachment-123"],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toBeDefined();
|
||||
|
||||
expect(vi.mocked(plain.getCustomerByEmail)).toHaveBeenCalledWith({ email: "test@example.com" });
|
||||
expect(vi.mocked(upsertPlainCustomer)).toHaveBeenCalled();
|
||||
expect(vi.mocked(plain.createThread)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle Plain customer creation error", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const { plain, upsertPlainCustomer } = vi.mocked(await import("@lib/plain/plain"));
|
||||
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
|
||||
data: null,
|
||||
});
|
||||
|
||||
vi.mocked(upsertPlainCustomer).mockResolvedValue({
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: "Customer creation failed",
|
||||
},
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("should handle Plain thread creation error", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: { id: 123, email: "test@example.com" },
|
||||
});
|
||||
|
||||
const { plain } = vi.mocked(await import("@lib/plain/plain"));
|
||||
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
|
||||
data: {
|
||||
id: "customer-123",
|
||||
__typename: "Customer",
|
||||
fullName: "Test User",
|
||||
shortName: "Test",
|
||||
externalId: "123",
|
||||
email: {
|
||||
email: "test@example.com",
|
||||
isVerified: true,
|
||||
verifiedAt: {
|
||||
__typename: "DateTime",
|
||||
iso8601: "2025-01-01T00:00:00Z",
|
||||
unixTimestamp: "1735689600",
|
||||
},
|
||||
},
|
||||
company: null,
|
||||
createdBy: { __typename: "SystemActor" as const, systemId: "system" },
|
||||
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
|
||||
markedAsSpamAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(plain.createThread).mockResolvedValue({
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: "Thread creation failed",
|
||||
},
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/support", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||
import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: [`/api/support/conversation`] });
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const headersList = await headers();
|
||||
const cookiesList = await cookies();
|
||||
const legacyReq = buildLegacyRequest(headersList, cookiesList);
|
||||
|
||||
const session = await getServerSession({ req: legacyReq });
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 });
|
||||
}
|
||||
|
||||
const intercomApiKey = process.env.INTERCOM_API_TOKEN;
|
||||
if (!intercomApiKey) {
|
||||
return NextResponse.json({ error: "Intercom API key not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let contact: Contact;
|
||||
|
||||
const existingContact = await intercom.getContactByEmail(session.user.email);
|
||||
|
||||
if (existingContact.error) {
|
||||
return NextResponse.json(
|
||||
{ error: existingContact?.error ?? "Error fetching intercom contact for user" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = session;
|
||||
|
||||
if (!existingContact.data) {
|
||||
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
|
||||
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
|
||||
(sum, team) => sum + team.team.eventTypes.length,
|
||||
0
|
||||
);
|
||||
|
||||
const newContact = await intercom.createContact({
|
||||
email: session.user.email,
|
||||
external_id: session.user.id.toString(),
|
||||
name: session.user.name ?? session.user.email,
|
||||
type: "contact",
|
||||
custom_attributes: {
|
||||
user_name: user?.username,
|
||||
link: `${WEBSITE_URL}/${encodeURIComponent(user?.username ?? "")}`,
|
||||
admin_link: `${WEBAPP_URL}/settings/admin/users/${user?.id}/edit`,
|
||||
impersonate_user: `${WEBAPP_URL}/settings/admin/impersonation?username=${encodeURIComponent(
|
||||
user?.email ?? user?.username ?? ""
|
||||
)}`,
|
||||
locale: user?.locale,
|
||||
completed_onboarding: user?.completedOnboarding,
|
||||
is_logged_in: !!user,
|
||||
has_orgs_plan: !!user?.org,
|
||||
organization: user?.org?.slug,
|
||||
sum_of_bookings: additionalUserInfo?._count?.bookings,
|
||||
sum_of_calendars: additionalUserInfo?._count?.userLevelSelectedCalendars,
|
||||
sum_of_teams: additionalUserInfo?._count?.teams,
|
||||
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
|
||||
sum_of_team_event_types: sumOfTeamEventTypes,
|
||||
},
|
||||
});
|
||||
|
||||
if (newContact.error || !newContact.data) {
|
||||
return NextResponse.json(
|
||||
{ error: newContact.error ?? "Error creating contact from email" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
contact = newContact.data;
|
||||
} else {
|
||||
contact = existingContact.data;
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return NextResponse.json({ error: "No contactId found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { message } = await req.json();
|
||||
|
||||
if (!message.trim()) {
|
||||
return NextResponse.json({ error: "Cannot start a conversation without message" }, { status: 400 });
|
||||
}
|
||||
|
||||
const conversation = await intercom.createConversation({
|
||||
body: message,
|
||||
from: {
|
||||
id: contact.id,
|
||||
type: "user",
|
||||
},
|
||||
});
|
||||
|
||||
if (conversation.error) {
|
||||
return NextResponse.json(
|
||||
{ error: conversation.error ?? "Error creating conversation" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Created conversation in Intercom" }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
log.error(`Error creating Intercom conversation:`, safeStringify(err));
|
||||
return NextResponse.json({ error: "Unexpected error occurred" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { plain, upsertPlainCustomer } from "@lib/plain/plain";
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
message: z.string().min(1, "Message is required"),
|
||||
attachmentIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const log = logger.getSubLogger({ prefix: [`/api/support`] });
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!IS_PLAIN_CHAT_ENABLED) {
|
||||
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { message, attachmentIds } = contactFormSchema.parse(body);
|
||||
|
||||
const plainApiKey = process.env.PLAIN_API_KEY;
|
||||
if (!plainApiKey) {
|
||||
return NextResponse.json({ error: "Plain API key not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let plainCustomerId: string | null = null;
|
||||
|
||||
const plainCustomer = await plain.getCustomerByEmail({ email: session.user.email });
|
||||
|
||||
if (plainCustomer.data) {
|
||||
plainCustomerId = plainCustomer.data.id;
|
||||
} else {
|
||||
const { data, error } = await upsertPlainCustomer({
|
||||
email: session.user.email,
|
||||
id: session.user.id,
|
||||
name: session.user.name,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log.error(`Error submitting plain contact form: `, safeStringify(error));
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if (data) {
|
||||
plainCustomerId = data.customer.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!plainCustomerId) {
|
||||
return NextResponse.json({ message: "Plain customer not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data, error } = await plain.createThread({
|
||||
customerIdentifier: {
|
||||
customerId: plainCustomerId,
|
||||
},
|
||||
components: [
|
||||
{
|
||||
componentText: {
|
||||
text: message,
|
||||
},
|
||||
},
|
||||
],
|
||||
attachmentIds,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log.error("Error creating plain contact form thread: ", safeStringify(error));
|
||||
return NextResponse.json({ message: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (err) {
|
||||
log.error(`Error submitting plain contact form: `, safeStringify(err));
|
||||
return NextResponse.json({ message: "Unexpected error occured" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { AttachmentType } from "@team-plain/typescript-sdk";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { plain, upsertPlainCustomer } from "@lib/plain/plain";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["/api/support/upload"] });
|
||||
|
||||
/**
|
||||
* Returns a signed url from plain to upload the attachment
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!IS_PLAIN_CHAT_ENABLED) {
|
||||
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const name = searchParams.get("name");
|
||||
const size = searchParams.get("size");
|
||||
|
||||
if (!name || !size) {
|
||||
return NextResponse.json({ error: "Missing required parameters: name and size" }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 });
|
||||
}
|
||||
|
||||
let plainCustomerId: string | null = null;
|
||||
|
||||
const plainCustomer = await plain.getCustomerByEmail({
|
||||
email: session.user.email,
|
||||
});
|
||||
|
||||
if (plainCustomer.data) {
|
||||
plainCustomerId = plainCustomer.data.id;
|
||||
} else {
|
||||
const { data, error } = await upsertPlainCustomer({
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
id: session.user.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log.error("Error getting customer info: ", safeStringify(error));
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (data) {
|
||||
plainCustomerId = data.customer.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!plainCustomerId) {
|
||||
return NextResponse.json({ error: "Plain customer not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data, error } = await plain.createAttachmentUploadUrl({
|
||||
customerId: plainCustomerId,
|
||||
fileName: name,
|
||||
fileSizeBytes: parseInt(size),
|
||||
attachmentType: AttachmentType.CustomTimelineEntry,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
log.error(`Error getting signed url for attachment upload: `, safeStringify(error));
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
import { TrpcProvider } from "app/_trpc/trpc-provider";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import CacheProvider from "react-inlinesvg/provider";
|
||||
|
||||
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
|
||||
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import PlainChat from "@lib/plain/dynamicProvider";
|
||||
|
||||
type ProvidersProps = {
|
||||
isEmbed: boolean;
|
||||
@@ -18,13 +16,10 @@ type ProvidersProps = {
|
||||
};
|
||||
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const pathname = usePathname();
|
||||
const isOnboardingPage = pathname?.startsWith("/getting-started");
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<TrpcProvider>
|
||||
{!isBookingPage && !isOnboardingPage ? <PlainChat nonce={nonce} /> : null}
|
||||
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
|
||||
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
||||
<CacheProvider>
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useRouter } from "next/navigation";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { AppDependencyComponent } from "@calcom/app-store/AppDependencyComponent";
|
||||
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
|
||||
import { isRedirectApp } from "@calcom/app-store/_utils/redirectApps";
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
|
||||
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, Suspense } from "react";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
|
||||
import { DestinationCalendarSettingsWebWrapper } from "@calcom/atoms/destination-calendar/wrappers/DestinationCalendarSettingsWebWrapper";
|
||||
import { SelectedCalendarsSettingsWebWrapper } from "@calcom/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
|
||||
import AppListCard from "@calcom/features/apps/components/AppListCard";
|
||||
|
||||
@@ -10,7 +10,7 @@ import Locations from "@calcom/features/eventtypes/components/locations/Location
|
||||
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import type { SingleValueLocationOption } from "@calcom/features/form/components/LocationSelect";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { Skeleton, SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
|
||||
|
||||
@@ -6,10 +6,11 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { shouldChargeNoShowCancellationFee } from "@calcom/lib/payment/shouldChargeNoShowCancellationFee";
|
||||
import { collectPageParameters, telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, Select, TextArea } from "@calcom/ui/components/form";
|
||||
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
interface InternalNotePresetsSelectProps {
|
||||
@@ -76,6 +77,12 @@ type Props = {
|
||||
title?: string;
|
||||
uid?: string;
|
||||
id?: number;
|
||||
startTime: Date;
|
||||
payment?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
appId: string | null;
|
||||
} | null;
|
||||
};
|
||||
profile: {
|
||||
name: string | null;
|
||||
@@ -100,6 +107,7 @@ type Props = {
|
||||
};
|
||||
isHost: boolean;
|
||||
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
|
||||
eventTypeMetadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
@@ -112,13 +120,48 @@ export default function CancelBooking(props: Props) {
|
||||
seatReferenceUid,
|
||||
bookingCancelledEventProps,
|
||||
currentUserEmail,
|
||||
teamId,
|
||||
eventTypeMetadata,
|
||||
} = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
const [internalNote, setInternalNote] = useState<{ id: number; name: string } | null>(null);
|
||||
const [acknowledgeCancellationNoShowFee, setAcknowledgeCancellationNoShowFee] = useState(false);
|
||||
|
||||
const getAppMetadata = (appId: string): Record<string, unknown> | null => {
|
||||
if (!eventTypeMetadata?.apps || !appId) return null;
|
||||
const apps = eventTypeMetadata.apps as Record<string, unknown>;
|
||||
return (apps[appId] as Record<string, unknown>) || null;
|
||||
};
|
||||
|
||||
const timeValue = booking?.payment?.appId
|
||||
? (getAppMetadata(booking.payment.appId) as Record<string, unknown> | null)?.autoChargeNoShowFeeTimeValue
|
||||
: null;
|
||||
const timeUnit = booking?.payment?.appId
|
||||
? (getAppMetadata(booking.payment.appId) as Record<string, unknown> | null)?.autoChargeNoShowFeeTimeUnit
|
||||
: null;
|
||||
|
||||
const autoChargeNoShowFee = () => {
|
||||
if (props.isHost) return false; // Hosts/organizers are exempt
|
||||
|
||||
if (!booking?.startTime) return false;
|
||||
|
||||
if (!booking?.payment) return false;
|
||||
|
||||
return shouldChargeNoShowCancellationFee({
|
||||
eventTypeMetadata: eventTypeMetadata || null,
|
||||
booking,
|
||||
payment: booking.payment,
|
||||
});
|
||||
};
|
||||
|
||||
const cancellationNoShowFeeWarning = autoChargeNoShowFee();
|
||||
|
||||
const hostMissingCancellationReason =
|
||||
props.isHost &&
|
||||
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id));
|
||||
const cancellationNoShowFeeNotAcknowledged =
|
||||
!props.isHost && cancellationNoShowFeeWarning && !acknowledgeCancellationNoShowFee;
|
||||
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||
if (node !== null) {
|
||||
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
|
||||
@@ -187,6 +230,23 @@ export default function CancelBooking(props: Props) {
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{cancellationNoShowFeeWarning && booking?.payment && (
|
||||
<div>
|
||||
<div className="bg-attention mb-5 rounded-md p-3">
|
||||
<CheckboxField
|
||||
description={t("cancel_booking_acknowledge_no_show_fee", {
|
||||
timeValue,
|
||||
timeUnit,
|
||||
amount: booking.payment.amount / 100,
|
||||
formatParams: { amount: { currency: booking.payment.currency } },
|
||||
})}
|
||||
onChange={(e) => setAcknowledgeCancellationNoShowFee(e.target.checked)}
|
||||
descriptionClassName="text-info font-semibold"
|
||||
/>
|
||||
<p className="text-subtle ml-9 mt-2 text-sm">{t("contact_organizer")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||
<div className="ml-auto flex w-full space-x-4 ">
|
||||
<Button
|
||||
@@ -197,10 +257,7 @@ export default function CancelBooking(props: Props) {
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="confirm_cancel"
|
||||
disabled={
|
||||
props.isHost &&
|
||||
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id))
|
||||
}
|
||||
disabled={hostMissingCancellationReason || cancellationNoShowFeeNotAcknowledged}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { describe, expect, it, vi, beforeAll } from "vitest";
|
||||
|
||||
import * as shouldChargeModule from "@calcom/lib/payment/shouldChargeNoShowCancellationFee";
|
||||
|
||||
import CancelBooking from "../CancelBooking";
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
vi.mock("@calcom/trpc", () => ({
|
||||
trpc: {
|
||||
viewer: {
|
||||
bookings: {
|
||||
requestReschedule: {
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (key === "cancellation_fee_warning_cancel") {
|
||||
const opts = options as {
|
||||
time?: string;
|
||||
unit?: string;
|
||||
amount?: number;
|
||||
formatParams?: { amount?: { currency?: string } };
|
||||
};
|
||||
return `Cancelling within ${opts?.time} ${opts?.unit} will result in a ${opts?.amount} ${opts?.formatParams?.amount?.currency} cancellation fee being charged to your card.`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useLocale", () => ({
|
||||
useLocale: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (key === "cancel_booking_acknowledge_no_show_fee") {
|
||||
const opts = options as {
|
||||
timeValue?: number;
|
||||
timeUnit?: string;
|
||||
amount?: number;
|
||||
formatParams?: { amount?: { currency?: string } };
|
||||
};
|
||||
return `I acknowledge that cancelling within ${opts?.timeValue} ${opts?.timeUnit} will result in a ${opts?.amount} ${opts?.formatParams?.amount?.currency} cancellation fee being charged to my card.`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useTelemetry", () => ({
|
||||
useTelemetry: () => ({
|
||||
event: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/hooks/useRefreshData", () => ({
|
||||
useRefreshData: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/router", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
query: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/payment/shouldChargeNoShowCancellationFee", () => ({
|
||||
shouldChargeNoShowCancellationFee: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBookingWithCancellationFee = {
|
||||
uid: "test-booking-uid",
|
||||
title: "Test Meeting",
|
||||
id: 123,
|
||||
startTime: new Date(Date.now() + 30 * 60 * 1000),
|
||||
payment: {
|
||||
amount: 1000,
|
||||
currency: "usd",
|
||||
appId: "stripe",
|
||||
},
|
||||
};
|
||||
|
||||
const mockEventTypeMetadataWithFee = {
|
||||
apps: {
|
||||
stripe: {
|
||||
autoChargeNoShowFeeIfCancelled: true,
|
||||
autoChargeNoShowFeeTimeValue: 1,
|
||||
autoChargeNoShowFeeTimeUnit: "hours" as const,
|
||||
paymentOption: "HOLD" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockEventTypeMetadataWithoutFee = {
|
||||
apps: {
|
||||
stripe: {
|
||||
autoChargeNoShowFeeIfCancelled: true,
|
||||
autoChargeNoShowFeeTimeValue: 1,
|
||||
autoChargeNoShowFeeTimeUnit: "hours" as const,
|
||||
paymentOption: "HOLD" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
recurringEvent: null,
|
||||
setIsCancellationMode: vi.fn(),
|
||||
theme: "light",
|
||||
allRemainingBookings: false,
|
||||
seatReferenceUid: undefined,
|
||||
currentUserEmail: "test@example.com",
|
||||
bookingCancelledEventProps: {
|
||||
booking: {},
|
||||
organizer: {
|
||||
name: "Test Organizer",
|
||||
email: "organizer@example.com",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
eventType: {},
|
||||
},
|
||||
internalNotePresets: [],
|
||||
};
|
||||
|
||||
const mockBookingWithoutCancellationFee = {
|
||||
uid: "test-booking-uid-2",
|
||||
title: "Test Meeting 2",
|
||||
id: 124,
|
||||
startTime: new Date(Date.now() + 2 * 60 * 60 * 1000),
|
||||
payment: {
|
||||
amount: 1000,
|
||||
currency: "usd",
|
||||
appId: "stripe",
|
||||
},
|
||||
};
|
||||
|
||||
describe("CancelBooking Cancellation Fee Warning", () => {
|
||||
it("should show cancellation fee warning when booking is within time threshold", () => {
|
||||
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<CancelBooking
|
||||
booking={mockBookingWithCancellationFee}
|
||||
profile={{ name: "Test User", slug: "test-user" }}
|
||||
team={null}
|
||||
isHost={false}
|
||||
eventTypeMetadata={mockEventTypeMetadataWithFee}
|
||||
{...mockProps}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/I acknowledge that cancelling within 1 hours will result in a/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show cancellation fee warning when booking is outside time threshold", () => {
|
||||
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<CancelBooking
|
||||
booking={mockBookingWithoutCancellationFee}
|
||||
profile={{ name: "Test User", slug: "test-user" }}
|
||||
team={null}
|
||||
isHost={false}
|
||||
eventTypeMetadata={mockEventTypeMetadataWithoutFee}
|
||||
{...mockProps}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show cancellation fee warning when user is host", () => {
|
||||
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<CancelBooking
|
||||
booking={mockBookingWithCancellationFee}
|
||||
profile={{ name: "Test User", slug: "test-user" }}
|
||||
team={null}
|
||||
isHost={true}
|
||||
eventTypeMetadata={mockEventTypeMetadataWithFee}
|
||||
{...mockProps}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show cancellation fee warning when cancellation fee is disabled", () => {
|
||||
const eventTypeMetadataWithDisabledFee = {
|
||||
apps: {
|
||||
stripe: {
|
||||
autoChargeNoShowFeeIfCancelled: false,
|
||||
autoChargeNoShowFeeTimeValue: 1,
|
||||
autoChargeNoShowFeeTimeUnit: "hours" as const,
|
||||
paymentOption: "HOLD" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<CancelBooking
|
||||
booking={mockBookingWithCancellationFee}
|
||||
profile={{ name: "Test User", slug: "test-user" }}
|
||||
team={null}
|
||||
isHost={false}
|
||||
eventTypeMetadata={eventTypeMetadataWithDisabledFee}
|
||||
{...mockProps}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show cancellation fee warning when payment option is not HOLD", () => {
|
||||
const eventTypeMetadataWithOnBookingPayment = {
|
||||
apps: {
|
||||
stripe: {
|
||||
autoChargeNoShowFeeIfCancelled: true,
|
||||
autoChargeNoShowFeeTimeValue: 1,
|
||||
autoChargeNoShowFeeTimeUnit: "hours" as const,
|
||||
paymentOption: "ON_BOOKING" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<CancelBooking
|
||||
booking={mockBookingWithCancellationFee}
|
||||
profile={{ name: "Test User", slug: "test-user" }}
|
||||
team={null}
|
||||
isHost={false}
|
||||
eventTypeMetadata={eventTypeMetadataWithOnBookingPayment}
|
||||
{...mockProps}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -475,6 +475,34 @@ describe("Booking Actions", () => {
|
||||
expect(isActionDisabled("cancel", context)).toBe(true);
|
||||
});
|
||||
|
||||
it("should disable cancelling all past bookings", () => {
|
||||
const pastConfirmedContext = createMockContext({
|
||||
isBookingInPast: true,
|
||||
isPending: false,
|
||||
isConfirmed: true,
|
||||
});
|
||||
|
||||
const pastPendingContext = createMockContext({
|
||||
isBookingInPast: true,
|
||||
isPending: true,
|
||||
isConfirmed: false,
|
||||
});
|
||||
|
||||
// Current implementation blocks ALL past bookings
|
||||
expect(isActionDisabled("cancel", pastConfirmedContext)).toBe(true);
|
||||
expect(isActionDisabled("cancel", pastPendingContext)).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow cancelling future bookings when cancelling is not disabled", () => {
|
||||
const futureContext = createMockContext({
|
||||
isBookingInPast: false,
|
||||
isPending: true,
|
||||
isConfirmed: false,
|
||||
});
|
||||
|
||||
expect(isActionDisabled("cancel", futureContext)).toBe(false);
|
||||
});
|
||||
|
||||
it("should disable video actions for non-past bookings", () => {
|
||||
const context = createMockContext({ isBookingInPast: false });
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext
|
||||
case "reschedule_request":
|
||||
return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling;
|
||||
case "cancel":
|
||||
return isDisabledCancelling || (isBookingInPast && isPending && !isConfirmed);
|
||||
return isDisabledCancelling || isBookingInPast;
|
||||
case "view_recordings":
|
||||
return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation);
|
||||
case "meeting_session_details":
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/InstallAppButtonWithoutPlanCheck";
|
||||
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
|
||||
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
@@ -37,7 +37,7 @@ const UserProfile = ({ user }: UserProfileProps) => {
|
||||
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
|
||||
const utils = trpc.useUtils();
|
||||
const router = useRouter();
|
||||
const createEventType = trpc.viewer.eventTypes.create.useMutation();
|
||||
const createEventType = trpc.viewer.eventTypes.heavy.create.useMutation();
|
||||
const telemetry = useTelemetry();
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { FileUploader, type FileData } from "@calcom/ui/components/file-uploader";
|
||||
import { Label, TextArea } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
const PlainContactForm = ({ open, setIsOpen }: { open: boolean, setIsOpen: Dispatch<SetStateAction<boolean>> }) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
|
||||
const [data, setData] = useState<{
|
||||
message: string;
|
||||
attachmentIds: string[];
|
||||
}>({
|
||||
message: "",
|
||||
attachmentIds: [],
|
||||
});
|
||||
|
||||
const [uploads, setUploads] = useState<
|
||||
{
|
||||
attachmentId?: string;
|
||||
uploading: boolean;
|
||||
file: File;
|
||||
id: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const handleUpload = async (allFiles: FileData[], newFiles: FileData[], removedFiles: FileData[]) => {
|
||||
if (newFiles.length > 0) {
|
||||
const newFile = newFiles[0];
|
||||
setUploads((prev) => [...prev, { file: newFile.file, uploading: true, id: newFile.id }]);
|
||||
setIsUploadingImage(true);
|
||||
|
||||
const { file } = newFile;
|
||||
|
||||
const res = await fetch(`/api/support/upload?name=${file.name}&size=${file.size}`);
|
||||
if (!res.ok) {
|
||||
showToast("Error uploading attachment", "error");
|
||||
setIsUploadingImage(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
uploadFormUrl,
|
||||
uploadFormData,
|
||||
attachment: { id: attachmentId },
|
||||
} = await res.json();
|
||||
setIsUploadingImage(false);
|
||||
|
||||
const formData = new FormData();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uploadFormData.forEach(({ key, value }: any) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
formData.append("file", file);
|
||||
|
||||
const uploadRes = await fetch(uploadFormUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
showToast(`Failed while uploading file: ${uploadRes.text}`, "error");
|
||||
setIsUploadingImage(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploads((prev) =>
|
||||
prev.map((upload) => (upload.file === file ? { ...upload, uploading: false, attachmentId } : upload))
|
||||
);
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
attachmentIds: [...prev.attachmentIds, attachmentId],
|
||||
}));
|
||||
setIsUploadingImage(false);
|
||||
showToast("File uploaded successfully", "success");
|
||||
} else if (removedFiles.length > 0) {
|
||||
const removedFile = removedFiles[0];
|
||||
const file = uploads.find((upload) => upload.id === removedFile.id);
|
||||
if (!file) {
|
||||
console.warn("File not found in uploads: ", removedFile.id);
|
||||
return;
|
||||
}
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
attachmentIds: prev.attachmentIds.filter((id) => id !== file.attachmentId),
|
||||
}));
|
||||
setUploads((prev) => prev.filter((upload) => upload.id !== removedFile.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch("/api/support", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
showToast(errorData.message ?? "Failed to submit contact form", "error");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitted(true);
|
||||
setIsSubmitting(false);
|
||||
setData({
|
||||
message: "",
|
||||
attachmentIds: [],
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : "An error occurred", "error");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setIsSubmitted(false);
|
||||
setData({
|
||||
message: "",
|
||||
attachmentIds: [],
|
||||
});
|
||||
setUploads([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[1rem] right-[1rem] z-50">
|
||||
<Popover open={open} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-subtle text-emphasis flex h-12 w-12 items-center justify-center rounded-full border-none">
|
||||
<Icon name="message-circle" className="h-6 w-6" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
style={{ maxWidth: "450px", maxHeight: "650px" }}
|
||||
className="!bg-muted no-scrollbar mb-2 mr-8 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
|
||||
<div className="flex w-full justify-between">
|
||||
<p className="mb-5 text-lg font-semibold">Contact support</p>
|
||||
<Button
|
||||
color="minimal"
|
||||
variant="button"
|
||||
StartIcon="x"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isSubmitted ? (
|
||||
<div className="py-4 text-center">
|
||||
<h4 className="mb-2 text-lg font-medium ">Message Sent</h4>
|
||||
<p className="text-subtle mb-4 text-sm">
|
||||
Thank you for contacting us. We'll get back to you as soon as possible.
|
||||
</p>
|
||||
<Button color="primary" className="my-2" onClick={resetForm} variant="button" size="base">
|
||||
Send Another Message
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="message">Describe the issue</Label>
|
||||
<TextArea
|
||||
id="message"
|
||||
name="message"
|
||||
value={data.message}
|
||||
onChange={(e) => setData((prev) => ({ ...prev, message: e.target.value }))}
|
||||
placeholder="Please describe the issue you're facing, e.g. 'Busy slots are marked available', ..., etc."
|
||||
required
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Attachments (optional)</Label>
|
||||
<FileUploader
|
||||
id="contact-attachments"
|
||||
buttonMsg="Add Files"
|
||||
onFilesChange={handleUpload}
|
||||
acceptedFileTypes={["images", "videos"]}
|
||||
multiple={false}
|
||||
showFilesList
|
||||
maxFiles={5}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
disabled={isSubmitting || isUploadingImage}
|
||||
testId="contact-form-file-upload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full items-center">
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="button"
|
||||
type="submit"
|
||||
disabled={isSubmitting || isUploadingImage}
|
||||
className="w-full">
|
||||
<div className="flex w-full justify-center">
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center">
|
||||
<Icon name="loader" className="mr-2 h-4 w-4 animate-spin rounded-full" />
|
||||
Sending
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="send" className="mr-2 h-4 w-4" />
|
||||
Send Message
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlainContactForm;
|
||||
@@ -1,181 +0,0 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import PlainContactForm from "../PlainContactForm";
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ui/components/toast", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseSession = vi.mocked(useSession);
|
||||
const mockShowToast = vi.mocked(showToast);
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
describe("PlainContactForm", () => {
|
||||
let mockSetIsOpen: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSetIsOpen = vi.fn();
|
||||
|
||||
mockUseSession.mockReturnValue({
|
||||
data: {
|
||||
hasValidLicense: true,
|
||||
upId: "test-up-id",
|
||||
expires: "2025-12-31T23:59:59.999Z",
|
||||
user: {
|
||||
id: 123,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
status: "authenticated",
|
||||
update: vi.fn(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("renders the contact button when closed", () => {
|
||||
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens when the launcher button is clicked (calls setIsOpen(true))", () => {
|
||||
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockSetIsOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("shows the form when open=true", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByText("Contact support")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Describe the issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Attachments (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty form initially", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("closes when X button is clicked (calls setIsOpen(false))", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const closeButton = buttons.find((button) => button.querySelector('svg use[href="#x"]'));
|
||||
expect(closeButton).toBeDefined();
|
||||
|
||||
fireEvent.click(closeButton!);
|
||||
|
||||
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("handles form submission successfully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /send message/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Message Sent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/support", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: "Test message",
|
||||
attachmentIds: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state during submission", async () => {
|
||||
// never resolves → stays loading
|
||||
mockFetch.mockImplementation(() => new Promise((_resolve) => {}));
|
||||
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /send message/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(screen.getByText("Sending")).toBeInTheDocument();
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("resets form after successful submission", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /send message/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Message Sent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const sendAnotherButton = screen.getByRole("button", { name: /send another message/i });
|
||||
fireEvent.click(sendAnotherButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles missing user session", () => {
|
||||
mockUseSession.mockReturnValue({
|
||||
data: null,
|
||||
status: "unauthenticated",
|
||||
update: vi.fn(),
|
||||
} as any);
|
||||
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("requires message field", async () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
await expect(screen.getByLabelText("Describe the issue")).toHaveAttribute("required");
|
||||
});
|
||||
});
|
||||
@@ -1,327 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
|
||||
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import PlainContactForm from "./PlainContactForm";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Plain?: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
init: (config: any) => void;
|
||||
open: () => void;
|
||||
};
|
||||
plainScriptLoaded?: () => void;
|
||||
__PLAIN_CONFIG__?: PlainChatConfig;
|
||||
}
|
||||
}
|
||||
|
||||
interface PlainChatConfig {
|
||||
appId: string;
|
||||
customerDetails: {
|
||||
email: string;
|
||||
emailHash: string;
|
||||
fullName: string;
|
||||
shortName: string;
|
||||
chatAvatarUrl: string;
|
||||
};
|
||||
links: Array<{
|
||||
icon: string;
|
||||
text: string;
|
||||
url: string;
|
||||
}>;
|
||||
chatButtons: Array<{
|
||||
icon: string;
|
||||
text: string;
|
||||
threadDetails?: {
|
||||
labelTypeIds: Array<string>;
|
||||
tierIdentifier: {
|
||||
externalId: string;
|
||||
};
|
||||
};
|
||||
form?: {
|
||||
fields: Array<{
|
||||
type: string;
|
||||
placeholder: string;
|
||||
options: Array<{
|
||||
icon: string;
|
||||
text: string;
|
||||
threadDetails: {
|
||||
labelTypeIds: Array<string>;
|
||||
tierIdentifier: {
|
||||
externalId: string;
|
||||
};
|
||||
};
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
entryPoint: {
|
||||
type: string;
|
||||
};
|
||||
hideBranding: boolean;
|
||||
theme: string;
|
||||
style: {
|
||||
brandColor: string;
|
||||
launcherBackgroundColor: string;
|
||||
launcherIconColor: string;
|
||||
};
|
||||
position: {
|
||||
zIndex: string;
|
||||
bottom: string;
|
||||
right: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
? ({ nonce }: { nonce: string | undefined }) => {
|
||||
const [config, setConfig] = useState<PlainChatConfig | null>(null);
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const shouldOpenPlain = pathname === "/event-types" && searchParams?.has("openPlain");
|
||||
const userEmail = session?.user?.email;
|
||||
const isPaidUser = session?.user.belongsToActiveTeam || !!session?.user.org;
|
||||
|
||||
const isAppDomain = useMemo(() => {
|
||||
const restrictedPathsSet = new Set(
|
||||
(process.env.NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS?.split(",") || []).map((path) => path.trim())
|
||||
);
|
||||
|
||||
const pathSegments = pathname?.split("/").filter(Boolean) || [];
|
||||
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.location.origin === process.env.NEXT_PUBLIC_WEBAPP_URL &&
|
||||
!pathSegments.some((segment) => restrictedPathsSet.has(segment))
|
||||
);
|
||||
}, [pathname]);
|
||||
|
||||
const checkScreenSize = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const isSmall = window.innerWidth < 768;
|
||||
setIsSmallScreen(isSmall);
|
||||
|
||||
if (isSmall && window.Plain) {
|
||||
const plainElement = document.querySelector("#plain-container");
|
||||
plainElement?.remove();
|
||||
window.Plain = undefined;
|
||||
} else if (!isSmall && window.Plain === undefined) {
|
||||
window.plainScriptLoaded?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initConfig = useCallback(async () => {
|
||||
if (!userEmail) return;
|
||||
|
||||
// Check if Plain Chat is enabled
|
||||
if (!IS_PLAIN_CHAT_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/plain-hash", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to generate hash: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.hash || !data.email || !data.appId) {
|
||||
throw new Error("Missing required fields in API response");
|
||||
}
|
||||
|
||||
const plainChatConfig: PlainChatConfig = {
|
||||
appId: data.appId,
|
||||
customerDetails: {
|
||||
email: data.email,
|
||||
shortName: data.shortName,
|
||||
fullName: data.fullName,
|
||||
emailHash: data.hash,
|
||||
chatAvatarUrl: data.chatAvatarUrl,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
icon: "book",
|
||||
text: "Documentation",
|
||||
url: "https://cal.com/docs",
|
||||
},
|
||||
{
|
||||
icon: "chat",
|
||||
text: "Ask the community",
|
||||
url: "https://github.com/calcom/cal.com/discussions",
|
||||
},
|
||||
],
|
||||
chatButtons: [
|
||||
{
|
||||
icon: "bulb",
|
||||
text: "Send feedback",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Report an issue",
|
||||
form: {
|
||||
fields: [
|
||||
{
|
||||
type: "dropdown",
|
||||
placeholder: "Select severity...",
|
||||
options: [
|
||||
{
|
||||
icon: "support",
|
||||
text: "I'm unable to use the app",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "error",
|
||||
text: "Major functionality degraded",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "bug",
|
||||
text: "Minor annoyance",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
entryPoint: {
|
||||
type: "chat",
|
||||
},
|
||||
hideBranding: true,
|
||||
theme: "auto",
|
||||
style: {
|
||||
brandColor: "#FFFFFF",
|
||||
launcherBackgroundColor: "#262626",
|
||||
launcherIconColor: "#FFFFFF",
|
||||
},
|
||||
position: {
|
||||
zIndex: "1",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
},
|
||||
};
|
||||
|
||||
plainChatConfig.chatButtons.push({
|
||||
icon: "chat",
|
||||
text: "Ask a question",
|
||||
threadDetails: {
|
||||
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
|
||||
tierIdentifier: { externalId: data.userTier },
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
|
||||
window.__PLAIN_CONFIG__ = plainChatConfig;
|
||||
}
|
||||
|
||||
setConfig(plainChatConfig);
|
||||
|
||||
if (shouldOpenPlain) {
|
||||
const timer = setTimeout(() => {
|
||||
if (window.Plain) {
|
||||
window.Plain.open();
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Plain Chat:", error);
|
||||
}
|
||||
}, [userEmail, shouldOpenPlain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppDomain) return;
|
||||
|
||||
// Skip initialization if Plain Chat is not enabled
|
||||
if (!IS_PLAIN_CHAT_ENABLED) return;
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
if (isPaidUser) {
|
||||
initConfig();
|
||||
} else if (typeof window !== "undefined" && !window.Plain) {
|
||||
window.Plain = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
init: () => {},
|
||||
open: () => {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, [isAppDomain, checkScreenSize, initConfig, userEmail, isPaidUser]);
|
||||
|
||||
const plainChatScript = `
|
||||
window.plainScriptLoaded = function() {
|
||||
if (window.Plain && ${Boolean(config)}) {
|
||||
try {
|
||||
Plain.init(${config ? JSON.stringify(config) : null});
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Plain:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
if (!isAppDomain || isSmallScreen || typeof window === "undefined") return null;
|
||||
|
||||
if (!isPaidUser) {
|
||||
return <PlainContactForm open={isOpen} setIsOpen={setIsOpen} />;
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="plain-chat"
|
||||
src="https://chat.cdn-plain.com/index.js"
|
||||
strategy="afterInteractive"
|
||||
onLoad={() => window.plainScriptLoaded?.()}
|
||||
/>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="plain-chat-init"
|
||||
strategy="afterInteractive"
|
||||
dangerouslySetInnerHTML={{ __html: plainChatScript }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
: () => null;
|
||||
|
||||
export default PlainChat;
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getRecentImpersonations, type RecentImpersonation } from "@calcom/lib/recentImpersonations";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { PanelCard } from "@calcom/ui/components/card";
|
||||
import {
|
||||
TableNew,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@calcom/ui/components/table";
|
||||
|
||||
interface RecentImpersonationsListProps {
|
||||
onImpersonate?: (username: string) => void;
|
||||
}
|
||||
|
||||
export default function RecentImpersonationsList({ onImpersonate }: RecentImpersonationsListProps) {
|
||||
const { t } = useLocale();
|
||||
const [recentImpersonations] = useState<RecentImpersonation[]>(() => getRecentImpersonations());
|
||||
|
||||
const handleQuickImpersonate = (username: string) => {
|
||||
if (onImpersonate) {
|
||||
onImpersonate(username);
|
||||
} else {
|
||||
signIn("impersonation-auth", {
|
||||
username: username.toLowerCase(),
|
||||
callbackUrl: `${WEBAPP_URL}/event-types`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (recentImpersonations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelCard title={t("recent_impersonations")}>
|
||||
<div className="overflow-x-auto">
|
||||
<TableNew className="border-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40%]">User</TableHead>
|
||||
<TableHead>Impersonated At</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentImpersonations.map((impersonation) => (
|
||||
<TableRow key={impersonation.username}>
|
||||
<TableCell className="font-medium">{impersonation.username}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(impersonation.timestamp).toLocaleDateString()}{" "}
|
||||
{new Date(impersonation.timestamp).toLocaleTimeString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleQuickImpersonate(impersonation.username)}
|
||||
data-testid={`quick-impersonate-${impersonation.username}`}>
|
||||
{t("quick_impersonate")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</TableNew>
|
||||
</div>
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,16 @@ import { useCheckTeamBilling } from "@calcom/web/lib/hooks/settings/platform/bil
|
||||
import { usePlatformMe } from "./usePlatformMe";
|
||||
|
||||
export const useGetUserAttributes = () => {
|
||||
const { data: platformUser, isLoading: isPlatformUserLoading } = usePlatformMe();
|
||||
const { data: userBillingData, isFetching: isUserBillingDataLoading } = useCheckTeamBilling(
|
||||
platformUser?.organizationId,
|
||||
platformUser?.organization?.isPlatform ?? false
|
||||
);
|
||||
const {
|
||||
data: platformUser,
|
||||
isLoading: isPlatformUserLoading,
|
||||
refetch: refetchPlatformUser,
|
||||
} = usePlatformMe();
|
||||
const {
|
||||
data: userBillingData,
|
||||
isFetching: isUserBillingDataLoading,
|
||||
refetch: refetchTeamBilling,
|
||||
} = useCheckTeamBilling(platformUser?.organizationId, platformUser?.organization?.isPlatform ?? false);
|
||||
const isPlatformUser = platformUser?.organization?.isPlatform ?? false;
|
||||
const isPaidUser = userBillingData?.valid;
|
||||
const userOrgId = platformUser?.organizationId;
|
||||
@@ -19,5 +24,7 @@ export const useGetUserAttributes = () => {
|
||||
isPaidUser,
|
||||
userBillingData,
|
||||
userOrgId,
|
||||
refetchTeamBilling,
|
||||
refetchPlatformUser,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -137,7 +137,7 @@ export const OAuthClientForm = ({
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="plus"
|
||||
className="text-default mx-2 mb-2"
|
||||
className="text-default mx-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
append({ uri: "" });
|
||||
@@ -150,7 +150,7 @@ export const OAuthClientForm = ({
|
||||
color="destructive"
|
||||
variant="icon"
|
||||
StartIcon="trash"
|
||||
className="text-default mx-2 mb-2"
|
||||
className="text-default mx-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
@@ -119,7 +120,13 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
isThemeSupported={isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
{props.isBookingPage || isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEffect } from "react";
|
||||
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
@@ -271,6 +272,10 @@ function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const AppProviders = (props: AppPropsWithChildren) => {
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const _isBookingPage =
|
||||
(typeof props.Component.isBookingPage === "function"
|
||||
? props.Component.isBookingPage({ router: props.router })
|
||||
: props.Component.isBookingPage) || isBookingPage;
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
@@ -282,7 +287,13 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
{_isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
) : (
|
||||
<DynamicIntercomProvider>
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
</DynamicIntercomProvider>
|
||||
)}
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user