Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e6ad8be9a | ||
|
|
1aca18324d | ||
|
|
a8a3e48465 | ||
|
|
2e96600529 | ||
|
|
b9074aeb81 | ||
|
|
13add03479 | ||
|
|
bb2dcc51c6 | ||
|
|
8f82e78597 | ||
|
|
34d2a13080 | ||
|
|
6066ed206b | ||
|
|
d30d2792cd | ||
|
|
ba50268634 | ||
|
|
4ca78f46df | ||
|
|
3a7ca0246c | ||
|
|
2cc8b1c2af | ||
|
|
a2d0cbfe5b | ||
|
|
d65c0753ef | ||
|
|
76bd90b228 | ||
|
|
d908403a99 | ||
|
|
0fe775962f | ||
|
|
412a6992c7 | ||
|
|
cfd1992733 | ||
|
|
e86d6169ba | ||
|
|
a379bd2b52 | ||
|
|
1137047606 | ||
|
|
ad4cf0cc3a | ||
|
|
e7d2e0dc2f | ||
|
|
aa51218428 | ||
|
|
38bc5fb71e | ||
|
|
411516ddb9 | ||
|
|
c6c17ac388 | ||
|
|
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 |
@@ -318,3 +318,12 @@ dates.map((date) => dayjs.utc(date).add(1, "day").format());
|
||||
// ✅ Best - Use native Date when possible
|
||||
dates.map((date) => new Date(date.valueOf() + 24 * 60 * 60 * 1000));
|
||||
```
|
||||
|
||||
## Avoid using Dayjs if you don’t need to be strictly tz aware.
|
||||
|
||||
When doing logic like Dayjs.startOf(".."), you can instead use date-fns' `startOfMonth(dateObj)` / `endOfDay(dateObj)`;
|
||||
When doing logic that depends on Browser locale, use i18n.language (prefer to deconstruct) like: `const { i18n: { language } } = useLocale();`, in combination with built-in Intl.
|
||||
|
||||
Note that with Date, you’re dealing with System time, so it’s not suited to everywhere (such as in the Booker, where instead we’ll likely migrate to Temporal) - but in most cases the above are suitable.
|
||||
|
||||
The main reason for doing so is that Dayjs uses a useful, but highly risky plugin system, which has led us to create `@calcom/dayjs` - this is heavy however, because it pre-loads ALL plugins, including locale handling. It’s a non-ideal solution to a problem that unfortunately exists due to Dayjs.
|
||||
|
||||
+3
-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
|
||||
|
||||
@@ -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/*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
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
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { NextApiRequest } from "next";
|
||||
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { schemaCredentialPatchParams, schemaCredentialPatchBody } from "~/lib/validations/credential-sync";
|
||||
|
||||
@@ -71,7 +72,7 @@ async function handler(req: NextApiRequest) {
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
key,
|
||||
key: key as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -6,7 +6,8 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
|
||||
import { schemaCredentialPostBody, schemaCredentialPostParams } from "~/lib/validations/credential-sync";
|
||||
@@ -92,7 +93,7 @@ async function handler(req: NextApiRequest) {
|
||||
data: {
|
||||
userId,
|
||||
appId: appSlug,
|
||||
key,
|
||||
key: key as unknown as Prisma.InputJsonValue,
|
||||
type: appMetadata.type,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("POST /api/attendees", () => {
|
||||
|
||||
const userBooking = { id: 1 };
|
||||
|
||||
prismaMock.booking.findFirst.mockResolvedValue(userBooking);
|
||||
prismaMock.booking.findFirst.mockResolvedValue(userBooking as any);
|
||||
|
||||
const attendeeData = {
|
||||
id: 1,
|
||||
|
||||
@@ -25,7 +25,7 @@ beforeEach(() => {
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -20,7 +20,7 @@ beforeEach(() => {
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -30,12 +30,13 @@ beforeEach(() => {
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
} as any);
|
||||
(getAccessibleUsers as any).mockResolvedValue([userId]);
|
||||
(retrieveOrgScopedAccessibleUsers as any).mockResolvedValue([userId]);
|
||||
|
||||
prismaMock.membership.findMany.mockResolvedValue([
|
||||
{
|
||||
// @ts-expect-error Will be fixed by Prisma 6.7.0 upgrade mock changes - which uses vitest-mock-extended
|
||||
team: {
|
||||
id: 1,
|
||||
isOrganization: true,
|
||||
@@ -48,7 +49,7 @@ beforeEach(() => {
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
},
|
||||
]);
|
||||
] as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -11,9 +11,9 @@ import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBook
|
||||
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { buildBooking, buildEventType, buildWebhook, buildUser } from "@calcom/lib/test/builder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Booking } from "@calcom/prisma/client";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { CreationSource, BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import handler from "../../../pages/api/bookings/_post";
|
||||
|
||||
@@ -303,6 +303,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
profile: { organizationId: null },
|
||||
hosts: [],
|
||||
users: [buildUser()],
|
||||
@@ -340,6 +341,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
profile: { organizationId: null },
|
||||
hosts: [],
|
||||
users: [buildUser()],
|
||||
@@ -376,6 +378,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||
...buildEventType({ profileId: null }),
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
profile: { organizationId: null },
|
||||
hosts: [],
|
||||
users: [buildUser()],
|
||||
@@ -417,6 +420,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||
...buildEventType({ profileId: null, length: 15 }),
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
profile: { organizationId: null },
|
||||
hosts: [],
|
||||
users: [buildUser()],
|
||||
@@ -468,7 +472,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue({
|
||||
...originalBooking,
|
||||
status: "cancelled",
|
||||
status: "CANCELLED",
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
@@ -492,6 +496,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||
...buildEventType({ profileId: null, length: 15 }),
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
profile: { organizationId: null },
|
||||
hosts: [],
|
||||
users: [buildUser()],
|
||||
@@ -532,7 +537,7 @@ describe("POST /api/bookings", () => {
|
||||
const previousBooking = await prisma.booking.findUnique({
|
||||
where: { uid: "original-booking-uid" },
|
||||
});
|
||||
expect(previousBooking?.status).toBe("cancelled");
|
||||
expect(previousBooking?.status).toBe(BookingStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test("Creates source as api_v1", async () => {
|
||||
@@ -556,6 +561,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||
...buildEventType({ profileId: null, length: 15 }),
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
profile: { organizationId: null },
|
||||
hosts: [],
|
||||
users: [buildUser()],
|
||||
@@ -627,6 +633,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||
...buildEventType({ profileId: null, length: 15 }),
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
profile: { organizationId: null },
|
||||
hosts: [],
|
||||
users: [buildUser()],
|
||||
@@ -684,6 +691,7 @@ describe("POST /api/bookings", () => {
|
||||
|
||||
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
|
||||
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
profile: { organizationId: null },
|
||||
hosts: [],
|
||||
users: [buildUser()],
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("DELETE /api/event-types/[id]", () => {
|
||||
// Mocking team.findUnique
|
||||
prismaMock.team.findUnique.mockResolvedValue({
|
||||
id: teamId,
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
members: [
|
||||
{ userId: memberUser, role: MembershipRole.MEMBER, teamId: teamId },
|
||||
{ userId: adminUser, role: MembershipRole.ADMIN, teamId: teamId },
|
||||
|
||||
@@ -84,6 +84,7 @@ describe("GET /api/event-types/[id]", () => {
|
||||
|
||||
prismaMock.team.findFirst.mockResolvedValue({
|
||||
id: teamId,
|
||||
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
|
||||
members: [
|
||||
{
|
||||
userId,
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.344",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.350",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+215
-40
@@ -12,6 +12,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User, Schedule, EventType } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
@@ -88,11 +89,16 @@ describe("Event types Endpoints", () => {
|
||||
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let apiKeyString: string;
|
||||
let apiKeyOrgUser: string;
|
||||
|
||||
const userEmail = `event-types-2024-06-14-user-${randomString()}@api.com`;
|
||||
const falseTestEmail = `event-types-2024-06-14-false-user-${randomString()}@api.com`;
|
||||
const name = `event-types-2024-06-14-user-${randomString()}`;
|
||||
const username = name;
|
||||
let eventType: EventTypeOutput_2024_06_14;
|
||||
let hiddenEventType: EventTypeOutput_2024_06_14;
|
||||
let user: User;
|
||||
let orgUser: User;
|
||||
let falseTestUser: User;
|
||||
@@ -165,13 +171,10 @@ describe("Event types Endpoints", () => {
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaExceptionFilter, HttpExceptionFilter],
|
||||
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -198,12 +201,20 @@ describe("Event types Endpoints", () => {
|
||||
name,
|
||||
username,
|
||||
});
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
orgUser = await userRepositoryFixture.create({
|
||||
email: `event-types-2024-06-14-org-user-${randomString()}@example.com`,
|
||||
name: `event-types-2024-06-14-org-user-${randomString()}`,
|
||||
username: `event-types-2024-06-14-org-user-${randomString()}`,
|
||||
});
|
||||
const { keyString: orgUserApiKeyString } = await apiKeysRepositoryFixture.createApiKey(
|
||||
orgUser.id,
|
||||
null
|
||||
);
|
||||
apiKeyOrgUser = `cal_test_${orgUserApiKeyString}`;
|
||||
|
||||
profileRepositoryFixture.create({
|
||||
uid: `usr-${orgUser.id}`,
|
||||
@@ -334,6 +345,7 @@ describe("Event types Endpoints", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
});
|
||||
@@ -370,6 +382,7 @@ describe("Event types Endpoints", () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
|
||||
@@ -395,6 +408,7 @@ describe("Event types Endpoints", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
@@ -504,6 +518,7 @@ describe("Event types Endpoints", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
@@ -576,21 +591,52 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a hidden event type", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class hidden",
|
||||
slug: "coding-class-hidden",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.hidden).toEqual(body.hidden);
|
||||
expect(createdEventType.ownerId).toEqual(user.id);
|
||||
hiddenEventType = responseBody.data;
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${username}`)
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
expect(responseBody.data?.length).toEqual(2);
|
||||
|
||||
const fetchedEventType = responseBody.data?.[0];
|
||||
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
|
||||
const fetchedHiddenEventType = responseBody.data?.find((et) => et.id === hiddenEventType.id);
|
||||
|
||||
expect(fetchedEventType?.id).toEqual(eventType.id);
|
||||
expect(fetchedEventType?.title).toEqual(eventType.title);
|
||||
@@ -602,33 +648,149 @@ describe("Event types Endpoints", () => {
|
||||
eventType.bookingFields.sort(orderBySlug).filter((f) => ("hidden" in f ? !f?.hidden : true))
|
||||
);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
|
||||
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
||||
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
|
||||
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
|
||||
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
|
||||
expect(fetchedEventType.customName).toEqual(eventType.customName);
|
||||
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
|
||||
expect(fetchedEventType?.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
|
||||
expect(fetchedEventType?.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
||||
expect(fetchedEventType?.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType?.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType?.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType?.bookerLayouts).toEqual(eventType.bookerLayouts);
|
||||
expect(fetchedEventType?.confirmationPolicy).toEqual(eventType.confirmationPolicy);
|
||||
expect(fetchedEventType?.recurrence).toEqual(eventType.recurrence);
|
||||
expect(fetchedEventType?.customName).toEqual(eventType.customName);
|
||||
expect(fetchedEventType?.requiresBookerEmailVerification).toEqual(
|
||||
eventType.requiresBookerEmailVerification
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
expect(fetchedEventType?.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType?.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType?.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType?.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(fetchedEventType.color).toEqual(eventType.color);
|
||||
expect(fetchedEventType?.color).toEqual(eventType.color);
|
||||
expect(fetchedEventType?.hidden).toEqual(false);
|
||||
|
||||
expect(fetchedHiddenEventType?.id).toEqual(hiddenEventType.id);
|
||||
expect(fetchedHiddenEventType?.hidden).toEqual(true);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username should not return hidden event type if auth of non event type owner provided`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyOrgUser}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
|
||||
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
|
||||
|
||||
expect(fetchedEventType?.id).toEqual(eventType.id);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
expect(fetchedEventType?.hidden).toEqual(false);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username should not return hidden event type if no auth provided`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
|
||||
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
|
||||
|
||||
expect(fetchedEventType?.id).toEqual(eventType.id);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
expect(fetchedEventType?.hidden).toEqual(false);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and eventSlug`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}&eventSlug=${eventType.slug}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
|
||||
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
|
||||
|
||||
expect(fetchedEventType?.id).toEqual(eventType.id);
|
||||
expect(fetchedEventType?.title).toEqual(eventType.title);
|
||||
expect(fetchedEventType?.description).toEqual(eventType.description);
|
||||
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
|
||||
expect(fetchedEventType?.locations).toEqual(eventType.locations);
|
||||
expect(fetchedEventType?.bookingFields.sort(orderBySlug)).toEqual(
|
||||
eventType.bookingFields.sort(orderBySlug).filter((f) => ("hidden" in f ? !f?.hidden : true))
|
||||
);
|
||||
expect(fetchedEventType?.ownerId).toEqual(user.id);
|
||||
expect(fetchedEventType?.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
|
||||
expect(fetchedEventType?.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
|
||||
expect(fetchedEventType?.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
|
||||
expect(fetchedEventType?.offsetStart).toEqual(eventType.offsetStart);
|
||||
expect(fetchedEventType?.bookingWindow).toEqual(eventType.bookingWindow);
|
||||
expect(fetchedEventType?.bookerLayouts).toEqual(eventType.bookerLayouts);
|
||||
expect(fetchedEventType?.confirmationPolicy).toEqual(eventType.confirmationPolicy);
|
||||
expect(fetchedEventType?.recurrence).toEqual(eventType.recurrence);
|
||||
expect(fetchedEventType?.customName).toEqual(eventType.customName);
|
||||
expect(fetchedEventType?.requiresBookerEmailVerification).toEqual(
|
||||
eventType.requiresBookerEmailVerification
|
||||
);
|
||||
expect(fetchedEventType?.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType?.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType?.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType?.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(fetchedEventType?.color).toEqual(eventType.color);
|
||||
expect(fetchedEventType?.hidden).toEqual(false);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and eventSlug should not return hidden event type if auth of non event type owner provided`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}&eventSlug=${hiddenEventType.slug}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyOrgUser}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(0);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and eventSlug should not return hidden event type if no auth provided`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${user.username}&eventSlug=${hiddenEventType.slug}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(0);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and orgSlug`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
@@ -647,8 +809,7 @@ describe("Event types Endpoints", () => {
|
||||
`/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}&eventSlug=${orgUserEventType1.slug}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
@@ -663,8 +824,7 @@ describe("Event types Endpoints", () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${orgUser.username}&orgId=${organization.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
@@ -705,6 +865,7 @@ describe("Event types Endpoints", () => {
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
@@ -732,6 +893,7 @@ describe("Event types Endpoints", () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
@@ -748,6 +910,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send({
|
||||
seats: {
|
||||
@@ -779,6 +942,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
@@ -799,6 +963,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
@@ -817,6 +982,7 @@ describe("Event types Endpoints", () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send({
|
||||
seats: {
|
||||
seatsPerTimeSlot: 4,
|
||||
@@ -844,6 +1010,7 @@ describe("Event types Endpoints", () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
|
||||
@@ -860,6 +1027,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send({
|
||||
locations: [
|
||||
@@ -897,6 +1065,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
@@ -918,6 +1087,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201);
|
||||
@@ -935,6 +1105,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventType.id}`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send({
|
||||
confirmationPolicy: {
|
||||
@@ -1034,6 +1205,7 @@ describe("Event types Endpoints", () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -1135,6 +1307,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(404);
|
||||
@@ -1152,6 +1325,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
@@ -1161,8 +1335,7 @@ describe("Event types Endpoints", () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
@@ -1203,8 +1376,7 @@ describe("Event types Endpoints", () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.slug}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
@@ -1245,13 +1417,16 @@ describe("Event types Endpoints", () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/1000`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should delete event type", async () => {
|
||||
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
+28
-24
@@ -6,11 +6,22 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
|
||||
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import {
|
||||
API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import {
|
||||
AuthOptionalUser,
|
||||
GetOptionalUser,
|
||||
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
@@ -40,7 +51,6 @@ import {
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
GetEventTypesQuery_2024_06_14,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
@@ -62,7 +72,8 @@ export class EventTypesController_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService_2024_06_14,
|
||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe
|
||||
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe,
|
||||
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -109,30 +120,23 @@ export class EventTypesController_2024_06_14 {
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all event types" })
|
||||
@ApiOperation({
|
||||
summary: "Get all event types",
|
||||
description:
|
||||
"Hidden event types are returned only if authentication is provided and it belongs to the event type owner.",
|
||||
})
|
||||
@UseGuards(OptionalApiAuthGuard)
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
async getEventTypes(
|
||||
@Query() queryParams: GetEventTypesQuery_2024_06_14
|
||||
@Query() queryParams: GetEventTypesQuery_2024_06_14,
|
||||
@GetOptionalUser() authUser: AuthOptionalUser
|
||||
): Promise<GetEventTypesOutput_2024_06_14> {
|
||||
const eventTypes = await this.eventTypesService.getEventTypes(queryParams);
|
||||
if (!eventTypes || eventTypes.length === 0) {
|
||||
throw new NotFoundException(`Event types not found`);
|
||||
}
|
||||
const eventTypes = await this.eventTypesService.getEventTypes(queryParams, authUser);
|
||||
const eventTypesFormatted = this.eventTypeResponseTransformPipe.transform(eventTypes);
|
||||
const eventTypesWithoutHiddenFields = eventTypesFormatted.map((eventType) => {
|
||||
return {
|
||||
...eventType,
|
||||
bookingFields: Array.isArray(eventType?.bookingFields)
|
||||
? eventType?.bookingFields
|
||||
.map((field) => {
|
||||
if ("hidden" in field) {
|
||||
return field.hidden !== true ? field : null;
|
||||
}
|
||||
return field;
|
||||
})
|
||||
.filter((f) => f)
|
||||
: [],
|
||||
};
|
||||
}) as EventTypeOutput_2024_06_14[];
|
||||
const eventTypesWithoutHiddenFields =
|
||||
this.outputEventTypesService.getResponseEventTypesWithoutHiddenFields(eventTypesFormatted);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -75,6 +75,16 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypesPublic(userId: number) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
userId,
|
||||
hidden: false,
|
||||
},
|
||||
include: { users: true, schedule: true, destinationCalendar: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeById(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
|
||||
+47
-13
@@ -3,6 +3,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
|
||||
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
|
||||
import { SystemField, CustomField } from "@/ee/event-types/event-types_2024_06_14/transformers";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
@@ -38,6 +39,7 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
await this.checkCanCreateEventType(user.id, body);
|
||||
const eventTypeUser = await this.getUserToCreateEvent(user);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { destinationCalendar: _destinationCalendar, ...rest } = body;
|
||||
|
||||
const { eventType: eventTypeCreated } = await createEventType({
|
||||
@@ -93,34 +95,47 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
}
|
||||
|
||||
async getEventTypeByUsernameAndSlug(
|
||||
username: string,
|
||||
eventTypeSlug: string,
|
||||
orgSlug?: string,
|
||||
orgId?: number
|
||||
) {
|
||||
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
|
||||
async getEventTypeByUsernameAndSlug(params: {
|
||||
username: string;
|
||||
eventTypeSlug: string;
|
||||
orgSlug?: string;
|
||||
orgId?: number;
|
||||
authUser?: AuthOptionalUser;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByUsername(params.username, params.orgSlug, params.orgId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventType = await this.eventTypesRepository.getUserEventTypeBySlug(user.id, eventTypeSlug);
|
||||
const eventType = await this.eventTypesRepository.getUserEventTypeBySlug(user.id, params.eventTypeSlug);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (eventType.hidden && params.authUser?.id !== user.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
ownerId: user.id,
|
||||
...eventType,
|
||||
};
|
||||
}
|
||||
|
||||
async getEventTypesByUsername(username: string, orgSlug?: string, orgId?: number) {
|
||||
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
|
||||
async getEventTypesByUsername(params: {
|
||||
username: string;
|
||||
orgSlug?: string;
|
||||
orgId?: number;
|
||||
authUser?: AuthOptionalUser;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByUsername(params.username, params.orgSlug, params.orgId);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
if (params.authUser?.id !== user.id) {
|
||||
return await this.getUserEventTypesPublic(user.id);
|
||||
}
|
||||
return await this.getUserEventTypes(user.id);
|
||||
}
|
||||
|
||||
@@ -172,6 +187,14 @@ export class EventTypesService_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypesPublic(userId: number) {
|
||||
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId);
|
||||
|
||||
return eventTypes.map((eventType) => {
|
||||
return { ownerId: userId, ...eventType };
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
if (!user) {
|
||||
@@ -181,15 +204,26 @@ export class EventTypesService_2024_06_14 {
|
||||
return await getEventTypesPublic(user.id);
|
||||
}
|
||||
|
||||
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14) {
|
||||
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14, authUser?: AuthOptionalUser) {
|
||||
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
|
||||
if (username && eventSlug) {
|
||||
const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug, orgSlug, orgId);
|
||||
const eventType = await this.getEventTypeByUsernameAndSlug({
|
||||
username,
|
||||
eventTypeSlug: eventSlug,
|
||||
orgSlug,
|
||||
orgId,
|
||||
authUser,
|
||||
});
|
||||
return eventType ? [eventType] : [];
|
||||
}
|
||||
|
||||
if (username) {
|
||||
return await this.getEventTypesByUsername(username, orgSlug, orgId);
|
||||
return await this.getEventTypesByUsername({
|
||||
username,
|
||||
orgSlug,
|
||||
orgId,
|
||||
authUser,
|
||||
});
|
||||
}
|
||||
|
||||
if (usernames) {
|
||||
|
||||
+24
-1
@@ -31,6 +31,7 @@ import {
|
||||
EventTypeOutput_2024_06_14,
|
||||
OutputUnknownLocation_2024_06_14,
|
||||
OutputUnknownBookingField_2024_06_14,
|
||||
OutputBookingField_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
type EventTypeRelations = {
|
||||
@@ -253,7 +254,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
type: "unknown",
|
||||
slug: "unknown",
|
||||
bookingField: JSON.stringify(bookingField),
|
||||
});
|
||||
} satisfies OutputUnknownBookingField_2024_06_14);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,4 +345,26 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
seatsShowAvailabilityCount: !!seatsShowAvailabilityCount,
|
||||
});
|
||||
}
|
||||
|
||||
getResponseEventTypesWithoutHiddenFields(
|
||||
eventTypes: EventTypeOutput_2024_06_14[]
|
||||
): EventTypeOutput_2024_06_14[] {
|
||||
return eventTypes.map((eventType) => this.getResponseEventTypeWithoutHiddenFields(eventType));
|
||||
}
|
||||
|
||||
getResponseEventTypeWithoutHiddenFields(eventType: EventTypeOutput_2024_06_14): EventTypeOutput_2024_06_14 {
|
||||
if (!Array.isArray(eventType?.bookingFields) || eventType.bookingFields.length === 0) return eventType;
|
||||
|
||||
const visibleBookingFields: OutputBookingField_2024_06_14[] = [];
|
||||
for (const bookingField of eventType.bookingFields) {
|
||||
if ("hidden" in bookingField && bookingField.hidden === true) {
|
||||
continue;
|
||||
}
|
||||
visibleBookingFields.push(bookingField);
|
||||
}
|
||||
return {
|
||||
...eventType,
|
||||
bookingFields: visibleBookingFields,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
@@ -36,6 +37,7 @@ import { Module } from "@nestjs/common";
|
||||
SchedulesAtomsService,
|
||||
VerificationAtomsService,
|
||||
RedisService,
|
||||
TeamsRepository,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
controllers: [
|
||||
|
||||
@@ -64,4 +64,47 @@ export class AtomsRepository {
|
||||
|
||||
return userTeams;
|
||||
}
|
||||
|
||||
async getSecondaryEmails(userId: number) {
|
||||
return await this.dbRead.prisma.secondaryEmail.findMany({
|
||||
where: {
|
||||
userId,
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
userId_email: { userId, email },
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async getExistingSecondaryEmail(email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
|
||||
async addSecondaryEmail(userId: number, email: string) {
|
||||
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
|
||||
data: {
|
||||
userId,
|
||||
email,
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return existingSecondaryEmailRecord?.email;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { AddVerifiedEmailInput } from "@/modules/atoms/inputs/add-verified-email.input";
|
||||
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
||||
import { GetVerifiedEmailsParams } from "@/modules/atoms/inputs/get-verified-emails-params";
|
||||
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
||||
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
||||
import { GetVerifiedEmailsOutput } from "@/modules/atoms/outputs/get-verified-emails-output";
|
||||
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
|
||||
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
|
||||
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
|
||||
@@ -105,4 +108,44 @@ export class AtomsVerificationController {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/emails/verified-emails")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getVerifiedEmails(
|
||||
@Query() query: GetVerifiedEmailsParams,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetVerifiedEmailsOutput> {
|
||||
const verifiedEmails = await this.verificationService.getVerifiedEmails({
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
teamId: query.teamId ? Number(query.teamId) : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
data: verifiedEmails,
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/emails/verified-emails")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async addVerifiedEmails(
|
||||
@Body() body: AddVerifiedEmailInput,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ApiResponse<{ emailVerified: boolean }>> {
|
||||
const emailVerified = await this.verificationService.addVerifiedEmail({
|
||||
userId: user.id,
|
||||
existingPrimaryEmail: user.email,
|
||||
email: body.email,
|
||||
});
|
||||
|
||||
return {
|
||||
data: { emailVerified },
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail } from "class-validator";
|
||||
|
||||
export class AddVerifiedEmailInput {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNumber, IsOptional, IsString, IsEmail } from "class-validator";
|
||||
|
||||
export class GetVerifiedEmailsParams {
|
||||
@ApiPropertyOptional({ example: "12345" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamId?: string;
|
||||
}
|
||||
|
||||
export class GetVerifiedEmailsInput {
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
userId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
@IsString()
|
||||
userEmail!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
teamId?: number;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsString, IsArray } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetVerifiedEmailsOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ApiProperty({ type: [String] })
|
||||
readonly data!: string[];
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
||||
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
|
||||
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
||||
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
|
||||
@@ -13,6 +16,11 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class VerificationAtomsService {
|
||||
constructor(
|
||||
private readonly atomsRepository: AtomsRepository,
|
||||
private readonly teamsRepository: TeamsRepository
|
||||
) {}
|
||||
|
||||
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
|
||||
return await checkEmailVerificationRequired(input);
|
||||
}
|
||||
@@ -61,4 +69,78 @@ export class VerificationAtomsService {
|
||||
isVerifyingEmail: input.isVerifyingEmail,
|
||||
});
|
||||
}
|
||||
|
||||
async getVerifiedEmails(input: GetVerifiedEmailsInput): Promise<string[]> {
|
||||
const { userId, userEmail, teamId } = input;
|
||||
const userEmailWithoutOauthClientId = this.removeClientIdFromEmail(userEmail);
|
||||
|
||||
if (teamId) {
|
||||
const verifiedEmails: string[] = [];
|
||||
const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId);
|
||||
|
||||
if (teamMembers.length === 0) {
|
||||
return verifiedEmails;
|
||||
}
|
||||
|
||||
teamMembers.forEach((member) => {
|
||||
const memberEmailWithoutOauthClientId = this.removeClientIdFromEmail(member.email);
|
||||
|
||||
verifiedEmails.push(memberEmailWithoutOauthClientId);
|
||||
member.secondaryEmails.forEach((secondaryEmail) => {
|
||||
verifiedEmails.push(this.removeClientIdFromEmail(secondaryEmail.email));
|
||||
});
|
||||
});
|
||||
|
||||
return verifiedEmails;
|
||||
}
|
||||
|
||||
let verifiedEmails = [userEmailWithoutOauthClientId];
|
||||
|
||||
const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId);
|
||||
verifiedEmails = verifiedEmails.concat(
|
||||
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
|
||||
);
|
||||
|
||||
return verifiedEmails;
|
||||
}
|
||||
|
||||
async addVerifiedEmail({
|
||||
userId,
|
||||
existingPrimaryEmail,
|
||||
email,
|
||||
}: {
|
||||
userId: number;
|
||||
existingPrimaryEmail: string;
|
||||
email: string;
|
||||
}): Promise<boolean> {
|
||||
const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail(
|
||||
userId,
|
||||
email
|
||||
);
|
||||
const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email);
|
||||
|
||||
if (alreadyExistingEmail) {
|
||||
throw new BadRequestException("Email already exists");
|
||||
}
|
||||
|
||||
if (existingPrimaryEmail === email || existingSecondaryEmail === email) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.atomsRepository.addSecondaryEmail(userId, email);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
removeClientIdFromEmail(email: string): string {
|
||||
const [localPart, domain] = email.split("@");
|
||||
const localPartSegments = localPart.split("+");
|
||||
|
||||
localPartSegments.pop();
|
||||
|
||||
const baseEmail = localPartSegments.join("+");
|
||||
const normalizedEmail = `${baseEmail}@${domain}`;
|
||||
|
||||
return normalizedEmail;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -119,6 +119,15 @@ export class OAuthClientRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getByOrgId(organizationId: number) {
|
||||
return this.dbRead.prisma.platformOAuthClient.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getByEventTypeHosts(eventTypeId: number) {
|
||||
const hostWithUserPlatformClient = await this.dbRead.prisma.host.findFirst({
|
||||
select: {
|
||||
|
||||
+34
-2
@@ -1,17 +1,23 @@
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
|
||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { TeamService } from "@calcom/platform-libraries";
|
||||
|
||||
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
|
||||
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
|
||||
|
||||
export const PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR = `Can't add user to organization - the user is platform managed user but organization is not because organization probably was not created using OAuth credentials.`;
|
||||
export const REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR = `Can't add user to organization - the user is not platform managed user but organization is platform managed. Both have to be created using OAuth credentials.`;
|
||||
export const MANAGED_USER_AND_MANAGED_ORG_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR = `Can't add user to organization - managed user and organization were created using different OAuth clients.`;
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsMembershipService {
|
||||
constructor(
|
||||
private readonly organizationsMembershipRepository: OrganizationsMembershipRepository,
|
||||
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService
|
||||
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService,
|
||||
private readonly oAuthClientsRepository: OAuthClientRepository
|
||||
) {}
|
||||
|
||||
async getOrgMembership(organizationId: number, membershipId: number) {
|
||||
@@ -95,7 +101,33 @@ export class OrganizationsMembershipService {
|
||||
}
|
||||
|
||||
async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
|
||||
await this.canUserBeAddedToOrg(data.userId, organizationId);
|
||||
const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data);
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||
}
|
||||
|
||||
async canUserBeAddedToOrg(userId: number, orgId: number) {
|
||||
const [userOAuthClient, orgOAuthClients] = await Promise.all([
|
||||
this.oAuthClientsRepository.getByUserId(userId),
|
||||
this.oAuthClientsRepository.getByOrgId(orgId),
|
||||
]);
|
||||
|
||||
if (!userOAuthClient && orgOAuthClients.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userOAuthClient && orgOAuthClients.some((orgClient) => orgClient.id === userOAuthClient.id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!userOAuthClient) {
|
||||
throw new BadRequestException(REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR);
|
||||
}
|
||||
|
||||
if (orgOAuthClients.length === 0) {
|
||||
throw new BadRequestException(PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR);
|
||||
}
|
||||
|
||||
throw new BadRequestException(MANAGED_USER_AND_MANAGED_ORG_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
|
||||
import { UserOOOService } from "@/modules/ooo/services/ooo.service";
|
||||
import { OrganizationsAttributesController } from "@/modules/organizations/attributes/index/controllers/organizations-attributes.controller";
|
||||
@@ -65,6 +66,8 @@ import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
|
||||
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
@@ -93,6 +96,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsOrganizationsModule,
|
||||
OrganizationsStripeModule,
|
||||
OrganizationsTeamsRoutingFormsModule,
|
||||
MembershipsModule,
|
||||
OrganizationsConferencingModule,
|
||||
EventTypesPrivateLinksModule,
|
||||
],
|
||||
@@ -101,7 +105,6 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsService,
|
||||
OrganizationsTeamsService,
|
||||
MembershipsRepository,
|
||||
OrganizationsSchedulesService,
|
||||
OrganizationsUsersRepository,
|
||||
OrganizationsUsersService,
|
||||
@@ -147,6 +150,9 @@ import { Module } from "@nestjs/common";
|
||||
TeamsSchedulesService,
|
||||
SchedulesService_2024_06_11,
|
||||
InputSchedulesService_2024_06_11,
|
||||
TeamsMembershipsService,
|
||||
TeamsMembershipsRepository,
|
||||
OAuthClientRepository,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import {
|
||||
CreateManagedUserData,
|
||||
CreateManagedUserOutput,
|
||||
} from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import {
|
||||
PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR,
|
||||
REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR,
|
||||
} from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import {
|
||||
PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR,
|
||||
REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR,
|
||||
PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR,
|
||||
} from "@/modules/teams/memberships/services/teams-memberships.service";
|
||||
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Teams Memberships Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let orgOwner: User;
|
||||
let orgUser: User;
|
||||
let orgTeam: Team;
|
||||
let orgApiKey: string;
|
||||
|
||||
let platformOrg: Team;
|
||||
let platformOrgOwner: User;
|
||||
let platformOrgUser: CreateManagedUserData;
|
||||
let platformOrgTeam: Team;
|
||||
let platformOAuthClient: PlatformOAuthClient;
|
||||
let secondPlatformOAuthClient: PlatformOAuthClient;
|
||||
let secondPlatformOrgUser: CreateManagedUserData;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, TeamsMembershipsModule],
|
||||
}).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
|
||||
const orgName = `organizations-teams-memberships-organization-${randomString()}`;
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: orgName,
|
||||
slug: orgName,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
const platformOrgName = `organizations-teams-memberships-platform-organization-${randomString()}`;
|
||||
platformOrg = await organizationsRepositoryFixture.create({
|
||||
isPlatform: true,
|
||||
name: platformOrgName,
|
||||
slug: platformOrgName,
|
||||
isOrganization: true,
|
||||
platformBilling: {
|
||||
create: {
|
||||
customerId: "cus_999",
|
||||
plan: "ESSENTIALS",
|
||||
subscriptionId: "sub_999",
|
||||
},
|
||||
},
|
||||
});
|
||||
platformOAuthClient = await createOAuthClient(platformOrg.id);
|
||||
secondPlatformOAuthClient = await createOAuthClient(platformOrg.id);
|
||||
|
||||
orgOwner = await userRepositoryFixture.create({
|
||||
email: `organizations-teams-memberships-org-owner-${randomString()}@api.com`,
|
||||
username: `organizations-teams-memberships-org-owner-${randomString()}`,
|
||||
});
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(orgOwner.id, null);
|
||||
orgApiKey = `cal_test_${keyString}`;
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: orgOwner.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
platformOrgOwner = await userRepositoryFixture.create({
|
||||
email: `platform-org-owner-${randomString()}@api.com`,
|
||||
username: `platform-org-owner-${randomString()}`,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: platformOrgOwner.id } },
|
||||
team: { connect: { id: platformOrg.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: "asd1qwwqeqw-asddsadasd",
|
||||
username: `platform-org-owner-${randomString()}`,
|
||||
organization: { connect: { id: platformOrg.id } },
|
||||
user: {
|
||||
connect: { id: platformOrgOwner.id },
|
||||
},
|
||||
});
|
||||
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: "asd1qwwqeqw-asddsadasd-2",
|
||||
username: `org-owner-${randomString()}`,
|
||||
organization: { connect: { id: org.id } },
|
||||
user: {
|
||||
connect: { id: orgOwner.id },
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:4321"],
|
||||
permissions: 1023,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("should create org users", () => {
|
||||
it("should create a new org user", async () => {
|
||||
const newOrgUser: CreateUserInput = {
|
||||
email: `organization-user-${randomString()}@api.com`,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(EmailService.prototype, "sendSignupToOrganizationEmail")
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/users`)
|
||||
.send(newOrgUser)
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Authorization", `Bearer ${orgApiKey}`)
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.email).toBe(newOrgUser.email);
|
||||
orgUser = userData;
|
||||
});
|
||||
|
||||
it(`should create a new platform org manager user using first oAuth client`, async () => {
|
||||
const managedUserEmail = `platform-organization-manager-user-${randomString()}@api.com`;
|
||||
const requestBody: CreateManagedUserInput = {
|
||||
email: managedUserEmail,
|
||||
timeZone: "Europe/Berlin",
|
||||
weekStart: "Monday",
|
||||
timeFormat: 24,
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
bio: "I am a bio",
|
||||
metadata: {
|
||||
key: "value",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${platformOAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", platformOAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
|
||||
expect(responseBody.data.user.name).toEqual(requestBody.name);
|
||||
await userConnectedToOAuth(platformOAuthClient.id, responseBody.data.user.email, 1);
|
||||
platformOrgUser = responseBody.data;
|
||||
});
|
||||
|
||||
it(`should create a new platform org manager user using second oAuth client`, async () => {
|
||||
const managedUserEmail = `platform-organization-manager-user-${randomString()}@api.com`;
|
||||
const requestBody: CreateManagedUserInput = {
|
||||
email: managedUserEmail,
|
||||
timeZone: "Europe/Berlin",
|
||||
weekStart: "Monday",
|
||||
timeFormat: 24,
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
bio: "I am a bio",
|
||||
metadata: {
|
||||
key: "value",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${secondPlatformOAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", secondPlatformOAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
|
||||
expect(responseBody.data.user.name).toEqual(requestBody.name);
|
||||
await userConnectedToOAuth(secondPlatformOAuthClient.id, responseBody.data.user.email, 1);
|
||||
secondPlatformOrgUser = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
describe("should create org teams", () => {
|
||||
it("should create the team for org", async () => {
|
||||
const teamName = `organization team ${randomString()}`;
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.set("Authorization", `Bearer ${orgApiKey}`)
|
||||
.send({
|
||||
name: teamName,
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
orgTeam = responseBody.data;
|
||||
expect(orgTeam.name).toEqual(teamName);
|
||||
expect(orgTeam.parentId).toEqual(org.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create the team for platform org", async () => {
|
||||
const teamName = `platform organization team ${randomString()}`;
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${platformOrg.id}/teams`)
|
||||
.set("x-cal-client-id", platformOAuthClient.id)
|
||||
.set("x-cal-secret-key", platformOAuthClient.secret)
|
||||
.send({
|
||||
name: teamName,
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
platformOrgTeam = responseBody.data;
|
||||
expect(platformOrgTeam.name).toEqual(teamName);
|
||||
expect(platformOrgTeam.parentId).toEqual(platformOrg.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization memberships", () => {
|
||||
describe("negative tests", () => {
|
||||
it("should not add managed user to organization", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/memberships`)
|
||||
.set("Authorization", `Bearer ${orgApiKey}`)
|
||||
.send({
|
||||
userId: platformOrgUser.user.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(400);
|
||||
expect(response.body.error.message).toEqual(PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR);
|
||||
});
|
||||
|
||||
it("should not add managed user to organization team", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
|
||||
.set("Authorization", `Bearer ${orgApiKey}`)
|
||||
.send({
|
||||
userId: platformOrgUser.user.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(422);
|
||||
});
|
||||
|
||||
it("should not add managed user to organization team", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${orgTeam.id}/memberships`)
|
||||
.set("Authorization", `Bearer ${orgApiKey}`)
|
||||
.send({
|
||||
userId: platformOrgUser.user.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(400);
|
||||
expect(response.body.error.message).toEqual(PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positive tests", () => {
|
||||
it("should add user to organization", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/memberships`)
|
||||
.set("Authorization", `Bearer ${orgApiKey}`)
|
||||
.send({
|
||||
userId: orgUser.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it("should add user to organization team", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
|
||||
.set("Authorization", `Bearer ${orgApiKey}`)
|
||||
.send({
|
||||
userId: orgUser.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform organization memberships", () => {
|
||||
describe("negative tests", () => {
|
||||
it("should not add user to platform organization", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${platformOrg.id}/memberships`)
|
||||
.set("x-cal-client-id", platformOAuthClient.id)
|
||||
.set("x-cal-secret-key", platformOAuthClient.secret)
|
||||
.send({
|
||||
userId: orgUser.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(400);
|
||||
expect(response.body.error.message).toEqual(REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR);
|
||||
});
|
||||
|
||||
it("should not add user to platform organization team", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${platformOrg.id}/teams/${platformOrgTeam.id}/memberships`)
|
||||
.set("x-cal-client-id", platformOAuthClient.id)
|
||||
.set("x-cal-secret-key", platformOAuthClient.secret)
|
||||
.send({
|
||||
userId: orgUser.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(422);
|
||||
});
|
||||
it("should not add user to platform organization team", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${platformOrgTeam.id}/memberships`)
|
||||
.set("x-cal-client-id", platformOAuthClient.id)
|
||||
.set("x-cal-secret-key", platformOAuthClient.secret)
|
||||
.send({
|
||||
userId: orgUser.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(400);
|
||||
expect(response.body.error.message).toEqual(REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR);
|
||||
});
|
||||
|
||||
it("should not add user to platform organization team because user is created using different oAuth client", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${platformOrg.id}/teams/${platformOrgTeam.id}/memberships`)
|
||||
.set("x-cal-client-id", platformOAuthClient.id)
|
||||
.set("x-cal-secret-key", platformOAuthClient.secret)
|
||||
.send({
|
||||
userId: secondPlatformOrgUser.user.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(400);
|
||||
expect(response.body.error.message).toEqual(
|
||||
PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positive tests", () => {
|
||||
it("should add user to organization", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${platformOrg.id}/memberships`)
|
||||
.set("x-cal-client-id", platformOAuthClient.id)
|
||||
.set("x-cal-secret-key", platformOAuthClient.secret)
|
||||
.send({
|
||||
userId: platformOrgUser.user.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it("should add user to organization team", async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${platformOrg.id}/teams/${platformOrgTeam.id}/memberships`)
|
||||
.set("x-cal-client-id", platformOAuthClient.id)
|
||||
.set("x-cal-secret-key", platformOAuthClient.secret)
|
||||
.send({
|
||||
userId: platformOrgUser.user.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateOrgTeamMembershipDto)
|
||||
.expect(201);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function userConnectedToOAuth(oAuthClientId: string, userEmail: string, usersCount: number) {
|
||||
const oAuthUsers = await oauthClientRepositoryFixture.getUsers(oAuthClientId);
|
||||
const newOAuthUser = oAuthUsers?.find((user) => user.email === userEmail);
|
||||
|
||||
expect(oAuthUsers?.length).toEqual(usersCount);
|
||||
expect(newOAuthUser?.email).toEqual(userEmail);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await teamsRepositoryFixture.delete(orgTeam.id);
|
||||
await organizationsRepositoryFixture.delete(platformOrg.id);
|
||||
await teamsRepositoryFixture.delete(platformOrgTeam.id);
|
||||
await userRepositoryFixture.deleteByEmail(platformOrgUser.user.email);
|
||||
await userRepositoryFixture.deleteByEmail(orgUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(orgOwner.email);
|
||||
await userRepositoryFixture.deleteByEmail(platformOrgOwner.email);
|
||||
await userRepositoryFixture.deleteByEmail(secondPlatformOrgUser.user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { TeamService } from "@calcom/platform-libraries";
|
||||
@@ -8,10 +9,12 @@ import { TeamService } from "@calcom/platform-libraries";
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsMembershipsService {
|
||||
constructor(
|
||||
private readonly organizationsTeamsMembershipsRepository: OrganizationsTeamsMembershipsRepository
|
||||
private readonly organizationsTeamsMembershipsRepository: OrganizationsTeamsMembershipsRepository,
|
||||
private readonly teamsMembershipsService: TeamsMembershipsService
|
||||
) {}
|
||||
|
||||
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
||||
await this.teamsMembershipsService.canUserBeAddedToTeam(data.userId, teamId);
|
||||
const teamMembership = await this.organizationsTeamsMembershipsRepository.createOrgTeamMembership(
|
||||
teamId,
|
||||
data
|
||||
|
||||
@@ -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,15 +1,24 @@
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { TeamService } from "@calcom/platform-libraries";
|
||||
|
||||
export const PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR = `Can't add user to team - the user is platform managed user but team is not because team probably was not created using OAuth credentials.`;
|
||||
export const REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR = `Can't add user to team - the user is not platform managed user but team is platform managed. Both have to be created using OAuth credentials.`;
|
||||
export const PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR = `Can't add user to team - managed user and team were created using different OAuth clients.`;
|
||||
|
||||
@Injectable()
|
||||
export class TeamsMembershipsService {
|
||||
constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {}
|
||||
constructor(
|
||||
private readonly teamsMembershipsRepository: TeamsMembershipsRepository,
|
||||
private readonly oAuthClientsRepository: OAuthClientRepository
|
||||
) {}
|
||||
|
||||
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
|
||||
await this.canUserBeAddedToTeam(data.userId, teamId);
|
||||
const teamMembership = await this.teamsMembershipsRepository.createTeamMembership(teamId, data);
|
||||
return teamMembership;
|
||||
}
|
||||
@@ -54,4 +63,29 @@ export class TeamsMembershipsService {
|
||||
|
||||
return teamMembership;
|
||||
}
|
||||
|
||||
async canUserBeAddedToTeam(userId: number, teamId: number) {
|
||||
const [userOAuthClient, teamOAuthClient] = await Promise.all([
|
||||
this.oAuthClientsRepository.getByUserId(userId),
|
||||
this.oAuthClientsRepository.getByTeamId(teamId),
|
||||
]);
|
||||
|
||||
if (!userOAuthClient && !teamOAuthClient) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userOAuthClient && teamOAuthClient && userOAuthClient.id === teamOAuthClient.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!teamOAuthClient) {
|
||||
throw new BadRequestException(PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR);
|
||||
}
|
||||
|
||||
if (!userOAuthClient) {
|
||||
throw new BadRequestException(REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR);
|
||||
}
|
||||
|
||||
throw new BadRequestException(PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@ import { Module } from "@nestjs/common";
|
||||
imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule, TeamsEventTypesModule],
|
||||
providers: [TeamsMembershipsRepository, TeamsMembershipsService],
|
||||
controllers: [TeamsMembershipsController],
|
||||
exports: [TeamsMembershipsService],
|
||||
})
|
||||
export class TeamsMembershipsModule {}
|
||||
|
||||
@@ -120,4 +120,30 @@ export class TeamsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamMemberEmails(teamId: number) {
|
||||
return this.dbRead.prisma.user.findMany({
|
||||
where: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
secondaryEmails: {
|
||||
where: {
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9132,8 +9132,6 @@
|
||||
{
|
||||
"name": "credentialId",
|
||||
"in": "query",
|
||||
"description": "The ID of the user's calendar credential used to access the calendar. Retrieve this from your connected calendar credentials via the `/v2/credentials` endpoint.",
|
||||
"example": 135,
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
@@ -9142,8 +9140,6 @@
|
||||
{
|
||||
"name": "externalId",
|
||||
"in": "query",
|
||||
"description": "The external calendar identifier from the calendar provider (e.g., email address for Google Calendar). This is returned when listing connected calendars.",
|
||||
"example": "user@gmail.com",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
@@ -9862,6 +9858,7 @@
|
||||
"get": {
|
||||
"operationId": "EventTypesController_2024_06_14_getEventTypes",
|
||||
"summary": "Get all event types",
|
||||
"description": "Hidden event types are returned only if authentication is provided and it belongs to the event type owner.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -9917,6 +9914,33 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-cal-secret-key",
|
||||
"in": "header",
|
||||
"description": "For platform customers - OAuth client secret key",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-cal-client-id",
|
||||
"in": "header",
|
||||
"description": "For platform customers - OAuth client ID",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -23642,7 +23666,12 @@
|
||||
"afterEvent",
|
||||
"rescheduleEvent",
|
||||
"afterHostsCalVideoNoShow",
|
||||
"afterGuestsCalVideoNoShow"
|
||||
"afterGuestsCalVideoNoShow",
|
||||
"bookingRejected",
|
||||
"bookingRequested",
|
||||
"bookingPaymentInitiated",
|
||||
"bookingPaid",
|
||||
"bookingNoShowUpdated"
|
||||
]
|
||||
],
|
||||
"description": "Trigger type for the workflow"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { hasInsightsPermission } from "@calcom/features/insights/server/hasInsightsPermission";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export async function checkInsightsPagePermission() {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const hasPermission = await hasInsightsPermission({
|
||||
userId: session.user.id,
|
||||
organizationId: session.user.org?.id,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -1,34 +1,22 @@
|
||||
import { getTranslate } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
||||
|
||||
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading={t("insights")}
|
||||
subtitle={t("insights_subtitle")}
|
||||
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
|
||||
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
|
||||
</Shell>
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading={t("insights")}
|
||||
subtitle={t("insights_subtitle")}
|
||||
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
|
||||
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import InsightsPage from "~/insights/insights-view";
|
||||
|
||||
import { checkInsightsPagePermission } from "./checkInsightsPagePermission";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("insights"),
|
||||
@@ -18,7 +16,7 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const session = await checkInsightsPagePermission();
|
||||
|
||||
const { timeZone } = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: session?.user.id ?? -1 },
|
||||
|
||||
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
|
||||
|
||||
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("insights"),
|
||||
@@ -12,5 +14,7 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
await checkInsightsPagePermission();
|
||||
|
||||
return <InsightsVirtualQueuesPage />;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import InsightsRoutingPage from "~/insights/insights-routing-view";
|
||||
|
||||
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("insights"),
|
||||
@@ -12,5 +14,7 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
await checkInsightsPagePermission();
|
||||
|
||||
return <InsightsRoutingPage />;
|
||||
}
|
||||
|
||||
+3
-11
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
|
||||
import type Shell from "@calcom/features/shell/Shell";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import type { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
|
||||
|
||||
export type AdminLayoutProps = {
|
||||
@@ -14,14 +14,6 @@ export type AdminLayoutProps = {
|
||||
} & ComponentProps<typeof Shell>;
|
||||
export default function AdminLayoutAppDirClient({ userRole, children }: AdminLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// Force redirect on component level
|
||||
useEffect(() => {
|
||||
if (userRole !== UserPermissionRole.ADMIN) {
|
||||
router.replace("/settings/my-account/profile");
|
||||
}
|
||||
}, [userRole, router]);
|
||||
|
||||
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -15,5 +17,9 @@ export default async function AdminLayoutAppDir(props: AdminLayoutAppDirProps) {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const userRole = session?.user?.role;
|
||||
|
||||
if (userRole !== UserPermissionRole.ADMIN) {
|
||||
return redirect("/settings/my-account/profile");
|
||||
}
|
||||
|
||||
return await SettingsLayoutAppDir({ children: <AdminLayoutAppDirClient {...props} userRole={userRole} /> });
|
||||
}
|
||||
|
||||
+4
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import OrgAttributesEditPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-edit-view";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../../../actions/validateUserHasOrgAdmin";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
(t) => t("attribute"),
|
||||
@@ -12,6 +14,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
);
|
||||
|
||||
const OrgAttributesEditPageWrapper = async () => {
|
||||
await validateUserHasOrgAdmin();
|
||||
|
||||
return <OrgAttributesEditPage />;
|
||||
};
|
||||
|
||||
|
||||
+4
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import OrgAttributesCreatePage from "@calcom/ee/organizations/pages/settings/attributes/attributes-create-view";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../../actions/validateUserHasOrgAdmin";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("attribute"),
|
||||
@@ -12,6 +14,8 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const OrgAttributesCreatePageWrapper = async () => {
|
||||
await validateUserHasOrgAdmin();
|
||||
|
||||
return <OrgAttributesCreatePage />;
|
||||
};
|
||||
|
||||
|
||||
+2
-8
@@ -1,15 +1,13 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -22,11 +20,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
const session = await validateUserHasOrgAdmin();
|
||||
|
||||
const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
|
||||
+33
-3
@@ -1,5 +1,35 @@
|
||||
import BillingPage, { generateMetadata } from "../../../billing/page";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
export { generateMetadata };
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export default BillingPage;
|
||||
import BillingView from "~/settings/billing/billing-view";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("billing"),
|
||||
(t) => t("manage_billing_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/billing"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
await validateUserHasOrgAdmin();
|
||||
|
||||
// TODO(SEAN): Add PBAC to this page in the next PR
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("billing")}
|
||||
description={t("manage_billing_description")}
|
||||
borderInShellHeader={true}>
|
||||
<BillingView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
+4
@@ -3,6 +3,8 @@ import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import DelegationCredentialList from "@calcom/features/ee/organizations/pages/settings/delegationCredential";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("delegation_credential"),
|
||||
@@ -15,6 +17,8 @@ export const generateMetadata = async () =>
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
await validateUserHasOrgAdmin();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
borderInShellHeader
|
||||
|
||||
+2
-9
@@ -1,15 +1,12 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-view";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -22,11 +19,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/organizations/general");
|
||||
}
|
||||
const session = await validateUserHasOrgAdmin();
|
||||
|
||||
const { canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
|
||||
+3
-5
@@ -1,15 +1,13 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import PrivacyView from "@calcom/features/ee/organizations/pages/settings/privacy";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -21,9 +19,9 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const [t, _headers, _cookies] = await Promise.all([getTranslate(), headers(), cookies()]);
|
||||
const t = await getTranslate();
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
|
||||
const session = await validateUserHasOrgAdmin();
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
|
||||
+2
-4
@@ -1,15 +1,13 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -22,7 +20,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const session = await validateUserHasOrgAdmin();
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/organizations/general");
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
import type { Session } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { describe, it, vi, expect, beforeEach, type MockedFunction } from "vitest";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrg, type ValidatedOrgSession } from "./validateUserHasOrg";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(() => Promise.resolve({})),
|
||||
headers: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const mockedGetServerSession = getServerSession as MockedFunction<typeof getServerSession>;
|
||||
const mockedRedirect = vi.mocked(redirect);
|
||||
|
||||
describe("validateUserHasOrg", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockSession = (overrides: Partial<Session> = {}): Session => ({
|
||||
expires: "2025-01-01",
|
||||
hasValidLicense: true,
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
logoUrl: null,
|
||||
fullDomain: "test-org.cal.com",
|
||||
domainSuffix: "cal.com",
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
profileId: 789,
|
||||
upId: "usr-123",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("when user has valid organization", () => {
|
||||
it("should return session when user has org in user.org", async () => {
|
||||
const mockSession = createMockSession();
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await validateUserHasOrg();
|
||||
|
||||
expect(result).toEqual(mockSession);
|
||||
expect(mockedRedirect).not.toHaveBeenCalled();
|
||||
expect(result.user.id).toBe(123);
|
||||
expect(result.user.org?.id).toBe(456);
|
||||
expect(result.user.profile?.organizationId).toBe(456);
|
||||
});
|
||||
|
||||
it("should return session when user has organizationId but no org", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: undefined,
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await validateUserHasOrg();
|
||||
|
||||
expect(result).toEqual(mockSession);
|
||||
expect(mockedRedirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when user does not have valid organization", () => {
|
||||
it("should redirect when session is null", async () => {
|
||||
mockedGetServerSession.mockResolvedValue(null);
|
||||
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
mockedRedirect.mockImplementation(() => {
|
||||
throw redirectError;
|
||||
});
|
||||
|
||||
await expect(validateUserHasOrg()).rejects.toThrow("NEXT_REDIRECT");
|
||||
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
|
||||
});
|
||||
|
||||
it("should redirect when user has no organization data", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: undefined,
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: null,
|
||||
organization: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
mockedRedirect.mockImplementation(() => {
|
||||
throw redirectError;
|
||||
});
|
||||
|
||||
await expect(validateUserHasOrg()).rejects.toThrow("NEXT_REDIRECT");
|
||||
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
|
||||
});
|
||||
|
||||
it("should redirect when user has no user id", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: undefined as unknown as number,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
logoUrl: null,
|
||||
fullDomain: "test-org.cal.com",
|
||||
domainSuffix: "cal.com",
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
mockedRedirect.mockImplementation(() => {
|
||||
throw redirectError;
|
||||
});
|
||||
|
||||
await expect(validateUserHasOrg()).rejects.toThrow("NEXT_REDIRECT");
|
||||
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
|
||||
});
|
||||
});
|
||||
|
||||
describe("return type validation", () => {
|
||||
it("should return ValidatedOrgSession type with non-nullable properties", async () => {
|
||||
const mockSession = createMockSession();
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
|
||||
const result: ValidatedOrgSession = await validateUserHasOrg();
|
||||
|
||||
// Type assertions to ensure proper typing
|
||||
expect(typeof result.user.id).toBe("number");
|
||||
expect(result.user.org).toBeTruthy();
|
||||
expect(result.user.profile).toBeTruthy();
|
||||
expect(typeof result.user.profile.organizationId).toBe("number");
|
||||
|
||||
// Runtime checks that the types guarantee these properties exist
|
||||
expect(result.user.id).toBe(123);
|
||||
expect(result.user.org.id).toBe(456);
|
||||
expect(result.user.profile.organizationId).toBe(456);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should propagate errors from getServerSession", async () => {
|
||||
const error = new Error("Session error");
|
||||
mockedGetServerSession.mockRejectedValue(error);
|
||||
|
||||
await expect(validateUserHasOrg()).rejects.toThrow("Session error");
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import type { Session } from "next-auth";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export type ValidatedOrgSession = NonNullable<Session> & {
|
||||
user: NonNullable<Session["user"]> & {
|
||||
id: number;
|
||||
org: NonNullable<NonNullable<Session["user"]>["org"]>;
|
||||
profile: NonNullable<NonNullable<Session["user"]>["profile"]> & {
|
||||
organizationId: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export async function validateUserHasOrg(): Promise<ValidatedOrgSession> {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
const orgExists =
|
||||
session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization;
|
||||
|
||||
if (!orgExists || !session?.user?.id || !session?.user?.profile?.organizationId) {
|
||||
redirect("/settings/my-account/profile");
|
||||
}
|
||||
|
||||
return session as ValidatedOrgSession;
|
||||
}
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
import type { Session } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { describe, it, vi, expect, beforeEach, type MockedFunction } from "vitest";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrgAdmin, type ValidatedOrgAdminSession } from "./validateUserHasOrgAdmin";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(() => Promise.resolve({})),
|
||||
headers: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/auth/lib/checkAdminOrOwner", () => ({
|
||||
checkAdminOrOwner: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@lib/buildLegacyCtx", () => ({
|
||||
buildLegacyRequest: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const mockedGetServerSession = getServerSession as MockedFunction<typeof getServerSession>;
|
||||
const mockedCheckAdminOrOwner = vi.mocked(checkAdminOrOwner);
|
||||
const mockedRedirect = vi.mocked(redirect);
|
||||
|
||||
describe("validateUserHasOrgAdmin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockSession = (overrides: Partial<Session> = {}): Session => ({
|
||||
expires: "2025-01-01",
|
||||
hasValidLicense: true,
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
logoUrl: null,
|
||||
fullDomain: "test-org.cal.com",
|
||||
domainSuffix: "cal.com",
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
profileId: 789,
|
||||
upId: "usr-123",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("when user has valid organization and admin role", () => {
|
||||
it("should return session when user is admin via user.org.role", async () => {
|
||||
const mockSession = createMockSession();
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
mockedCheckAdminOrOwner.mockReturnValue(true);
|
||||
|
||||
const result = await validateUserHasOrgAdmin();
|
||||
|
||||
expect(result).toEqual(mockSession);
|
||||
expect(mockedRedirect).not.toHaveBeenCalled();
|
||||
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.ADMIN);
|
||||
expect(result.user.id).toBe(123);
|
||||
expect(result.user.org?.id).toBe(456);
|
||||
expect(result.user.profile?.organizationId).toBe(456);
|
||||
});
|
||||
|
||||
it("should return session when user is owner", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
logoUrl: null,
|
||||
fullDomain: "test-org.cal.com",
|
||||
domainSuffix: "cal.com",
|
||||
role: MembershipRole.OWNER,
|
||||
},
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
mockedCheckAdminOrOwner.mockReturnValue(true);
|
||||
|
||||
const result = await validateUserHasOrgAdmin();
|
||||
|
||||
expect(result).toEqual(mockSession);
|
||||
expect(mockedRedirect).not.toHaveBeenCalled();
|
||||
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.OWNER);
|
||||
});
|
||||
|
||||
it("should return session when role comes from organization members", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: undefined,
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
members: [
|
||||
{ userId: 123, role: MembershipRole.ADMIN },
|
||||
{ userId: 124, role: MembershipRole.MEMBER },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
mockedCheckAdminOrOwner.mockReturnValue(true);
|
||||
|
||||
const result = await validateUserHasOrgAdmin();
|
||||
|
||||
expect(result).toEqual(mockSession);
|
||||
expect(mockedRedirect).not.toHaveBeenCalled();
|
||||
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.ADMIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when user does not have valid organization", () => {
|
||||
it("should redirect when session is null", async () => {
|
||||
mockedGetServerSession.mockResolvedValue(null);
|
||||
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
mockedRedirect.mockImplementation(() => {
|
||||
throw redirectError;
|
||||
});
|
||||
|
||||
await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT");
|
||||
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
|
||||
});
|
||||
|
||||
it("should redirect when user has no organization data", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: undefined,
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: null,
|
||||
organization: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
mockedRedirect.mockImplementation(() => {
|
||||
throw redirectError;
|
||||
});
|
||||
|
||||
await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT");
|
||||
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when user has organization but is not admin", () => {
|
||||
it("should redirect when user is only a member", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
logoUrl: null,
|
||||
fullDomain: "test-org.cal.com",
|
||||
domainSuffix: "cal.com",
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
mockedCheckAdminOrOwner.mockReturnValue(false);
|
||||
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
mockedRedirect.mockImplementation(() => {
|
||||
throw redirectError;
|
||||
});
|
||||
|
||||
await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT");
|
||||
expect(mockedRedirect).toHaveBeenCalledWith("/settings/organizations/profile");
|
||||
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.MEMBER);
|
||||
});
|
||||
|
||||
it("should redirect when role is undefined", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: undefined,
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
members: [
|
||||
{ userId: 999, role: MembershipRole.ADMIN }, // Different user
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
mockedCheckAdminOrOwner.mockReturnValue(false);
|
||||
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
mockedRedirect.mockImplementation(() => {
|
||||
throw redirectError;
|
||||
});
|
||||
|
||||
await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT");
|
||||
expect(mockedRedirect).toHaveBeenCalledWith("/settings/organizations/profile");
|
||||
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("return type validation", () => {
|
||||
it("should return ValidatedOrgAdminSession type with non-nullable properties", async () => {
|
||||
const mockSession = createMockSession();
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
mockedCheckAdminOrOwner.mockReturnValue(true);
|
||||
|
||||
const result: ValidatedOrgAdminSession = await validateUserHasOrgAdmin();
|
||||
|
||||
// Type assertions to ensure proper typing
|
||||
expect(typeof result.user.id).toBe("number");
|
||||
expect(result.user.org).toBeTruthy();
|
||||
expect(result.user.profile).toBeTruthy();
|
||||
expect(typeof result.user.profile.organizationId).toBe("number");
|
||||
expect(result.user.org.role).toBeTruthy();
|
||||
|
||||
// Runtime checks that the types guarantee these properties exist
|
||||
expect(result.user.id).toBe(123);
|
||||
expect(result.user.org.id).toBe(456);
|
||||
expect(result.user.org.role).toBe(MembershipRole.ADMIN);
|
||||
expect(result.user.profile.organizationId).toBe(456);
|
||||
});
|
||||
});
|
||||
|
||||
describe("role resolution logic", () => {
|
||||
it("should prefer org role over organization members role", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
logoUrl: null,
|
||||
fullDomain: "test-org.cal.com",
|
||||
domainSuffix: "cal.com",
|
||||
role: MembershipRole.OWNER, // This should be preferred
|
||||
},
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
members: [
|
||||
{ userId: 123, role: MembershipRole.MEMBER }, // Different role
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
mockedCheckAdminOrOwner.mockReturnValue(true);
|
||||
|
||||
await validateUserHasOrgAdmin();
|
||||
|
||||
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.OWNER);
|
||||
});
|
||||
|
||||
it("should fall back to organization members role when org role is not available", async () => {
|
||||
const mockSession = createMockSession({
|
||||
user: {
|
||||
id: 123,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
org: undefined,
|
||||
profile: {
|
||||
id: 789,
|
||||
upId: "usr-123",
|
||||
username: "testuser",
|
||||
organizationId: 456,
|
||||
organization: {
|
||||
id: 456,
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
calVideoLogo: null,
|
||||
bannerUrl: null,
|
||||
requestedSlug: null,
|
||||
members: [{ userId: 123, role: MembershipRole.ADMIN }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
mockedCheckAdminOrOwner.mockReturnValue(true);
|
||||
|
||||
await validateUserHasOrgAdmin();
|
||||
|
||||
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.ADMIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should propagate errors from getServerSession", async () => {
|
||||
const error = new Error("Session error");
|
||||
mockedGetServerSession.mockRejectedValue(error);
|
||||
|
||||
await expect(validateUserHasOrgAdmin()).rejects.toThrow("Session error");
|
||||
});
|
||||
|
||||
it("should propagate errors from checkAdminOrOwner", async () => {
|
||||
const mockSession = createMockSession();
|
||||
mockedGetServerSession.mockResolvedValue(mockSession);
|
||||
|
||||
const error = new Error("Permission check error");
|
||||
mockedCheckAdminOrOwner.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(validateUserHasOrgAdmin()).rejects.toThrow("Permission check error");
|
||||
});
|
||||
});
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { Session } from "next-auth";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export type ValidatedOrgAdminSession = NonNullable<Session> & {
|
||||
user: NonNullable<Session["user"]> & {
|
||||
id: number;
|
||||
org: NonNullable<NonNullable<Session["user"]>["org"]> & {
|
||||
role: MembershipRole;
|
||||
};
|
||||
profile: NonNullable<NonNullable<Session["user"]>["profile"]> & {
|
||||
organizationId: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export async function validateUserHasOrgAdmin(): Promise<ValidatedOrgAdminSession> {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
const orgExists =
|
||||
session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization;
|
||||
|
||||
if (!orgExists || !session?.user?.id || !session?.user?.profile?.organizationId) {
|
||||
redirect("/settings/my-account/profile");
|
||||
}
|
||||
|
||||
const userProfile = session?.user?.profile;
|
||||
const userId = session?.user?.id;
|
||||
const orgRole =
|
||||
session?.user?.org?.role ??
|
||||
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
|
||||
|
||||
if (!isOrgAdminOrOwner) {
|
||||
redirect("/settings/organizations/profile");
|
||||
}
|
||||
|
||||
return session as ValidatedOrgAdminSession;
|
||||
}
|
||||
+4
@@ -3,6 +3,8 @@ import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import { AdminAPIView } from "@calcom/features/ee/organizations/pages/settings/admin-api";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => `${t("admin")} ${t("api_reference")}`,
|
||||
@@ -15,6 +17,8 @@ export const generateMetadata = async () =>
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
await validateUserHasOrg();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={`${t("admin")} ${t("api_reference")}`}
|
||||
|
||||
+2
-9
@@ -1,15 +1,12 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/general";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -23,11 +20,7 @@ export const generateMetadata = async () =>
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
const { canRead, canEdit } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
|
||||
@@ -1,19 +1,4 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const SettingsOrganizationsLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
const orgExists =
|
||||
session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization;
|
||||
if (!orgExists) {
|
||||
return redirect("/settings/my-account/profile");
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
|
||||
+6
-8
@@ -1,8 +1,6 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/profile";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
@@ -10,7 +8,7 @@ import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import type { Membership } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -22,20 +20,20 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const session = await validateUserHasOrg();
|
||||
const t = await getTranslate();
|
||||
|
||||
const orgRole = session?.user.profile?.organization.members?.find(
|
||||
(member: Membership) => member.userId === session?.user.id
|
||||
const orgRole = session.user.profile.organization.members?.find(
|
||||
(member: Membership) => member.userId === session.user.id
|
||||
)?.role;
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !orgRole) {
|
||||
if (!orgRole) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
const { canRead, canEdit, canDelete } = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session?.user.profile?.organizationId,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: orgRole,
|
||||
fallbackRoles: {
|
||||
|
||||
+2
-5
@@ -1,9 +1,7 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||
@@ -13,8 +11,7 @@ import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
|
||||
import { CreateRoleCTA } from "./_components/CreateRoleCta";
|
||||
import { RolesList } from "./_components/RolesList";
|
||||
import { roleSearchParamsCache } from "./_components/searchParams";
|
||||
@@ -58,7 +55,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async ({ searchParams }: { searchParams: Record<string, string | string[] | undefined> }) => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
if (!session?.user?.org?.id || !session.user.id) {
|
||||
return notFound();
|
||||
|
||||
+2
-4
@@ -1,13 +1,11 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validateUserHasOrg } from "../../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -20,7 +18,7 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
|
||||
+4
@@ -3,6 +3,8 @@ import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import LegacyPage from "@calcom/features/ee/teams/pages/team-appearance-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import { validateUserHasOrg } from "../../../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
(t) => t("booking_appearance"),
|
||||
@@ -15,6 +17,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
await validateUserHasOrg();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("booking_appearance")}
|
||||
|
||||
+4
@@ -5,6 +5,8 @@ import LegacyPage, {
|
||||
} from "@calcom/features/ee/organizations/pages/settings/other-team-members-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import { validateUserHasOrg } from "../../../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
(t) => t("team_members"),
|
||||
@@ -17,6 +19,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
await validateUserHasOrg();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("team_members")}
|
||||
|
||||
+4
@@ -3,6 +3,8 @@ import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/other-team-profile-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
import { validateUserHasOrg } from "../../../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
(t) => t("profile"),
|
||||
@@ -15,6 +17,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
await validateUserHasOrg();
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("profile")} description={t("profile_team_description")}>
|
||||
<LegacyPage />
|
||||
|
||||
@@ -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(".");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user