Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1beec0487 | ||
|
|
6bbf263981 | ||
|
|
d86a28bf7f | ||
|
|
c00e8cfa54 | ||
|
|
e82cc328d3 | ||
|
|
7dbfc1b2e0 | ||
|
|
02fb7c0ae0 | ||
|
|
9004dcb93b | ||
|
|
cf83b0a236 | ||
|
|
7f02464fed | ||
|
|
2371253cb5 | ||
|
|
859e7f1c88 | ||
|
|
f1e8481878 | ||
|
|
acf3a7c54e | ||
|
|
f163e4638b | ||
|
|
9dab915ddd | ||
|
|
8fca8f3910 | ||
|
|
1638465607 | ||
|
|
ab7eb7f4b1 | ||
|
|
0592badd56 | ||
|
|
7e82b8fa86 | ||
|
|
7dfb71ca30 | ||
|
|
d99ecde0e2 | ||
|
|
d81b905ad7 | ||
|
|
46c6582328 | ||
|
|
396811b954 | ||
|
|
f6c7d8d97f | ||
|
|
930165b6e8 | ||
|
|
a77bd7bbe0 | ||
|
|
51212cdacf | ||
|
|
3731b1ef3d | ||
|
|
f631c4aa79 | ||
|
|
95cd76e732 | ||
|
|
7e525779b7 | ||
|
|
f176ebf1da | ||
|
|
5058e0e750 | ||
|
|
dc409e1c5a | ||
|
|
89b5d1ebc3 | ||
|
|
0d24664a72 | ||
|
|
69efc26f36 | ||
|
|
95c48ac805 | ||
|
|
59ab38db98 | ||
|
|
11a41e0e39 | ||
|
|
880fe151c0 | ||
|
|
603f106389 | ||
|
|
5c7f1aebb2 | ||
|
|
a8fe3a6466 | ||
|
|
8974f8f9de | ||
|
|
a11c2cc52c | ||
|
|
14d319f992 | ||
|
|
49f0759f31 |
No files matched your search
@@ -187,6 +187,7 @@ TWILIO_WHATSAPP_PHONE_NUMBER=
|
|||||||
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
|
||||||
NEXT_PUBLIC_SENDER_ID=
|
NEXT_PUBLIC_SENDER_ID=
|
||||||
TWILIO_VERIFY_SID=
|
TWILIO_VERIFY_SID=
|
||||||
|
TWILIO_OPT_OUT_ENABLED=
|
||||||
|
|
||||||
# Set it to "1" if you need to run E2E tests locally.
|
# Set it to "1" if you need to run E2E tests locally.
|
||||||
NEXT_PUBLIC_IS_E2E=
|
NEXT_PUBLIC_IS_E2E=
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
/**/package.json @calcom/Foundation
|
/**/package.json @calcom/Foundation
|
||||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||||
|
/apps/ui-playground/**/* @calcom/Consumer
|
||||||
|
/apps/web/components/**/* @calcom/Consumer
|
||||||
|
/apps/web/modules/**/* @calcom/Consumer
|
||||||
|
/apps/web/**/layout.tsx @calcom/Foundation
|
||||||
|
/apps/web/lib/**/* @calcom/Foundation
|
||||||
|
/apps/web/middleware.ts @calcom/Foundation
|
||||||
/deploy/**/* @calcom/Foundation
|
/deploy/**/* @calcom/Foundation
|
||||||
/infra/**/* @calcom/Foundation
|
/infra/**/* @calcom/Foundation
|
||||||
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
||||||
@@ -21,3 +27,4 @@
|
|||||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||||
/packages/prisma/**/* @calcom/Foundation
|
/packages/prisma/**/* @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||||
|
/packages/ui/**/* @calcom/Consumer
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/prisma"
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
undecided:
|
||||||
|
- "@calcom/app-store-cli"
|
||||||
|
- "@calcom/platform-constants"
|
||||||
|
- "@calcom/platform-enums"
|
||||||
|
- "@calcom/platform-types"
|
||||||
|
- "@calcom/platform-utils"
|
||||||
|
- "@calcom/prisma"
|
||||||
@@ -107,6 +107,12 @@ $ yarn test:e2e:watch --testPathPattern=filePath
|
|||||||
$ yarn run test:cov
|
$ yarn run test:cov
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Guards
|
||||||
|
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
|
||||||
|
2. In case a guard would return "false" for "canActivate" DO NOT cache the result in redis, because we don't want that someone is forbidden, updates whatever was the problem, and then has to wait for cache to expire. We only cache in redis guard results where "canAccess" is "true".
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.173",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.174",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
"@golevelup/ts-jest": "^0.4.0",
|
"@golevelup/ts-jest": "^0.4.0",
|
||||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||||
|
"@nestjs/axios": "^4.0.0",
|
||||||
"@nestjs/bull": "^10.1.1",
|
"@nestjs/bull": "^10.1.1",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
|
|||||||
@@ -19,9 +19,14 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
|
|||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||||
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
|
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||||
|
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
@@ -38,6 +43,9 @@ import { Module } from "@nestjs/common";
|
|||||||
SchedulesModule_2024_04_15,
|
SchedulesModule_2024_04_15,
|
||||||
EventTypesModule_2024_04_15,
|
EventTypesModule_2024_04_15,
|
||||||
EventTypesModule_2024_06_14,
|
EventTypesModule_2024_06_14,
|
||||||
|
StripeModule,
|
||||||
|
TeamsModule,
|
||||||
|
TeamsEventTypesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TokensRepository,
|
TokensRepository,
|
||||||
@@ -57,6 +65,8 @@ import { Module } from "@nestjs/common";
|
|||||||
AppsRepository,
|
AppsRepository,
|
||||||
CalendarsRepository,
|
CalendarsRepository,
|
||||||
SelectedCalendarsRepository,
|
SelectedCalendarsRepository,
|
||||||
|
OrganizationsTeamsRepository,
|
||||||
|
OrganizationsRepository,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController_2024_08_13],
|
controllers: [BookingsController_2024_08_13],
|
||||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||||
|
|||||||
@@ -100,9 +100,13 @@ export class BookingsController_2024_08_13 {
|
|||||||
|
|
||||||
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
||||||
|
|
||||||
Finally, there are 2 ways to book an event type:
|
Finally, there are 2 ways to book an event type belonging to an individual user:
|
||||||
1. Provide \`eventTypeId\` in the request body.
|
1. Provide \`eventTypeId\` in the request body.
|
||||||
2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization.
|
2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization.
|
||||||
|
|
||||||
|
And 2 ways to book and event type belonging to a team:
|
||||||
|
1. Provide \`eventTypeId\` in the request body.
|
||||||
|
2. Provide \`eventTypeSlug\` and \`teamSlug\` and optionally \`organizationSlug\` if the team with the teamSlug is within an organization.
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
|
|||||||
+1
-627
@@ -58,14 +58,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let user: User;
|
let user: User;
|
||||||
|
|
||||||
let seatedEventTypeId: number;
|
let seatedEventTypeId: number;
|
||||||
let recurringSeatedEventTypeId: number;
|
|
||||||
const maxRecurrenceCount = 3;
|
const maxRecurrenceCount = 3;
|
||||||
|
|
||||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||||
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
|
||||||
|
|
||||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||||
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
|
|
||||||
|
|
||||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
||||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
||||||
@@ -126,22 +123,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
);
|
);
|
||||||
seatedEventTypeId = seatedEvent.id;
|
seatedEventTypeId = seatedEvent.id;
|
||||||
|
|
||||||
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
|
|
||||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
|
||||||
{
|
|
||||||
title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
length: 60,
|
|
||||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
|
||||||
seatsPerTimeSlot: 5,
|
|
||||||
seatsShowAttendees: true,
|
|
||||||
seatsShowAvailabilityCount: true,
|
|
||||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
|
||||||
},
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
recurringSeatedEventTypeId = recurringSeatedEvent.id;
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
@@ -321,127 +302,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should book a recurring event type with seats", async () => {
|
|
||||||
const recurrenceCount = 2;
|
|
||||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
|
||||||
eventTypeId: recurringSeatedEventTypeId,
|
|
||||||
attendee: {
|
|
||||||
name: "Mr Proper",
|
|
||||||
email: "mr_proper@gmail.com",
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
language: "it",
|
|
||||||
},
|
|
||||||
location: googleMeetUrl,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
codingLanguage: "TypeScript",
|
|
||||||
},
|
|
||||||
recurrenceCount,
|
|
||||||
metadata: {
|
|
||||||
userId: "300",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
expect(data.length).toEqual(recurrenceCount);
|
|
||||||
|
|
||||||
const firstBooking = data[0];
|
|
||||||
expect(firstBooking.seatUid).toBeDefined();
|
|
||||||
const seatUid = firstBooking.seatUid;
|
|
||||||
expect(firstBooking.id).toBeDefined();
|
|
||||||
expect(firstBooking.uid).toBeDefined();
|
|
||||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(firstBooking.status).toEqual("accepted");
|
|
||||||
expect(firstBooking.start).toEqual(body.start);
|
|
||||||
expect(firstBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(firstBooking.duration).toEqual(60);
|
|
||||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(firstBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(firstBooking.attendees.length).toEqual(1);
|
|
||||||
expect(firstBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(firstBooking.location).toEqual(body.location);
|
|
||||||
expect(firstBooking.absentHost).toEqual(false);
|
|
||||||
|
|
||||||
const secondBooking = data[1];
|
|
||||||
expect(secondBooking.seatUid).toBeDefined();
|
|
||||||
const secondSeatUid = secondBooking.seatUid;
|
|
||||||
expect(secondBooking.id).toBeDefined();
|
|
||||||
expect(secondBooking.uid).toBeDefined();
|
|
||||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(secondBooking.status).toEqual("accepted");
|
|
||||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
|
||||||
expect(secondBooking.start).toEqual(expectedStart);
|
|
||||||
expect(secondBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(secondBooking.duration).toEqual(60);
|
|
||||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(secondBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees.length).toEqual(1);
|
|
||||||
expect(secondBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: secondSeatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.location).toEqual(body.location);
|
|
||||||
expect(secondBooking.absentHost).toEqual(false);
|
|
||||||
createdRecurringSeatedBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get a booking for an event type with seats", async () => {
|
it("should get a booking for an event type with seats", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
|
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
|
||||||
@@ -469,62 +329,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should get a booking for a recurring event type with seats", async () => {
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[1].recurringBookingUid}`)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(200)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsGetRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsGetRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: GetRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
const expected = structuredClone(createdRecurringSeatedBooking);
|
|
||||||
for (const booking of expected) {
|
|
||||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delete booking.seatUid;
|
|
||||||
}
|
|
||||||
expect(data).toEqual(expected);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get a specific recurrence of a booking for a recurring event type with seats", async () => {
|
|
||||||
return request(app.getHttpServer())
|
|
||||||
.get(`/v2/bookings/${createdRecurringSeatedBooking[0].uid}`)
|
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
|
||||||
.expect(200)
|
|
||||||
.then(async (response) => {
|
|
||||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
|
||||||
expect(responseBody.data).toBeDefined();
|
|
||||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
|
||||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
|
||||||
const expected = structuredClone(createdRecurringSeatedBooking[0]);
|
|
||||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delete expected.seatUid;
|
|
||||||
expect(data).toEqual(expected);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get all seated bookings", async () => {
|
it("should get all seated bookings", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get("/v2/bookings?sortCreated=asc")
|
.get("/v2/bookings?sortCreated=asc")
|
||||||
@@ -534,7 +338,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
expect(responseBody.data).toBeDefined();
|
expect(responseBody.data).toBeDefined();
|
||||||
expect(responseBody.data.length).toEqual(3);
|
expect(responseBody.data.length).toEqual(1);
|
||||||
|
|
||||||
const seatedBooking = responseBody.data[0];
|
const seatedBooking = responseBody.data[0];
|
||||||
const seatedBookingExpected = structuredClone(createdSeatedBooking);
|
const seatedBookingExpected = structuredClone(createdSeatedBooking);
|
||||||
@@ -543,16 +347,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete seatedBookingExpected.seatUid;
|
delete seatedBookingExpected.seatUid;
|
||||||
expect(seatedBooking).toEqual(seatedBookingExpected);
|
expect(seatedBooking).toEqual(seatedBookingExpected);
|
||||||
|
|
||||||
const recurringSeatedBookings = [responseBody.data[1], responseBody.data[2]];
|
|
||||||
const recurringSeatedBookingsExpected = structuredClone(createdRecurringSeatedBooking);
|
|
||||||
for (const booking of recurringSeatedBookingsExpected) {
|
|
||||||
// note(Lauris): seatUid in get response resides only in each attendee object
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
delete booking.seatUid;
|
|
||||||
}
|
|
||||||
expect(recurringSeatedBookings).toEqual(recurringSeatedBookingsExpected);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -682,424 +476,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Recurring seated bookings creation", () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let organization: Team;
|
|
||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
|
||||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
|
||||||
let schedulesService: SchedulesService_2024_04_15;
|
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
|
||||||
let oAuthClient: PlatformOAuthClient;
|
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
|
||||||
|
|
||||||
const userEmail = `seated-bookings-user-${randomString()}@api.com`;
|
|
||||||
let user: User;
|
|
||||||
|
|
||||||
let seatedEventTypeId: number;
|
|
||||||
let recurringSeatedEventTypeId: number;
|
|
||||||
const maxRecurrenceCount = 3;
|
|
||||||
|
|
||||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
|
||||||
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
|
||||||
|
|
||||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
|
||||||
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
|
|
||||||
|
|
||||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
|
||||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
|
||||||
const emailAttendeeTwo = `seated-bookings-attendee2-${randomString()}@api.com`;
|
|
||||||
const nameAttendeeTwo = `Attendee Two ${randomString()}`;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await withApiAuth(
|
|
||||||
userEmail,
|
|
||||||
Test.createTestingModule({
|
|
||||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.overrideGuard(PermissionsGuard)
|
|
||||||
.useValue({
|
|
||||||
canActivate: () => true,
|
|
||||||
})
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
|
||||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
|
||||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
|
||||||
|
|
||||||
organization = await teamRepositoryFixture.create({
|
|
||||||
name: `seated-bookings-organization-${randomString()}`,
|
|
||||||
});
|
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
|
||||||
email: userEmail,
|
|
||||||
platformOAuthClients: {
|
|
||||||
connect: {
|
|
||||||
id: oAuthClient.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
|
||||||
name: `seated-bookings-2024-08-13-schedule-${randomString()}`,
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
isDefault: true,
|
|
||||||
};
|
|
||||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
|
||||||
const seatedEvent = await eventTypesRepositoryFixture.create(
|
|
||||||
{
|
|
||||||
title: `seated-bookings-2024-08-13-event-type-${randomString()}`,
|
|
||||||
slug: seatedEventSlug,
|
|
||||||
length: 60,
|
|
||||||
seatsPerTimeSlot: 3,
|
|
||||||
},
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
seatedEventTypeId = seatedEvent.id;
|
|
||||||
|
|
||||||
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
|
|
||||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
|
||||||
{
|
|
||||||
title: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
length: 60,
|
|
||||||
seatsPerTimeSlot: 3,
|
|
||||||
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
|
|
||||||
},
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
recurringSeatedEventTypeId = recurringSeatedEvent.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:5555"],
|
|
||||||
permissions: 32,
|
|
||||||
};
|
|
||||||
const secret = "secret";
|
|
||||||
|
|
||||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("should book a recurring event type with seats for the first time", async () => {
|
|
||||||
const recurrenceCount = 2;
|
|
||||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
|
||||||
eventTypeId: recurringSeatedEventTypeId,
|
|
||||||
attendee: {
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
email: emailAttendeeOne,
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
language: "it",
|
|
||||||
},
|
|
||||||
location: googleMeetUrl,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
codingLanguage: "TypeScript",
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
userId: "1000",
|
|
||||||
},
|
|
||||||
recurrenceCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
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(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
expect(data.length).toEqual(recurrenceCount);
|
|
||||||
|
|
||||||
const firstBooking = data[0];
|
|
||||||
expect(firstBooking.seatUid).toBeDefined();
|
|
||||||
const seatUid = firstBooking.seatUid;
|
|
||||||
expect(firstBooking.id).toBeDefined();
|
|
||||||
expect(firstBooking.uid).toBeDefined();
|
|
||||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(firstBooking.status).toEqual("accepted");
|
|
||||||
expect(firstBooking.start).toEqual(body.start);
|
|
||||||
expect(firstBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(firstBooking.duration).toEqual(60);
|
|
||||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(firstBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(firstBooking.attendees.length).toEqual(1);
|
|
||||||
expect(firstBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(firstBooking.location).toEqual(body.location);
|
|
||||||
expect(firstBooking.absentHost).toEqual(false);
|
|
||||||
|
|
||||||
const secondBooking = data[1];
|
|
||||||
expect(secondBooking.seatUid).toBeDefined();
|
|
||||||
const secondSeatUid = secondBooking.seatUid;
|
|
||||||
expect(secondBooking.id).toBeDefined();
|
|
||||||
expect(secondBooking.uid).toBeDefined();
|
|
||||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(secondBooking.status).toEqual("accepted");
|
|
||||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
|
||||||
expect(secondBooking.start).toEqual(expectedStart);
|
|
||||||
expect(secondBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(secondBooking.duration).toEqual(60);
|
|
||||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(secondBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees.length).toEqual(1);
|
|
||||||
expect(secondBooking.attendees[0]).toEqual({
|
|
||||||
name: body.attendee.name,
|
|
||||||
email: body.attendee.email,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: secondSeatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: body.attendee.name,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.location).toEqual(body.location);
|
|
||||||
expect(secondBooking.absentHost).toEqual(false);
|
|
||||||
createdRecurringSeatedBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should book a recurring event type with seats for the second time", async () => {
|
|
||||||
const recurrenceCount = 2;
|
|
||||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
|
||||||
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
|
|
||||||
eventTypeId: recurringSeatedEventTypeId,
|
|
||||||
attendee: {
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
email: emailAttendeeTwo,
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
language: "it",
|
|
||||||
},
|
|
||||||
location: googleMeetUrl,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
codingLanguage: "TypeScript",
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
userId: "2000",
|
|
||||||
},
|
|
||||||
recurrenceCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
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(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
|
|
||||||
|
|
||||||
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
|
|
||||||
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
|
|
||||||
expect(data.length).toEqual(recurrenceCount);
|
|
||||||
|
|
||||||
const firstBooking = data[0];
|
|
||||||
expect(firstBooking.seatUid).toBeDefined();
|
|
||||||
const seatUid = firstBooking.seatUid;
|
|
||||||
expect(firstBooking.id).toBeDefined();
|
|
||||||
expect(firstBooking.uid).toBeDefined();
|
|
||||||
expect(firstBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(firstBooking.status).toEqual("accepted");
|
|
||||||
expect(firstBooking.start).toEqual(body.start);
|
|
||||||
expect(firstBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(firstBooking.duration).toEqual(60);
|
|
||||||
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(firstBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(firstBooking.attendees.length).toEqual(2);
|
|
||||||
const firstAttendee = firstBooking.attendees.find(
|
|
||||||
(attendee) => attendee.name === nameAttendeeOne
|
|
||||||
);
|
|
||||||
expect(firstAttendee).toEqual({
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
email: emailAttendeeOne,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: createdRecurringSeatedBooking[0].seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
|
||||||
});
|
|
||||||
const secondAttendee = firstBooking.attendees.find(
|
|
||||||
(attendee) => attendee.name === nameAttendeeTwo
|
|
||||||
);
|
|
||||||
expect(secondAttendee).toEqual({
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
email: emailAttendeeTwo,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(firstBooking.location).toEqual(body.location);
|
|
||||||
expect(firstBooking.absentHost).toEqual(false);
|
|
||||||
|
|
||||||
const secondBooking = data[1];
|
|
||||||
expect(secondBooking.seatUid).toBeDefined();
|
|
||||||
const secondSeatUid = secondBooking.seatUid;
|
|
||||||
expect(secondBooking.id).toBeDefined();
|
|
||||||
expect(secondBooking.uid).toBeDefined();
|
|
||||||
expect(secondBooking.hosts[0].id).toEqual(user.id);
|
|
||||||
expect(secondBooking.status).toEqual("accepted");
|
|
||||||
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
|
|
||||||
expect(secondBooking.start).toEqual(expectedStart);
|
|
||||||
expect(secondBooking.end).toEqual(
|
|
||||||
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
|
||||||
);
|
|
||||||
expect(secondBooking.duration).toEqual(60);
|
|
||||||
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
|
|
||||||
expect(secondBooking.eventType).toEqual({
|
|
||||||
id: recurringSeatedEventTypeId,
|
|
||||||
slug: recurringSeatedEventSlug,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees.length).toEqual(2);
|
|
||||||
expect(secondBooking.attendees[0]).toEqual({
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
email: emailAttendeeOne,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: createdRecurringSeatedBooking[1].seatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeOne,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.attendees[1]).toEqual({
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
email: emailAttendeeTwo,
|
|
||||||
timeZone: body.attendee.timeZone,
|
|
||||||
language: body.attendee.language,
|
|
||||||
absent: false,
|
|
||||||
seatUid: secondSeatUid,
|
|
||||||
bookingFieldsResponses: {
|
|
||||||
name: nameAttendeeTwo,
|
|
||||||
...body.bookingFieldsResponses,
|
|
||||||
location: {
|
|
||||||
optionValue: googleMeetUrl,
|
|
||||||
value: "link",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: body.metadata,
|
|
||||||
});
|
|
||||||
expect(secondBooking.location).toEqual(body.location);
|
|
||||||
expect(secondBooking.absentHost).toEqual(false);
|
|
||||||
createdRecurringSeatedBooking = data;
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid response data - expected recurring booking but received non array response"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function responseDataIsCreateRecurringSeatedBooking(
|
|
||||||
data: any
|
|
||||||
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
|
|
||||||
return Array.isArray(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
|
|
||||||
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function responseDataIsGetRecurringSeatedBooking(
|
|
||||||
data: any
|
|
||||||
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
|
|
||||||
return Array.isArray(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
|
||||||
await teamRepositoryFixture.delete(organization.id);
|
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
|
||||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -39,7 +39,10 @@ import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
|||||||
describe("Bookings Endpoints 2024-08-13", () => {
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
describe("Team bookings", () => {
|
describe("Team bookings", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
const organizationSlug = `team-bookings-organization-${randomString()}`;
|
||||||
let organization: Team;
|
let organization: Team;
|
||||||
|
const team1Slug = `team-bookings-team1-${randomString()}`;
|
||||||
|
const team2Slug = `team-bookings-team2-${randomString()}`;
|
||||||
let team1: Team;
|
let team1: Team;
|
||||||
let team2: Team;
|
let team2: Team;
|
||||||
|
|
||||||
@@ -95,12 +98,15 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
|
||||||
organization = await organizationsRepositoryFixture.create({
|
organization = await organizationsRepositoryFixture.create({
|
||||||
name: `team-bookings-organization-${randomString()}`,
|
name: organizationSlug,
|
||||||
|
slug: organizationSlug,
|
||||||
|
isOrganization: true,
|
||||||
});
|
});
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
|
|
||||||
team1 = await teamRepositoryFixture.create({
|
team1 = await teamRepositoryFixture.create({
|
||||||
name: `team-bookings-team1-${randomString()}`,
|
name: team1Slug,
|
||||||
|
slug: team1Slug,
|
||||||
isOrganization: false,
|
isOrganization: false,
|
||||||
parent: { connect: { id: organization.id } },
|
parent: { connect: { id: organization.id } },
|
||||||
createdByOAuthClient: {
|
createdByOAuthClient: {
|
||||||
@@ -111,7 +117,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
team2 = await teamRepositoryFixture.create({
|
team2 = await teamRepositoryFixture.create({
|
||||||
name: `team-bookings-team2-${randomString()}`,
|
name: team2Slug,
|
||||||
|
slug: team2Slug,
|
||||||
isOrganization: false,
|
isOrganization: false,
|
||||||
parent: { connect: { id: organization.id } },
|
parent: { connect: { id: organization.id } },
|
||||||
createdByOAuthClient: {
|
createdByOAuthClient: {
|
||||||
@@ -668,6 +675,84 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("book using teamSlug, eventTypeSlug and organizationSlug", () => {
|
||||||
|
it("should not be able to book if missing organizationSlug", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
|
||||||
|
teamSlug: team1Slug,
|
||||||
|
eventTypeSlug: team1EventTypeSlug,
|
||||||
|
attendee: {
|
||||||
|
name: "alice",
|
||||||
|
email: "alice@gmail.com",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
language: "es",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(body)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should book using teamSlug and eventTypeSlug and organizationSlug", async () => {
|
||||||
|
const body: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
|
||||||
|
teamSlug: team1Slug,
|
||||||
|
eventTypeSlug: team1EventTypeSlug,
|
||||||
|
organizationSlug: organizationSlug,
|
||||||
|
attendee: {
|
||||||
|
name: "alice",
|
||||||
|
email: "alice@gmail.com",
|
||||||
|
timeZone: "Europe/Madrid",
|
||||||
|
language: "es",
|
||||||
|
},
|
||||||
|
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||||
|
};
|
||||||
|
|
||||||
|
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(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||||
|
|
||||||
|
if (responseDataIsBooking(responseBody.data)) {
|
||||||
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.uid).toBeDefined();
|
||||||
|
expect(data.hosts.length).toEqual(1);
|
||||||
|
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||||
|
expect(data.status).toEqual("accepted");
|
||||||
|
expect(data.start).toEqual(body.start);
|
||||||
|
expect(data.end).toEqual(new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString());
|
||||||
|
expect(data.duration).toEqual(60);
|
||||||
|
expect(data.eventTypeId).toEqual(team1EventTypeId);
|
||||||
|
expect(data.attendees.length).toEqual(1);
|
||||||
|
expect(data.attendees[0]).toEqual({
|
||||||
|
name: body.attendee.name,
|
||||||
|
email: body.attendee.email,
|
||||||
|
timeZone: body.attendee.timeZone,
|
||||||
|
language: body.attendee.language,
|
||||||
|
absent: false,
|
||||||
|
});
|
||||||
|
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||||
|
expect(data.absentHost).toEqual(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function createOAuthClient(organizationId: number) {
|
async function createOAuthClient(organizationId: number) {
|
||||||
const data = {
|
const data = {
|
||||||
logo: "logo-url",
|
logo: "logo-url",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
import {
|
import {
|
||||||
CreateEventTypeInput_2024_06_14,
|
CreateEventTypeInput_2024_06_14,
|
||||||
|
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||||
GetBookingOutput_2024_08_13,
|
GetBookingOutput_2024_08_13,
|
||||||
GetBookingsOutput_2024_08_13,
|
GetBookingsOutput_2024_08_13,
|
||||||
GetSeatedBookingOutput_2024_08_13,
|
GetSeatedBookingOutput_2024_08_13,
|
||||||
@@ -1692,11 +1693,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(
|
expect(response.body.error.message.includes(FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE)).toBe(
|
||||||
response.body.error.message.includes(
|
true
|
||||||
"Either eventTypeId OR (eventTypeSlug + username) must be provided"
|
);
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create a booking by username and event type slug", async () => {
|
it("should create a booking by username and event type slug", async () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class BookingUidGuard implements CanActivate {
|
|||||||
const bookingUid = request.params.bookingUid;
|
const bookingUid = request.params.bookingUid;
|
||||||
|
|
||||||
if (!bookingUid) {
|
if (!bookingUid) {
|
||||||
throw new BadRequestException("Booking UID missing in the request path");
|
throw new BadRequestException("BookingUidGuard - Booking UID missing in the request path");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import { BillingService } from "@/modules/billing/services/billing.service";
|
|||||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
|
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||||
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -77,7 +81,11 @@ export class BookingsService_2024_08_13 {
|
|||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly platformBookingsService: PlatformBookingsService,
|
private readonly platformBookingsService: PlatformBookingsService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
|
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||||
|
private readonly organizationsRepository: OrganizationsRepository,
|
||||||
|
private readonly teamsRepository: TeamsRepository,
|
||||||
|
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createBooking(request: Request, body: CreateBookingInput) {
|
async createBooking(request: Request, body: CreateBookingInput) {
|
||||||
@@ -94,6 +102,16 @@ export class BookingsService_2024_08_13 {
|
|||||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +141,12 @@ export class BookingsService_2024_08_13 {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message === "no_available_users_found_error") {
|
if (error.message === "no_available_users_found_error") {
|
||||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||||
|
} else if (error.message === "booking_time_out_of_bounds_error") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`The event type can't be booked at selected time ${body.start}. This could be because it's too soon (violating the minimum booking notice) or too far in the future (outside the event's scheduling window). Try fetching available slots first using the GET /v2/slots endpoint and then make a booking with "start" time equal to one of the available slots: https://cal.com/docs/api-reference/v2/slots`
|
||||||
|
);
|
||||||
|
} else if (error.message === "Attempting to book a meeting in the past.") {
|
||||||
|
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -141,10 +165,34 @@ export class BookingsService_2024_08_13 {
|
|||||||
user.id,
|
user.id,
|
||||||
body.eventTypeSlug
|
body.eventTypeSlug
|
||||||
);
|
);
|
||||||
|
} else if (body.teamSlug && body.eventTypeSlug) {
|
||||||
|
const team = await this.getBookedEventTypeTeam(body.teamSlug, body.organizationSlug);
|
||||||
|
if (!team) {
|
||||||
|
throw new NotFoundException(`Team with slug ${body.teamSlug} not found`);
|
||||||
|
}
|
||||||
|
return await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlugWithOwnerAndTeam(
|
||||||
|
team.id,
|
||||||
|
body.eventTypeSlug
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBookedEventTypeTeam(teamSlug: string, organizationSlug: string | undefined) {
|
||||||
|
if (!organizationSlug) {
|
||||||
|
return await this.teamsRepository.findTeamBySlug(teamSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
const organization = await this.organizationsRepository.findOrgBySlug(organizationSlug);
|
||||||
|
if (!organization) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`slots-input.service.ts: Organization with slug ${organizationSlug} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.organizationsTeamsRepository.findOrgTeamBySlug(organization.id, teamSlug);
|
||||||
|
}
|
||||||
|
|
||||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||||
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
|
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
|
||||||
if (!eventType?.bookingFields) {
|
if (!eventType?.bookingFields) {
|
||||||
@@ -181,6 +229,12 @@ export class BookingsService_2024_08_13 {
|
|||||||
body: CreateInstantBookingInput_2024_08_13,
|
body: CreateInstantBookingInput_2024_08_13,
|
||||||
eventType: EventTypeWithOwnerAndTeam
|
eventType: EventTypeWithOwnerAndTeam
|
||||||
) {
|
) {
|
||||||
|
if (!eventType.team?.id) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Instant bookings are only supported for team event types, not individual user event types."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||||
const booking = await handleInstantMeeting(bookingRequest);
|
const booking = await handleInstantMeeting(bookingRequest);
|
||||||
|
|
||||||
@@ -271,29 +325,37 @@ export class BookingsService_2024_08_13 {
|
|||||||
eventType: EventTypeWithOwnerAndTeam
|
eventType: EventTypeWithOwnerAndTeam
|
||||||
) {
|
) {
|
||||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||||
const booking = await handleNewBooking({
|
try {
|
||||||
bookingData: bookingRequest.body,
|
const booking = await handleNewBooking({
|
||||||
userId: bookingRequest.userId,
|
bookingData: bookingRequest.body,
|
||||||
hostname: bookingRequest.headers?.host || "",
|
userId: bookingRequest.userId,
|
||||||
platformClientId: bookingRequest.platformClientId,
|
hostname: bookingRequest.headers?.host || "",
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformClientId: bookingRequest.platformClientId,
|
||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
});
|
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||||
|
});
|
||||||
|
|
||||||
if (!booking.uid) {
|
if (!booking.uid) {
|
||||||
throw new Error("Booking missing uid");
|
throw new Error("Booking missing uid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseBooking =
|
||||||
|
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
|
||||||
|
if (!databaseBooking) {
|
||||||
|
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "booking_seats_full_error") {
|
||||||
|
throw new ConflictException("No more seats left at this seated booking.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(
|
|
||||||
booking.uid
|
|
||||||
);
|
|
||||||
if (!databaseBooking) {
|
|
||||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBooking(uid: string) {
|
async getBooking(uid: string) {
|
||||||
@@ -604,13 +666,24 @@ export class BookingsService_2024_08_13 {
|
|||||||
|
|
||||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||||
|
|
||||||
await roundRobinReassignment({
|
try {
|
||||||
bookingId: booking.id,
|
await roundRobinReassignment({
|
||||||
orgId: profile?.organizationId || null,
|
bookingId: booking.id,
|
||||||
emailsEnabled,
|
orgId: profile?.organizationId || null,
|
||||||
platformClientParams,
|
emailsEnabled,
|
||||||
reassignedById: requestUser.id,
|
platformClientParams,
|
||||||
});
|
reassignedById: requestUser.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "no_available_users_found_error") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Can't reassign the booking because no other host is available at that time."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||||
if (!reassigned) {
|
if (!reassigned) {
|
||||||
@@ -644,17 +717,28 @@ export class BookingsService_2024_08_13 {
|
|||||||
|
|
||||||
const profile = this.usersService.getUserMainProfile(user);
|
const profile = this.usersService.getUserMainProfile(user);
|
||||||
|
|
||||||
const reassigned = await roundRobinManualReassignment({
|
try {
|
||||||
bookingId: booking.id,
|
const reassigned = await roundRobinManualReassignment({
|
||||||
newUserId,
|
bookingId: booking.id,
|
||||||
orgId: profile?.organizationId || null,
|
newUserId,
|
||||||
reassignReason: body.reason,
|
orgId: profile?.organizationId || null,
|
||||||
reassignedById,
|
reassignReason: body.reason,
|
||||||
emailsEnabled,
|
reassignedById,
|
||||||
platformClientParams,
|
emailsEnabled,
|
||||||
});
|
platformClientParams,
|
||||||
|
});
|
||||||
|
|
||||||
return this.outputService.getOutputReassignedBooking(reassigned);
|
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === "invalid_round_robin_host") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`User with id=${newUserId} is not a valid Round Robin host - the user to which you reassign this booking must be one of the booking hosts. Fetch the booking using following endpoint and select id of one of the hosts: https://cal.com/docs/api-reference/v2/bookings/get-a-booking`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export class CalendarsController {
|
|||||||
@Headers("Authorization") authorization: string,
|
@Headers("Authorization") authorization: string,
|
||||||
@Param("calendar") calendar: string,
|
@Param("calendar") calendar: string,
|
||||||
@Query("redir") redir?: string | null,
|
@Query("redir") redir?: string | null,
|
||||||
@Query("isDryRun", ParseBoolPipe) isDryRun?: boolean
|
@Query("isDryRun", new ParseBoolPipe({ optional: true })) isDryRun?: boolean
|
||||||
): Promise<ApiResponse<{ authUrl: string }>> {
|
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||||
switch (calendar) {
|
switch (calendar) {
|
||||||
case OFFICE_365_CALENDAR:
|
case OFFICE_365_CALENDAR:
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { MeController } from "@/ee/me/me.controller";
|
import { MeController } from "@/ee/me/me.controller";
|
||||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule],
|
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule],
|
||||||
controllers: [MeController],
|
controllers: [MeController],
|
||||||
})
|
})
|
||||||
export class MeModule {}
|
export class MeModule {}
|
||||||
@@ -38,6 +38,10 @@ export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environme
|
|||||||
const value = process.env[key] as Environment[K] | undefined;
|
const value = process.env[key] as Environment[K] | undefined;
|
||||||
|
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
|
// handle fallback falsy cases that should still be used as value
|
||||||
|
if (fallback === false || fallback === "" || fallback === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allLimitsBlocked) {
|
if (allLimitsBlocked) {
|
||||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -96,7 +96,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -150,7 +150,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!apiKeyRecord) {
|
if (!apiKeyRecord) {
|
||||||
throw new UnauthorizedException("Invalid API Key");
|
throw new UnauthorizedException("CustomThrottlerGuard - Invalid API Key");
|
||||||
}
|
}
|
||||||
|
|
||||||
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export class EventTypesAtomService {
|
|||||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
|
||||||
|
const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged);
|
||||||
|
eventType.teamMembers = onlyManagedTeamMembers;
|
||||||
|
|
||||||
return eventType;
|
return eventType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
|
||||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
|
||||||
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
|
||||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PassportModule } from "@nestjs/passport";
|
import { PassportModule } from "@nestjs/passport";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -29,28 +23,8 @@ import { PassportModule } from "@nestjs/passport";
|
|||||||
TokensModule,
|
TokensModule,
|
||||||
DeploymentsModule,
|
DeploymentsModule,
|
||||||
ProfilesModule,
|
ProfilesModule,
|
||||||
forwardRef(() => OrganizationsModule),
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
NextAuthGuard,
|
|
||||||
NextAuthStrategy,
|
|
||||||
ApiAuthGuard,
|
|
||||||
ApiAuthStrategy,
|
|
||||||
OAuthFlowService,
|
|
||||||
IsTeamInOrg,
|
|
||||||
RolesGuard,
|
|
||||||
IsOrgGuard,
|
|
||||||
IsAdminAPIEnabledGuard,
|
|
||||||
PlatformPlanGuard,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
NextAuthGuard,
|
|
||||||
ApiAuthGuard,
|
|
||||||
IsTeamInOrg,
|
|
||||||
RolesGuard,
|
|
||||||
IsOrgGuard,
|
|
||||||
IsAdminAPIEnabledGuard,
|
|
||||||
PlatformPlanGuard,
|
|
||||||
],
|
],
|
||||||
|
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||||
|
exports: [NextAuthGuard, ApiAuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
@@ -104,13 +104,6 @@ describe("PlatformPlanGuard", () => {
|
|||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw ForbiddenException if the result is cached as false in Redis", async () => {
|
|
||||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
|
||||||
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
|
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
|
|
||||||
});
|
|
||||||
|
|
||||||
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
||||||
return createMock<ExecutionContext>({
|
return createMock<ExecutionContext>({
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
|
|||||||
@@ -16,65 +16,63 @@ export class PlatformPlanGuard implements CanActivate {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
const teamId = request.params.teamId as string;
|
|
||||||
const orgId = request.params.orgId as string;
|
|
||||||
const user = request.user as ApiAuthGuardUser;
|
|
||||||
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||||
const { canAccess } = await this.checkPlatformPlanAccess({ teamId, orgId, user, minimumPlan });
|
if (!minimumPlan) {
|
||||||
return canAccess;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const orgId = request.params.orgId;
|
||||||
|
const user = request.user as ApiAuthGuardUser;
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException("PlatformPlanGuard - No user associated with the request.");
|
||||||
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
throw new ForbiddenException("PlatformPlanGuard - No organization associated with the request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.checkPlatformPlanAccess(orgId, minimumPlan);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkPlatformPlanAccess({
|
async checkPlatformPlanAccess(orgId: string, minimumPlan: PlatformPlanType) {
|
||||||
teamId,
|
const REDIS_CACHE_KEY = `apiv2:org:${orgId}:guard:platformbilling:${minimumPlan}`;
|
||||||
orgId,
|
const cachedValue = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||||
user,
|
if (cachedValue !== null) {
|
||||||
minimumPlan,
|
return cachedValue === "true";
|
||||||
}: {
|
|
||||||
teamId?: string;
|
|
||||||
orgId?: string;
|
|
||||||
user: ApiAuthGuardUser;
|
|
||||||
minimumPlan: PlatformPlanType;
|
|
||||||
}): Promise<{ canAccess: boolean }> {
|
|
||||||
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
|
||||||
teamId ?? "none"
|
|
||||||
}:guard:platformbilling:${minimumPlan}`;
|
|
||||||
|
|
||||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
|
||||||
|
|
||||||
if (cachedAccess) {
|
|
||||||
return { canAccess: cachedAccess };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let canAccess = false;
|
const organization = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
||||||
|
const isPlatform = organization?.isPlatform;
|
||||||
|
const hasSubscription = organization?.platformBilling?.subscriptionId;
|
||||||
|
|
||||||
if (user && orgId) {
|
if (!organization) {
|
||||||
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
|
throw new ForbiddenException(`PlatformPlanGuard - No organization found with id=${orgId}.`);
|
||||||
const isPlatform = team?.isPlatform;
|
}
|
||||||
const hasSubscription = team?.platformBilling?.subscriptionId;
|
if (!isPlatform) {
|
||||||
|
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
|
||||||
if (!team) {
|
return true;
|
||||||
canAccess = false;
|
}
|
||||||
} else if (!isPlatform) {
|
if (!hasSubscription) {
|
||||||
canAccess = true;
|
throw new ForbiddenException(
|
||||||
} else if (!hasSubscription) {
|
`PlatformPlanGuard - No platform subscription found for organization with id=${orgId}.`
|
||||||
canAccess = false;
|
);
|
||||||
} else {
|
}
|
||||||
canAccess = hasMinimumPlan({
|
if (
|
||||||
currentPlan: team.platformBilling?.plan as PlatformPlanType,
|
!hasMinimumPlan({
|
||||||
minimumPlan: minimumPlan,
|
currentPlan: organization.platformBilling?.plan as PlatformPlanType,
|
||||||
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
minimumPlan: minimumPlan,
|
||||||
});
|
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
|
||||||
}
|
})
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`PlatformPlanGuard - organization with id=${orgId} does not have required plan for this operation. Minimum plan is ${minimumPlan} while the organization has ${
|
||||||
|
organization.platformBilling?.plan || "undefined"
|
||||||
|
}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
|
||||||
if (canAccess) {
|
return true;
|
||||||
return { canAccess };
|
|
||||||
}
|
|
||||||
throw new ForbiddenException(
|
|
||||||
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +88,7 @@ export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
|
|||||||
|
|
||||||
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
`PlatformPlanGuard - Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export class IsMembershipInOrg implements CanActivate {
|
|||||||
const orgId: string = request.params.orgId;
|
const orgId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new ForbiddenException("No org id found in request params.");
|
throw new ForbiddenException("IsMembershipInOrg - No org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!membershipId) {
|
if (!membershipId) {
|
||||||
throw new ForbiddenException("No membership id found in request params.");
|
throw new ForbiddenException("IsMembershipInOrg - No membership id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
const membership = await this.organizationsMembershipRepository.findOrgMembership(
|
||||||
@@ -33,7 +33,7 @@ export class IsMembershipInOrg implements CanActivate {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new NotFoundException(`Membership (${membershipId}) not found.`);
|
throw new NotFoundException(`IsMembershipInOrg - Membership (${membershipId}) not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.membership = membership;
|
request.membership = membership;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { NO_AUTH_PROVIDED_MESSAGE } from "@/modules/auth/strategies/api-auth/api
|
|||||||
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||||
handleRequest(error: Error, user: any) {
|
handleRequest(error: Error, user: any) {
|
||||||
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
||||||
const noAuthProvided = error && error.message === NO_AUTH_PROVIDED_MESSAGE;
|
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
|
||||||
if (user || noAuthProvided || !error) {
|
if (user || noAuthProvided || !error) {
|
||||||
return user || null;
|
return user || null;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -22,14 +22,19 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
|||||||
const organizationId: string = request.params.orgId;
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new ForbiddenException("No organization id found in request params.");
|
throw new ForbiddenException("IsAdminAPIEnabledGuard - No organization id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
||||||
if (organization) {
|
if (organization) {
|
||||||
request.organization = organization;
|
request.organization = organization;
|
||||||
}
|
}
|
||||||
return canAccess;
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`IsAdminAPIEnabledGuard - Organization with id=${organizationId} does not have Admin API access. Please contact https://cal.com/sales to upgrade.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkAdminAPIEnabled(
|
async checkAdminAPIEnabled(
|
||||||
@@ -57,18 +62,21 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
|||||||
);
|
);
|
||||||
if (!adminAPIAccessIsEnabledInOrg) {
|
if (!adminAPIAccessIsEnabledInOrg) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
`IsAdminAPIEnabledGuard - Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
canAccess = true;
|
canAccess = true;
|
||||||
org &&
|
|
||||||
(await this.redisService.redis.set(
|
if (org && canAccess) {
|
||||||
|
await this.redisService.redis.set(
|
||||||
REDIS_CACHE_KEY,
|
REDIS_CACHE_KEY,
|
||||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||||
"EX",
|
"EX",
|
||||||
300
|
300
|
||||||
));
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { canAccess, organization: org };
|
return { canAccess, organization: org };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-11
@@ -1,11 +1,5 @@
|
|||||||
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
||||||
import {
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
Injectable,
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
@@ -20,11 +14,11 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
|
|||||||
const managerOrgId: string = request.params.orgId;
|
const managerOrgId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!managerOrgId) {
|
if (!managerOrgId) {
|
||||||
throw new ForbiddenException("No manager org id found in request params.");
|
throw new ForbiddenException("IsManagedOrgInManagerOrg - No manager org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!managedOrgId) {
|
if (!managedOrgId) {
|
||||||
throw new ForbiddenException("No managed org id found in request params.");
|
throw new ForbiddenException("IsManagedOrgInManagerOrg - No managed org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const managedOrganization = await this.managedOrganizationsRepository.getByManagerManagedIds(
|
const managedOrganization = await this.managedOrganizationsRepository.getByManagerManagedIds(
|
||||||
@@ -33,8 +27,8 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!managedOrganization) {
|
if (!managedOrganization) {
|
||||||
throw new NotFoundException(
|
throw new ForbiddenException(
|
||||||
`Managed organization with id=${managedOrgId} within manager organization with id=${managerOrgId} not found.`
|
`IsManagedOrgInManagerOrg - Managed organization with id=${managedOrgId} is not owned by manager organization with id=${managerOrgId}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class IsOrgGuard implements CanActivate {
|
|||||||
const organizationId: string = request.params.orgId;
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new ForbiddenException("No organization id found in request params.");
|
throw new ForbiddenException("IsOrgGuard - No organization id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canAccess, org } = await this.checkOrgAccess(organizationId);
|
const { canAccess, org } = await this.checkOrgAccess(organizationId);
|
||||||
@@ -31,7 +31,13 @@ export class IsOrgGuard implements CanActivate {
|
|||||||
request.organization = org;
|
request.organization = org;
|
||||||
}
|
}
|
||||||
|
|
||||||
return canAccess;
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`IsOrgGuard - provided organization id=${organizationId} does not represent any existing organization.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
|
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
|
||||||
@@ -55,7 +61,7 @@ export class IsOrgGuard implements CanActivate {
|
|||||||
canAccess = true;
|
canAccess = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (org) {
|
if (org && canAccess) {
|
||||||
await this.redisService.redis.set(
|
await this.redisService.redis.set(
|
||||||
REDIS_CACHE_KEY,
|
REDIS_CACHE_KEY,
|
||||||
JSON.stringify({ org, canAccess } satisfies CachedData),
|
JSON.stringify({ org, canAccess } satisfies CachedData),
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ export class IsWebhookInOrg implements CanActivate {
|
|||||||
const organizationId: string = request.params.orgId;
|
const organizationId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new ForbiddenException("No organization id found in request params.");
|
throw new ForbiddenException("IsWebhookInOrg - No organization id found in request params.");
|
||||||
}
|
}
|
||||||
if (!webhookId) {
|
if (!webhookId) {
|
||||||
throw new ForbiddenException("No webhook id found in request params.");
|
throw new ForbiddenException("IsWebhookInOrg - No webhook id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`;
|
const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`;
|
||||||
@@ -53,7 +53,7 @@ export class IsWebhookInOrg implements CanActivate {
|
|||||||
if (isWebhookInOrg) canAccess = true;
|
if (isWebhookInOrg) canAccess = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (org) {
|
if (org && canAccess) {
|
||||||
await this.redisService.redis.set(
|
await this.redisService.redis.set(
|
||||||
REDIS_CACHE_KEY,
|
REDIS_CACHE_KEY,
|
||||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||||
@@ -62,6 +62,12 @@ export class IsWebhookInOrg implements CanActivate {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return canAccess;
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`IsWebhookInOrg - webhook with id=${webhookId} is not part of the organization with id=${organizationId}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
|
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { createMock } from "@golevelup/ts-jest";
|
import { createMock } from "@golevelup/ts-jest";
|
||||||
import { ExecutionContext } from "@nestjs/common";
|
import { ExecutionContext } from "@nestjs/common";
|
||||||
@@ -28,7 +29,21 @@ describe("PermissionsGuard", () => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
createMock<OAuthClientRepository>()
|
createMock<OAuthClientRepository>(),
|
||||||
|
createMock<OAuthClientsOutputService>({
|
||||||
|
transformOAuthClientPermission: jest.fn().mockImplementation((permission: number) => {
|
||||||
|
switch (permission) {
|
||||||
|
case SCHEDULE_WRITE:
|
||||||
|
return "SCHEDULE_WRITE";
|
||||||
|
case SCHEDULE_READ:
|
||||||
|
return "SCHEDULE_READ";
|
||||||
|
case APPS_WRITE:
|
||||||
|
return "APPS_WRITE";
|
||||||
|
default:
|
||||||
|
return "UNKNOWN";
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,13 +51,22 @@ describe("PermissionsGuard", () => {
|
|||||||
expect(guard).toBeDefined();
|
expect(guard).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getMockOAuthClient(permissions: number) {
|
||||||
|
return {
|
||||||
|
id: "100",
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("when access token is missing", () => {
|
describe("when access token is missing", () => {
|
||||||
it("should return false", async () => {
|
it("should return false", async () => {
|
||||||
const mockContext = createMockExecutionContext({});
|
const mockContext = createMockExecutionContext({});
|
||||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
|
jest.spyOn(guard, "getOAuthClientByAccessToken").mockResolvedValue(getMockOAuthClient(0));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +77,9 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +90,9 @@ describe("PermissionsGuard", () => {
|
|||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
oAuthClientPermissions |= SCHEDULE_READ;
|
oAuthClientPermissions |= SCHEDULE_READ;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
@@ -75,7 +103,9 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,9 +115,13 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= APPS_WRITE;
|
oAuthClientPermissions |= APPS_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false for a missing permission", async () => {
|
it("should return false for a missing permission", async () => {
|
||||||
@@ -96,9 +130,13 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
jest
|
||||||
|
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||||
|
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,7 +147,7 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +158,7 @@ describe("PermissionsGuard", () => {
|
|||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
oAuthClientPermissions |= SCHEDULE_READ;
|
oAuthClientPermissions |= SCHEDULE_READ;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
@@ -131,7 +169,7 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,9 +179,11 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= APPS_WRITE;
|
oAuthClientPermissions |= APPS_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false for a missing permission", async () => {
|
it("should return false for a missing permission", async () => {
|
||||||
@@ -152,9 +192,11 @@ describe("PermissionsGuard", () => {
|
|||||||
|
|
||||||
let oAuthClientPermissions = 0;
|
let oAuthClientPermissions = 0;
|
||||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||||
|
|
||||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||||
|
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { isApiKey } from "@/lib/api-key";
|
import { isApiKey } from "@/lib/api-key";
|
||||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
|
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||||
import { hasPermissions } from "@calcom/platform-utils";
|
import { hasPermissions } from "@calcom/platform-utils";
|
||||||
|
import { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionsGuard implements CanActivate {
|
export class PermissionsGuard implements CanActivate {
|
||||||
@@ -16,7 +18,8 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
private reflector: Reflector,
|
private reflector: Reflector,
|
||||||
private tokensRepository: TokensRepository,
|
private tokensRepository: TokensRepository,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly oAuthClientRepository: OAuthClientRepository
|
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||||
|
private readonly oAuthClientsOutputService: OAuthClientsOutputService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
@@ -27,42 +30,61 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||||
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
||||||
|
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
|
||||||
|
|
||||||
if (nextAuthToken) {
|
// only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key
|
||||||
|
if (nextAuthToken || apiKey) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authString && !oAuthClientId) {
|
if (!bearerToken && !oAuthClientId) {
|
||||||
return false;
|
throw new ForbiddenException(
|
||||||
|
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// only check permissions for accessTokens attached to an oAuth Client
|
const oAuthClient = bearerToken
|
||||||
if (isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_")) {
|
? await this.getOAuthClientByAccessToken(bearerToken)
|
||||||
return true;
|
: await this.getOAuthClientById(oAuthClientId);
|
||||||
|
|
||||||
|
const hasRequiredPermissions = hasPermissions(oAuthClient.permissions, [...requiredPermissions]);
|
||||||
|
|
||||||
|
if (!hasRequiredPermissions) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`PermissionsGuard - oAuth client with id=${
|
||||||
|
oAuthClient.id
|
||||||
|
} does not have the required permissions=${requiredPermissions
|
||||||
|
.map((permission) => this.oAuthClientsOutputService.transformOAuthClientPermission(permission))
|
||||||
|
.join(
|
||||||
|
", "
|
||||||
|
)}. Go to platform dashboard settings and add the required permissions to the oAuth client.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oAuthClientPermissions = authString
|
return true;
|
||||||
? await this.getOAuthClientPermissionsByAccessToken(authString)
|
|
||||||
: await this.getOAuthClientPermissionsById(oAuthClientId);
|
|
||||||
|
|
||||||
if (!oAuthClientPermissions) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
|
async getOAuthClientByAccessToken(
|
||||||
|
accessToken: string
|
||||||
|
): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
|
||||||
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
|
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||||
return oAuthClient?.permissions;
|
if (!oAuthClient) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`PermissionsGuard - no oAuth client found for access token=${accessToken}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return oAuthClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOAuthClientPermissionsById(id: string) {
|
async getOAuthClientById(id: string): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
|
||||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
|
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
|
||||||
return oAuthClient?.permissions;
|
if (!oAuthClient) {
|
||||||
|
throw new ForbiddenException(`PermissionsGuard - no oAuth client found for client id=${id}`);
|
||||||
|
}
|
||||||
|
return oAuthClient;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,8 +25,27 @@ export class RolesGuard implements CanActivate {
|
|||||||
const user = request.user as ApiAuthGuardUser;
|
const user = request.user as ApiAuthGuardUser;
|
||||||
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
||||||
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
|
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
|
||||||
return canAccess;
|
|
||||||
|
if (!canAccess) {
|
||||||
|
this.throwForbiddenError(user, orgId, teamId, allowedRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throwForbiddenError(user: ApiAuthGuardUser, orgId: string, teamId: string, allowedRole: string) {
|
||||||
|
let errorMessage = `RolesGuard - user with id=${user.id} does not have the minimum required role=${allowedRole} within`;
|
||||||
|
if (orgId) {
|
||||||
|
errorMessage += ` organization with id=${orgId}`;
|
||||||
|
}
|
||||||
|
if (teamId) {
|
||||||
|
errorMessage += ` team with id=${teamId}`;
|
||||||
|
}
|
||||||
|
errorMessage += `.`;
|
||||||
|
|
||||||
|
throw new ForbiddenException(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
async checkUserRoleAccess(
|
async checkUserRoleAccess(
|
||||||
user: ApiAuthGuardUser,
|
user: ApiAuthGuardUser,
|
||||||
orgId: string,
|
orgId: string,
|
||||||
@@ -67,7 +86,9 @@ export class RolesGuard implements CanActivate {
|
|||||||
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
|
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
|
||||||
throw new ForbiddenException(`User is not a member of the organization.`);
|
throw new ForbiddenException(
|
||||||
|
`RolesGuard - User is not a member of the organization with id=${orgId}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
|
||||||
@@ -84,7 +105,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
|
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
|
||||||
throw new ForbiddenException(`User is not a member of the team.`);
|
throw new ForbiddenException(`RolesGuard - User is not a member of the team with id=${teamId}.`);
|
||||||
}
|
}
|
||||||
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
|
||||||
canAccess = hasMinimumRole({
|
canAccess = hasMinimumRole({
|
||||||
@@ -102,7 +123,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
|
|
||||||
if (!orgMembership) {
|
if (!orgMembership) {
|
||||||
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
|
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
|
||||||
throw new ForbiddenException(`User is not part of the organization.`);
|
throw new ForbiddenException(`RolesGuard - User is not part of the organization with id=${orgId}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the role checked is a TEAM role
|
// if the role checked is a TEAM role
|
||||||
@@ -116,7 +137,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
|
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
|
||||||
);
|
);
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"User is not part of the team and/or, is not an admin nor an owner of the organization."
|
`RolesGuard - User is not part of the team with id=${teamId} and/or, is not an admin nor an owner of the organization with id=${orgId}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +159,11 @@ export class RolesGuard implements CanActivate {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
|
||||||
|
if (canAccess) {
|
||||||
|
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||||
|
}
|
||||||
|
|
||||||
return { canAccess };
|
return { canAccess };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +190,7 @@ export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
|
|||||||
|
|
||||||
// minimum role given does not exist
|
// minimum role given does not exist
|
||||||
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
|
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
|
||||||
throw new Error("Invalid role");
|
throw new Error("RolesGuard - Invalid role");
|
||||||
}
|
}
|
||||||
|
|
||||||
return checkedRoleIndex <= requiredRoleIndex;
|
return checkedRoleIndex <= requiredRoleIndex;
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { RoutingFormsRepository } from "@/modules/routing-forms/routing-forms.repository";
|
import { RoutingFormsRepository } from "@/modules/routing-forms/routing-forms.repository";
|
||||||
import {
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||||
Injectable,
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import { Team } from "@calcom/prisma/client";
|
import { Team } from "@calcom/prisma/client";
|
||||||
@@ -20,18 +14,18 @@ export class IsRoutingFormInTeam implements CanActivate {
|
|||||||
const routingFormId: string = request.params.routingFormId;
|
const routingFormId: string = request.params.routingFormId;
|
||||||
|
|
||||||
if (!routingFormId) {
|
if (!routingFormId) {
|
||||||
throw new ForbiddenException("No routing form id found in request params.");
|
throw new ForbiddenException("IsRoutingFormInTeam - No routing form id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
throw new ForbiddenException("No team id found in request params.");
|
throw new ForbiddenException("IsRoutingFormInTeam - No team id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const routingForm = await this.routingFormsRepository.getTeamRoutingForm(Number(teamId), routingFormId);
|
const routingForm = await this.routingFormsRepository.getTeamRoutingForm(Number(teamId), routingFormId);
|
||||||
|
|
||||||
if (!routingForm) {
|
if (!routingForm) {
|
||||||
throw new NotFoundException(
|
throw new ForbiddenException(
|
||||||
`Team with id=(${teamId}) routing form with id=(${routingFormId}) not found.`
|
`IsRoutingFormInTeam - team with id=(${teamId}) does not own routing form with id=(${routingFormId}).`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,26 +20,30 @@ export class IsTeamInOrg implements CanActivate {
|
|||||||
const orgId: string = request.params.orgId;
|
const orgId: string = request.params.orgId;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new ForbiddenException("No org id found in request params.");
|
throw new ForbiddenException("IsTeamInOrg - No org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
throw new ForbiddenException("No team id found in request params.");
|
throw new ForbiddenException("IsTeamInOrg - No team id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
|
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
|
||||||
|
|
||||||
if (canAccess && team) {
|
if (!canAccess) {
|
||||||
request.team = team;
|
throw new ForbiddenException(
|
||||||
|
`IsTeamInOrg - Team with id=${teamId} is not part of the organization with id=${orgId}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return canAccess;
|
|
||||||
|
request.team = team;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
|
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
|
||||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||||
|
|
||||||
if (!team) {
|
if (!team) {
|
||||||
throw new NotFoundException(`Team (${teamId}) not found.`);
|
throw new NotFoundException(`IsTeamInOrg - Team (${teamId}) not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!team.isOrganization && team.parentId === Number(orgId)) {
|
if (!team.isOrganization && team.parentId === Number(orgId)) {
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ export class IsUserInOrgTeam implements CanActivate {
|
|||||||
const userId: string = request.params.userId;
|
const userId: string = request.params.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new ForbiddenException("No user id found in request params.");
|
throw new ForbiddenException("IsUserInOrgTeam - No user id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new ForbiddenException("No org id found in request params.");
|
throw new ForbiddenException("IsUserInOrgTeam - No org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
throw new ForbiddenException("No team id found in request params.");
|
throw new ForbiddenException("IsUserInOrgTeam - No team id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.organizationsRepository.findOrgTeamUser(
|
const user = await this.organizationsRepository.findOrgTeamUser(
|
||||||
@@ -37,6 +37,8 @@ export class IsUserInOrgTeam implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
throw new ForbiddenException(
|
||||||
|
`IsUserInOrgTeam - user with id=(${userId}) is not part of the team with id=(${teamId}) in the organization with id=(${orgId})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,20 +14,22 @@ export class IsUserInOrg implements CanActivate {
|
|||||||
const userId: string = request.params.userId;
|
const userId: string = request.params.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new ForbiddenException("No user id found in request params.");
|
throw new ForbiddenException("IsUserInOrg - No user id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new ForbiddenException("No org id found in request params.");
|
throw new ForbiddenException("IsUserInOrg - No org id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
|
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
|
||||||
|
|
||||||
if (user) {
|
if (!user) {
|
||||||
request.user = user;
|
throw new ForbiddenException(
|
||||||
return true;
|
`IsUserInOrg - user with id=${userId} is not part of the organization with id=${orgId}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
request.user = user;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,13 +70,15 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
return await this.authenticateNextAuth(nextAuthToken, request);
|
return await this.authenticateNextAuth(nextAuthToken, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new UnauthorizedException(NO_AUTH_PROVIDED_MESSAGE);
|
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return this.error(err);
|
return this.error(err);
|
||||||
}
|
}
|
||||||
return this.error(
|
return this.error(
|
||||||
new InternalServerErrorException("An error occurred while authenticating the request")
|
new InternalServerErrorException(
|
||||||
|
"ApiAuthStrategy - An error occurred while authenticating the request"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,23 +108,29 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`);
|
throw new UnauthorizedException(
|
||||||
|
`ApiAuthStrategy - oAuth client - Client with ID ${oAuthClientId} not found`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.secret !== oAuthClientSecret) {
|
if (client.secret !== oAuthClientSecret) {
|
||||||
throw new UnauthorizedException("Invalid client secret");
|
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
||||||
|
|
||||||
if (!platformCreatorId) {
|
if (!platformCreatorId) {
|
||||||
throw new UnauthorizedException("No owner ID found for this OAuth client");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - oAuth client - No owner ID found for this OAuth client"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
|
const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("No user associated with the provided OAuth client");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - oAuth client - No user associated with the provided OAuth client"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.organizationId = client.organizationId;
|
request.organizationId = client.organizationId;
|
||||||
@@ -141,7 +149,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
: await this.accessTokenStrategy(authString, request, requestOrigin);
|
: await this.accessTokenStrategy(authString, request, requestOrigin);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return this.error(new UnauthorizedException("No user associated with the provided token"));
|
return this.error(
|
||||||
|
new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - bearer token - No user associated with the provided token"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.success(this.getSuccessUser(user));
|
return this.success(this.getSuccessUser(user));
|
||||||
@@ -158,24 +170,26 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
async apiKeyStrategy(apiKey: string, request: ApiAuthGuardRequest) {
|
async apiKeyStrategy(apiKey: string, request: ApiAuthGuardRequest) {
|
||||||
const isLicenseValid = await this.deploymentsService.checkLicense();
|
const isLicenseValid = await this.deploymentsService.checkLicense();
|
||||||
if (!isLicenseValid) {
|
if (!isLicenseValid) {
|
||||||
throw new UnauthorizedException("Invalid or missing CALCOM_LICENSE_KEY environment variable");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - api key - Invalid or missing CALCOM_LICENSE_KEY environment variable"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
||||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
if (!keyData) {
|
if (!keyData) {
|
||||||
throw new UnauthorizedException("Your api key is not valid");
|
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isKeyExpired =
|
const isKeyExpired =
|
||||||
keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0);
|
keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0);
|
||||||
if (isKeyExpired) {
|
if (isKeyExpired) {
|
||||||
throw new UnauthorizedException("Your api key is expired");
|
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKeyOwnerId = keyData.userId;
|
const apiKeyOwnerId = keyData.userId;
|
||||||
if (!apiKeyOwnerId) {
|
if (!apiKeyOwnerId) {
|
||||||
throw new UnauthorizedException("No user tied to this apiKey");
|
throw new UnauthorizedException("ApiAuthStrategy - api key - No user tied to this apiKey");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId);
|
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId);
|
||||||
@@ -187,29 +201,35 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
async accessTokenStrategy(accessToken: string, request: ApiAuthGuardRequest, origin?: string) {
|
async accessTokenStrategy(accessToken: string, request: ApiAuthGuardRequest, origin?: string) {
|
||||||
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
|
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
|
||||||
if (!accessTokenValid) {
|
if (!accessTokenValid) {
|
||||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
throw new UnauthorizedException(`ApiAuthStrategy - access token - ${INVALID_ACCESS_TOKEN}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await this.tokensRepository.getAccessTokenClient(accessToken);
|
const client = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new UnauthorizedException("OAuth client not found given the access token");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - access token - OAuth client not found given the access token"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (origin && !isOriginAllowed(origin, client.redirectUris)) {
|
if (origin && !isOriginAllowed(origin, client.redirectUris)) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
`Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'`
|
`ApiAuthStrategy - access token - Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||||
|
|
||||||
if (!ownerId) {
|
if (!ownerId) {
|
||||||
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
|
throw new UnauthorizedException(
|
||||||
|
`ApiAuthStrategy - access token - ${INVALID_ACCESS_TOKEN}. No owner found for this access token.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId);
|
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("User associated with the authentication api key not found.");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - access token - User associated with the access token not found."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||||
@@ -220,12 +240,16 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
|
|
||||||
async nextAuthStrategy(token: { email?: string | null }, request: ApiAuthGuardRequest) {
|
async nextAuthStrategy(token: { email?: string | null }, request: ApiAuthGuardRequest) {
|
||||||
if (!token.email) {
|
if (!token.email) {
|
||||||
throw new UnauthorizedException("Email not found in the authentication token.");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - next auth - Email not found in the authentication token."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findByEmailWithProfile(token.email);
|
const user = await this.userRepository.findByEmailWithProfile(token.email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("User associated with the authentication token email not found.");
|
throw new UnauthorizedException(
|
||||||
|
"ApiAuthStrategy - next auth - User associated with the authentication token email not found."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
const organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||||
request.organizationId = organizationId;
|
request.organizationId = organizationId;
|
||||||
|
|||||||
@@ -18,23 +18,27 @@ export class NextAuthStrategy extends PassportStrategy(NextAuthPassportStrategy,
|
|||||||
const payload = await getToken({ req, secret: nextAuthSecret });
|
const payload = await getToken({ req, secret: nextAuthSecret });
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new UnauthorizedException("Authentication token is missing or invalid.");
|
throw new UnauthorizedException("NextAuthStrategy - Authentication token is missing or invalid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload.email) {
|
if (!payload.email) {
|
||||||
throw new UnauthorizedException("Email not found in the authentication token.");
|
throw new UnauthorizedException("NextAuthStrategy - Email not found in the authentication token.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.findByEmailWithProfile(payload.email);
|
const user = await this.userRepository.findByEmailWithProfile(payload.email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("User associated with the authentication token email not found.");
|
throw new UnauthorizedException(
|
||||||
|
"NextAuthStrategy - User associated with the authentication token email not found."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.success(user);
|
return this.success(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) return this.error(error);
|
if (error instanceof Error) return this.error(error);
|
||||||
return this.error(
|
return this.error(
|
||||||
new InternalServerErrorException("An error occurred while authenticating the request")
|
new InternalServerErrorException(
|
||||||
|
"NextAuthStrategy - An error occurred while authenticating the request"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
|||||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
|
imports: [PrismaModule, ConfigModule, HttpModule],
|
||||||
providers: [
|
providers: [
|
||||||
ConferencingService,
|
ConferencingService,
|
||||||
ConferencingRepository,
|
ConferencingRepository,
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ import {
|
|||||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
|
import { HttpService } from "@nestjs/axios";
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@@ -32,10 +31,10 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Headers,
|
Headers,
|
||||||
Redirect,
|
Redirect,
|
||||||
UnauthorizedException,
|
|
||||||
Req,
|
Req,
|
||||||
HttpException,
|
HttpException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { plainToInstance } from "class-transformer";
|
import { plainToInstance } from "class-transformer";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -60,9 +59,9 @@ export class ConferencingController {
|
|||||||
private readonly logger = new Logger("ConferencingController");
|
private readonly logger = new Logger("ConferencingController");
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokensRepository: TokensRepository,
|
|
||||||
private readonly conferencingService: ConferencingService,
|
private readonly conferencingService: ConferencingService,
|
||||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
private readonly config: ConfigService,
|
||||||
|
private readonly httpService: HttpService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("/:app/connect")
|
@Post("/:app/connect")
|
||||||
@@ -120,6 +119,14 @@ export class ConferencingController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles saving conferencing app credentials.
|
||||||
|
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
|
||||||
|
* otherwise, credentials are saved at the user level.
|
||||||
|
*
|
||||||
|
* Proxying ensures that permission checks—such as whether the user is allowed to install conferencing app for a team or organization—
|
||||||
|
* are enforced via controller route guards, avoiding duplication of this logic within the service layer.
|
||||||
|
*/
|
||||||
@Get("/:app/oauth/callback")
|
@Get("/:app/oauth/callback")
|
||||||
@UseGuards()
|
@UseGuards()
|
||||||
@Redirect(undefined, 301)
|
@Redirect(undefined, 301)
|
||||||
@@ -143,23 +150,28 @@ export class ConferencingController {
|
|||||||
|
|
||||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||||
try {
|
try {
|
||||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new BadRequestException(error_description);
|
throw new BadRequestException(error_description);
|
||||||
}
|
}
|
||||||
if (!userId) {
|
|
||||||
throw new UnauthorizedException("Invalid Access token.");
|
if (decodedCallbackState.teamId && decodedCallbackState.orgId) {
|
||||||
}
|
const apiUrl = this.config.get("api.url");
|
||||||
if (decodedCallbackState.orgId) {
|
const url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/conferencing/${app}/oauth/callback`;
|
||||||
return this.organizationsConferencingService.connectTeamOauthApps({
|
const params: Record<string, string | undefined> = { state, code, error, error_description };
|
||||||
app,
|
const headers = {
|
||||||
code,
|
Authorization: `Bearer ${decodedCallbackState.accessToken}`,
|
||||||
userId,
|
};
|
||||||
decodedCallbackState,
|
try {
|
||||||
});
|
const response = await this.httpService.axiosRef.get(url, { params, headers });
|
||||||
} else {
|
const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || "";
|
||||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
|
return { url: redirectUrl };
|
||||||
|
} catch (err) {
|
||||||
|
const fallbackUrl = decodedCallbackState.onErrorReturnTo || "";
|
||||||
|
return { url: fallbackUrl };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException || error instanceof Error) {
|
if (error instanceof HttpException || error instanceof Error) {
|
||||||
this.logger.error(error.message);
|
this.logger.error(error.message);
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import { ConferencingRepository } from "@/modules/conferencing/repositories/conf
|
|||||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +31,7 @@ export class ConferencingService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly conferencingRepository: ConferencingRepository,
|
private readonly conferencingRepository: ConferencingRepository,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
|
private readonly tokensRepository: TokensRepository,
|
||||||
private readonly googleMeetService: GoogleMeetService,
|
private readonly googleMeetService: GoogleMeetService,
|
||||||
private readonly zoomVideoService: ZoomVideoService,
|
private readonly zoomVideoService: ZoomVideoService,
|
||||||
private readonly office365VideoService: Office365VideoService
|
private readonly office365VideoService: Office365VideoService
|
||||||
@@ -47,10 +54,13 @@ export class ConferencingService {
|
|||||||
async connectOauthApps(
|
async connectOauthApps(
|
||||||
app: string,
|
app: string,
|
||||||
code: string,
|
code: string,
|
||||||
userId: number,
|
|
||||||
decodedCallbackState: OAuthCallbackState,
|
decodedCallbackState: OAuthCallbackState,
|
||||||
teamId?: number
|
teamId?: number
|
||||||
) {
|
) {
|
||||||
|
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException("Invalid Access token.");
|
||||||
|
}
|
||||||
switch (app) {
|
switch (app) {
|
||||||
case ZOOM:
|
case ZOOM:
|
||||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
|
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
|
|
||||||
const CACHING_TIME = 86400000; // 24 hours in milliseconds
|
const CACHING_TIME = 86400000; // 24 hours in milliseconds
|
||||||
|
|
||||||
const getLicenseCacheKey = (key: string) => `api-v2-license-key-url-${key}`;
|
const getLicenseCacheKey = (key: string) => `api-v2-license-key-goblin-url-${key}`;
|
||||||
|
|
||||||
type LicenseCheckResponse = {
|
type LicenseCheckResponse = {
|
||||||
valid: boolean;
|
status: boolean;
|
||||||
};
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeploymentsService {
|
export class DeploymentsService {
|
||||||
@@ -36,12 +36,12 @@ export class DeploymentsService {
|
|||||||
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`;
|
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`;
|
||||||
const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey));
|
const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey));
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
return (JSON.parse(cachedData) as LicenseCheckResponse)?.valid;
|
return (JSON.parse(cachedData) as LicenseCheckResponse)?.status;
|
||||||
}
|
}
|
||||||
const response = await fetch(licenseKeyUrl, { mode: "cors" });
|
const response = await fetch(licenseKeyUrl, { mode: "cors" });
|
||||||
const data = (await response.json()) as LicenseCheckResponse;
|
const data = (await response.json()) as LicenseCheckResponse;
|
||||||
const cacheKey = getLicenseCacheKey(licenseKey);
|
const cacheKey = getLicenseCacheKey(licenseKey);
|
||||||
this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME);
|
this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME);
|
||||||
return data.valid;
|
return data.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-2
@@ -16,8 +16,6 @@ export class DestinationCalendarsService {
|
|||||||
userId: number,
|
userId: number,
|
||||||
delegationCredentialId?: string
|
delegationCredentialId?: string
|
||||||
) {
|
) {
|
||||||
// note(Lauris): todo remove the log but leaving this now to confirm delegationCredentialId is received
|
|
||||||
console.log("debug: delegationCredentialId", delegationCredentialId);
|
|
||||||
const userCalendars = await this.calendarsService.getCalendars(userId);
|
const userCalendars = await this.calendarsService.getCalendars(userId);
|
||||||
const allCalendars: Calendar[] = userCalendars.connectedCalendars
|
const allCalendars: Calendar[] = userCalendars.connectedCalendars
|
||||||
.map((cal: ConnectedCalendar) => cal.calendars ?? [])
|
.map((cal: ConnectedCalendar) => cal.calendars ?? [])
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Request } from "express";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthClientGuard implements CanActivate {
|
export class OAuthClientGuard implements CanActivate {
|
||||||
@@ -35,7 +34,13 @@ export class OAuthClientGuard implements CanActivate {
|
|||||||
throw new NotFoundException("OAuthClientGuard - OAuth client not found.");
|
throw new NotFoundException("OAuthClientGuard - OAuth client not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId);
|
const allowed = Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`OAuthClientGuard - forbidden. oAuth client with id=${oAuthClientId} does not belong to the organization with id=${organizationId}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
getOrganizationId(context: ExecutionContext) {
|
getOrganizationId(context: ExecutionContext) {
|
||||||
|
|||||||
@@ -62,6 +62,6 @@ import { JwtService } from "@nestjs/jwt";
|
|||||||
OAuthClientUsersOutputService,
|
OAuthClientUsersOutputService,
|
||||||
],
|
],
|
||||||
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
||||||
exports: [OAuthClientRepository, OAuthClientUsersOutputService],
|
exports: [OAuthClientRepository, OAuthClientsOutputService, OAuthClientUsersOutputService],
|
||||||
})
|
})
|
||||||
export class OAuthClientModule {}
|
export class OAuthClientModule {}
|
||||||
+13
-14
@@ -6,17 +6,8 @@ import { PlatformOAuthClientDto } from "@calcom/platform-types";
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthClientsOutputService {
|
export class OAuthClientsOutputService {
|
||||||
transformPermissions(permissionsNumber: number): Array<keyof typeof PERMISSION_MAP> {
|
transformOAuthClients(clients: PlatformOAuthClient[]): PlatformOAuthClientDto[] {
|
||||||
const permissionNumbers = PERMISSIONS.filter(
|
return clients.map((client) => this.transformOAuthClient(client));
|
||||||
(permission) => (permissionsNumber & permission) === permission
|
|
||||||
);
|
|
||||||
|
|
||||||
return permissionNumbers.map(
|
|
||||||
(permission) =>
|
|
||||||
Object.entries(PERMISSION_MAP).find(
|
|
||||||
([_, value]) => value === permission
|
|
||||||
)?.[0] as keyof typeof PERMISSION_MAP
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
transformOAuthClient(client: PlatformOAuthClient): PlatformOAuthClientDto {
|
transformOAuthClient(client: PlatformOAuthClient): PlatformOAuthClientDto {
|
||||||
@@ -24,7 +15,7 @@ export class OAuthClientsOutputService {
|
|||||||
id: client.id,
|
id: client.id,
|
||||||
name: client.name,
|
name: client.name,
|
||||||
secret: client.secret,
|
secret: client.secret,
|
||||||
permissions: this.transformPermissions(client.permissions),
|
permissions: this.transformOAuthClientPermissions(client.permissions),
|
||||||
logo: client.logo,
|
logo: client.logo,
|
||||||
redirectUris: client.redirectUris,
|
redirectUris: client.redirectUris,
|
||||||
organizationId: client.organizationId,
|
organizationId: client.organizationId,
|
||||||
@@ -37,7 +28,15 @@ export class OAuthClientsOutputService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
transformOAuthClients(clients: PlatformOAuthClient[]): PlatformOAuthClientDto[] {
|
transformOAuthClientPermissions(permissions: number): Array<keyof typeof PERMISSION_MAP> {
|
||||||
return clients.map((client) => this.transformOAuthClient(client));
|
const permissionsNumbers = PERMISSIONS.filter((permission) => (permissions & permission) === permission);
|
||||||
|
|
||||||
|
return permissionsNumbers.map((permission) => this.transformOAuthClientPermission(permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
transformOAuthClientPermission(permission: (typeof PERMISSIONS)[number]): keyof typeof PERMISSION_MAP {
|
||||||
|
return Object.entries(PERMISSION_MAP).find(
|
||||||
|
([_, value]) => value === permission
|
||||||
|
)?.[0] as keyof typeof PERMISSION_MAP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Transform } from "class-transformer";
|
||||||
|
import { IsOptional, IsArray, ArrayMinSize, IsString, IsNumber } from "class-validator";
|
||||||
|
|
||||||
|
export class GetAssignedAttributeOptions {
|
||||||
|
@ApiPropertyOptional({ type: Number, description: "Number of responses to skip" })
|
||||||
|
@Transform(({ value }) => value && parseInt(value))
|
||||||
|
@IsOptional()
|
||||||
|
skip?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, description: "Number of responses to take" })
|
||||||
|
@Transform(({ value }) => value && parseInt(value))
|
||||||
|
@IsOptional()
|
||||||
|
take?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.split(",").map((optId: string) => optId);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [String],
|
||||||
|
description: "Filter by assigned attribute option ids. ids must be separated by a comma.",
|
||||||
|
example: "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ArrayMinSize(1, { message: "assignedOptionIds must contain at least 1 attribute option id" })
|
||||||
|
assignedOptionIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.split(",").map((teamId: string) => parseInt(teamId));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [Number],
|
||||||
|
description: "Filter by teamIds. Team ids must be separated by a comma.",
|
||||||
|
example: "?teamIds=100,200",
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@IsNumber({}, { each: true })
|
||||||
|
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
|
||||||
|
teamIds?: number[];
|
||||||
|
}
|
||||||
+78
@@ -7,6 +7,8 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
|||||||
|
|
||||||
import { slugify } from "@calcom/platform-libraries";
|
import { slugify } from "@calcom/platform-libraries";
|
||||||
|
|
||||||
|
import { GetOrganizationAttributeAssignedOptionsProp } from "./services/organization-attributes-option.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationAttributeOptionRepository {
|
export class OrganizationAttributeOptionRepository {
|
||||||
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
|
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
|
||||||
@@ -90,6 +92,82 @@ export class OrganizationAttributeOptionRepository {
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOrganizationAttributeAssignedOptions({
|
||||||
|
attributeId,
|
||||||
|
attributeSlug,
|
||||||
|
filters,
|
||||||
|
organizationId,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
}: GetOrganizationAttributeAssignedOptionsProp) {
|
||||||
|
const options = await this.dbRead.prisma.attributeOption.findMany({
|
||||||
|
where: {
|
||||||
|
attribute: {
|
||||||
|
...(attributeId && { id: attributeId }),
|
||||||
|
...(attributeSlug && { slug: attributeSlug }),
|
||||||
|
teamId: organizationId,
|
||||||
|
},
|
||||||
|
assignedUsers: {
|
||||||
|
some: {}, // empty {} statement checks if option is assigned to at least one user
|
||||||
|
...(filters?.teamIds && {
|
||||||
|
some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { assignedUsers: { include: { member: true } } },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
});
|
||||||
|
|
||||||
|
// only return options that are assigned to users alongside assignedOptionIds filter
|
||||||
|
if (filters?.assignedOptionIds) {
|
||||||
|
const filteredAssignedOptions = await this.dbRead.prisma.attributeOption.findMany({
|
||||||
|
where: {
|
||||||
|
attribute: { teamId: organizationId },
|
||||||
|
assignedUsers: {
|
||||||
|
every: {
|
||||||
|
attributeOptionId: { in: filters?.assignedOptionIds },
|
||||||
|
},
|
||||||
|
...(filters?.teamIds && {
|
||||||
|
some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { assignedUsers: { include: { member: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filteredAssignedOptions?.length) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
"Options provided in assignedOptionIds are not assigned to anyone, or the users are not part of the teams specified in teamIds filter."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingUserIds = filteredAssignedOptions.flatMap((opt) =>
|
||||||
|
opt.assignedUsers.flatMap((assignedUser) => assignedUser.member.userId)
|
||||||
|
);
|
||||||
|
// reduce remove options that are not assigned alongside assignedOptionIds filter
|
||||||
|
return options.reduce((acc, opt) => {
|
||||||
|
if (opt.assignedUsers.some((assignedUser) => matchingUserIds.includes(assignedUser.member.userId))) {
|
||||||
|
return [
|
||||||
|
...acc,
|
||||||
|
{
|
||||||
|
...opt,
|
||||||
|
assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as typeof options);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.map((opt) => {
|
||||||
|
return {
|
||||||
|
...opt,
|
||||||
|
assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async assignOrganizationAttributeOptionToUser({
|
async assignOrganizationAttributeOptionToUser({
|
||||||
organizationId,
|
organizationId,
|
||||||
membershipId,
|
membershipId,
|
||||||
|
|||||||
+233
-8
@@ -3,6 +3,8 @@ import { AppModule } from "@/app.module";
|
|||||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
|
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
|
||||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
||||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
||||||
|
import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
|
||||||
|
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
@@ -13,12 +15,13 @@ import * as request from "supertest";
|
|||||||
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
import { User, Team, Membership, Attribute, AttributeOption } from "@calcom/prisma/client";
|
||||||
|
|
||||||
describe("Organizations Attributes Options Endpoints", () => {
|
describe("Organizations Attributes Options Endpoints", () => {
|
||||||
describe("User lacks required role", () => {
|
describe("User lacks required role", () => {
|
||||||
@@ -95,19 +98,37 @@ describe("Organizations Attributes Options Endpoints", () => {
|
|||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
|
let teamsRepositoryFixtures: TeamRepositoryFixture;
|
||||||
|
|
||||||
let membershipFixtures: MembershipRepositoryFixture;
|
let membershipFixtures: MembershipRepositoryFixture;
|
||||||
let attributeRepositoryFixture: AttributeRepositoryFixture;
|
let attributeRepositoryFixture: AttributeRepositoryFixture;
|
||||||
|
|
||||||
const userEmail = `organization-attributes-options-admin-${randomString()}@api.com`;
|
const userEmail = `organization-attributes-options-admin-${randomString()}@api.com`;
|
||||||
|
const userEmail2 = `organization-attributes-options-member-${randomString()}@api.com`;
|
||||||
|
|
||||||
let user: User;
|
let user: User;
|
||||||
|
let user2: User;
|
||||||
let org: Team;
|
let org: Team;
|
||||||
|
let team: Team;
|
||||||
let membership: Membership;
|
let membership: Membership;
|
||||||
|
let membership2: Membership;
|
||||||
|
|
||||||
let attributeId: string;
|
let attributeId: string;
|
||||||
|
let attributeSlug: string;
|
||||||
|
|
||||||
let createdOption: any;
|
let createdOption: any;
|
||||||
|
let createdOption2: any;
|
||||||
|
let attribute2: Attribute;
|
||||||
|
let attribute2Option: AttributeOption;
|
||||||
|
|
||||||
const createOptionInput: CreateOrganizationAttributeOptionInput = {
|
const createOptionInput: CreateOrganizationAttributeOptionInput = {
|
||||||
value: "option1",
|
value: "option1",
|
||||||
slug: "option1",
|
slug: `option1-${randomString()}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOption2Input: CreateOrganizationAttributeOptionInput = {
|
||||||
|
value: "option2",
|
||||||
|
slug: `option2${randomString()}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -120,6 +141,7 @@ describe("Organizations Attributes Options Endpoints", () => {
|
|||||||
|
|
||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
|
teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef);
|
||||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||||
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
@@ -128,23 +150,50 @@ describe("Organizations Attributes Options Endpoints", () => {
|
|||||||
isOrganization: true,
|
isOrganization: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } });
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
user = await userRepositoryFixture.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
username: userEmail,
|
username: userEmail,
|
||||||
organization: { connect: { id: org.id } },
|
organization: { connect: { id: org.id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
user2 = await userRepositoryFixture.create({
|
||||||
|
email: userEmail2,
|
||||||
|
username: userEmail2,
|
||||||
|
organization: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||||
|
membership2 = await membershipFixtures.addUserToOrg(user2, org, "ADMIN", true);
|
||||||
|
await membershipFixtures.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
accepted: true,
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
});
|
||||||
|
|
||||||
// Create an attribute for testing
|
// Create an attribute for testing
|
||||||
const attribute = await attributeRepositoryFixture.create({
|
const attribute = await attributeRepositoryFixture.create({
|
||||||
name: "Test Attribute",
|
name: "Test Attribute",
|
||||||
team: { connect: { id: org.id } },
|
team: { connect: { id: org.id } },
|
||||||
type: "TEXT",
|
type: "TEXT",
|
||||||
slug: "test-attribute",
|
slug: `test-attribute-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
attribute2 = await attributeRepositoryFixture.create({
|
||||||
|
name: "Test Attribute 2",
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
type: "TEXT",
|
||||||
|
slug: `test-attribute-2-${randomString()}`,
|
||||||
});
|
});
|
||||||
attributeId = attribute.id;
|
attributeId = attribute.id;
|
||||||
|
attributeSlug = attribute.slug;
|
||||||
|
attribute2Option = await attributeRepositoryFixture.createOption({
|
||||||
|
slug: `optionA-${randomString()}`,
|
||||||
|
value: "optionA",
|
||||||
|
attribute: { connect: { id: attribute2.id } },
|
||||||
|
});
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
@@ -164,16 +213,31 @@ describe("Organizations Attributes Options Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create attribute option", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||||
|
.send(createOption2Input)
|
||||||
|
.expect(201)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
createdOption2 = response.body.data;
|
||||||
|
expect(createdOption2.value).toEqual(createOption2Input.value);
|
||||||
|
expect(createdOption2.slug).toEqual(createOption2Input.slug);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should get attribute options", async () => {
|
it("should get attribute options", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
const options = response.body.data;
|
const options = response.body.data as OptionOutput[];
|
||||||
expect(options.length).toEqual(1);
|
expect(options.length).toEqual(2);
|
||||||
expect(options[0].value).toEqual(createOptionInput.value);
|
expect(options.find((opt) => opt.value === createOptionInput.value)).toBeDefined();
|
||||||
expect(options[0].slug).toEqual(createOptionInput.slug);
|
expect(options.find((opt) => opt.slug === createOptionInput.slug)).toBeDefined();
|
||||||
|
expect(options.find((opt) => opt.value === createOption2Input.value)).toBeDefined();
|
||||||
|
expect(options.find((opt) => opt.slug === createOption2Input.slug)).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,6 +273,38 @@ describe("Organizations Attributes Options Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should assign attribute option to user2", async () => {
|
||||||
|
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
|
||||||
|
attributeId: attributeId,
|
||||||
|
attributeOptionId: createdOption2.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
|
||||||
|
.send(assignInput)
|
||||||
|
.expect(201)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(response.body.data).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should assign attribute option to user2", async () => {
|
||||||
|
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
|
||||||
|
attributeId: attribute2.id,
|
||||||
|
attributeOptionId: attribute2Option.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
|
||||||
|
.send(assignInput)
|
||||||
|
.expect(201)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(response.body.data).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should get attribute options for user", async () => {
|
it("should get attribute options for user", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
|
.get(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
|
||||||
@@ -221,6 +317,133 @@ describe("Organizations Attributes Options Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should get attribute options for user2", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
const userOptions = response.body.data as AttributeOption[];
|
||||||
|
expect(userOptions.length).toEqual(2);
|
||||||
|
expect(userOptions.find((opt) => opt.id === createdOption2.id)).toBeDefined();
|
||||||
|
expect(userOptions.find((opt) => opt.id === attribute2Option.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get attribute all assigned options", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned`)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||||
|
expect(assignedOptions?.length).toEqual(2);
|
||||||
|
|
||||||
|
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||||
|
expect(
|
||||||
|
assignedOptions
|
||||||
|
.find((opt) => createdOption.id === opt.id)
|
||||||
|
?.assignedUserIds.find((id) => id === user.id)
|
||||||
|
).toBeDefined();
|
||||||
|
|
||||||
|
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
|
||||||
|
expect(
|
||||||
|
assignedOptions
|
||||||
|
.find((opt) => createdOption2.id === opt.id)
|
||||||
|
?.assignedUserIds.find((id) => id === user2.id)
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get attribute all assigned options filtered by team id", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?teamIds=${team.id}`)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||||
|
expect(assignedOptions?.length).toEqual(1);
|
||||||
|
|
||||||
|
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||||
|
expect(
|
||||||
|
assignedOptions
|
||||||
|
.find((opt) => createdOption.id === opt.id)
|
||||||
|
?.assignedUserIds.find((id) => id === user.id)
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get attribute all assigned options by attribute slug", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/attributes/slugs/${attributeSlug}/options/assigned`)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||||
|
expect(assignedOptions?.length).toEqual(2);
|
||||||
|
|
||||||
|
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||||
|
expect(
|
||||||
|
assignedOptions
|
||||||
|
.find((opt) => createdOption.id === opt.id)
|
||||||
|
?.assignedUserIds.find((id) => id === user.id)
|
||||||
|
).toBeDefined();
|
||||||
|
|
||||||
|
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
|
||||||
|
expect(
|
||||||
|
assignedOptions
|
||||||
|
.find((opt) => createdOption2.id === opt.id)
|
||||||
|
?.assignedUserIds.find((id) => id === user2.id)
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get attribute all assigned options filtered by other assigned options", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}`
|
||||||
|
)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||||
|
expect(assignedOptions?.length).toEqual(1);
|
||||||
|
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
|
||||||
|
expect(
|
||||||
|
assignedOptions
|
||||||
|
.find((opt) => createdOption2.id === opt.id)
|
||||||
|
?.assignedUserIds.find((id) => id === user2.id)
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not get attribute all assigned options filtered by other assigned options and teamIds in which the user is not part of", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}&teamIds=${team.id}`
|
||||||
|
)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get attribute all assigned options filtered by other assigned options and teamIds", async () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(
|
||||||
|
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${createdOption.id}&teamIds=${team.id}`
|
||||||
|
)
|
||||||
|
.expect(200)
|
||||||
|
.then((response) => {
|
||||||
|
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||||
|
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||||
|
expect(assignedOptions?.length).toEqual(1);
|
||||||
|
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||||
|
expect(
|
||||||
|
assignedOptions
|
||||||
|
.find((opt) => createdOption.id === opt.id)
|
||||||
|
?.assignedUserIds.find((id) => id === user.id)
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should unassign attribute option from user", async () => {
|
it("should unassign attribute option from user", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
|
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
|
||||||
@@ -243,7 +466,9 @@ describe("Organizations Attributes Options Endpoints", () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await membershipFixtures.delete(membership.id);
|
await membershipFixtures.delete(membership.id);
|
||||||
|
await membershipFixtures.delete(membership2.id);
|
||||||
await userRepositoryFixture.deleteByEmail(user.email);
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||||
await organizationsRepositoryFixture.delete(org.id);
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
+62
-1
@@ -8,19 +8,32 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
|||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
|
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
|
||||||
|
import { GetAssignedAttributeOptions } from "@/modules/organizations/attributes/options/inputs/get-assigned-attribute-options.input";
|
||||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
||||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
||||||
import {
|
import {
|
||||||
AssignOptionUserOutput,
|
AssignOptionUserOutput,
|
||||||
UnassignOptionUserOutput,
|
UnassignOptionUserOutput,
|
||||||
} from "@/modules/organizations/attributes/options/outputs/assign-option-user.output";
|
} from "@/modules/organizations/attributes/options/outputs/assign-option-user.output";
|
||||||
|
import { GetAllAttributeAssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
|
||||||
import { CreateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/create-option.output";
|
import { CreateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/create-option.output";
|
||||||
import { DeleteAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/delete-option.output";
|
import { DeleteAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/delete-option.output";
|
||||||
import { GetOptionUserOutput } from "@/modules/organizations/attributes/options/outputs/get-option-user.output";
|
import { GetOptionUserOutput } from "@/modules/organizations/attributes/options/outputs/get-option-user.output";
|
||||||
import { GetAllAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/get-option.output";
|
import { GetAllAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/get-option.output";
|
||||||
import { UpdateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/update-option.output";
|
import { UpdateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/update-option.output";
|
||||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
@@ -118,6 +131,54 @@ export class OrganizationsAttributesOptionsController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_MEMBER")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/attributes/:attributeId/options/assigned")
|
||||||
|
@ApiOperation({ summary: "Get by attribute id all of the attribute options that are assigned to users" })
|
||||||
|
async getOrganizationAttributeAssignedOptions(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("attributeId") attributeId: string,
|
||||||
|
@Query() queryParams: GetAssignedAttributeOptions
|
||||||
|
): Promise<GetAllAttributeAssignedOptionOutput> {
|
||||||
|
const { skip, take, ...rest } = queryParams;
|
||||||
|
const attributeOptions =
|
||||||
|
await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({
|
||||||
|
organizationId: orgId,
|
||||||
|
attributeId,
|
||||||
|
skip: skip ?? 0,
|
||||||
|
take: take ?? 250,
|
||||||
|
filters: rest,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: attributeOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("ORG_MEMBER")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@Get("/attributes/slugs/:attributeSlug/options/assigned")
|
||||||
|
@ApiOperation({ summary: "Get by attribute slug all of the attribute options that are assigned to users" })
|
||||||
|
async getOrganizationAttributeAssignedOptionsBySlug(
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("attributeSlug") attributeSlug: string,
|
||||||
|
@Query() queryParams: GetAssignedAttributeOptions
|
||||||
|
): Promise<GetAllAttributeAssignedOptionOutput> {
|
||||||
|
const { skip, take, ...rest } = queryParams;
|
||||||
|
const attributeOptions =
|
||||||
|
await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({
|
||||||
|
organizationId: orgId,
|
||||||
|
attributeSlug,
|
||||||
|
skip: skip ?? 0,
|
||||||
|
take: take ?? 250,
|
||||||
|
filters: rest,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: attributeOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Post("/attributes/options/:userId")
|
@Post("/attributes/options/:userId")
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
|
||||||
|
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Expose, Type } from "class-transformer";
|
||||||
|
import { IsArray, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
export class AssignedOptionOutput extends OptionOutput {
|
||||||
|
@Expose()
|
||||||
|
@IsArray()
|
||||||
|
@ApiProperty({
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
description: "Ids of the users assigned to the attribute option.",
|
||||||
|
example: [124, 224],
|
||||||
|
})
|
||||||
|
assignedUserIds!: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetAllAttributeAssignedOptionOutput extends BaseOutputDTO {
|
||||||
|
@Expose()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => OptionOutput)
|
||||||
|
data!: AssignedOptionOutput[];
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
|
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
|
import { Expose } from "class-transformer";
|
||||||
|
import { IsString } from "class-validator";
|
||||||
|
|
||||||
export class OptionOutput {
|
export class OptionOutput {
|
||||||
|
@Expose()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: String,
|
type: String,
|
||||||
@@ -12,10 +13,12 @@ export class OptionOutput {
|
|||||||
})
|
})
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
|
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
|
||||||
attributeId!: string;
|
attributeId!: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: String,
|
type: String,
|
||||||
@@ -25,6 +28,7 @@ export class OptionOutput {
|
|||||||
})
|
})
|
||||||
value!: string;
|
value!: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
+43
@@ -3,8 +3,10 @@ import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/
|
|||||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
||||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
||||||
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
|
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
|
||||||
|
import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
|
||||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
|
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
|
||||||
|
|
||||||
@@ -111,4 +113,45 @@ export class OrganizationAttributeOptionService {
|
|||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOrganizationAttributeAssignedOptions({
|
||||||
|
organizationId,
|
||||||
|
attributeId,
|
||||||
|
attributeSlug,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
filters,
|
||||||
|
}: GetOrganizationAttributeAssignedOptionsProp) {
|
||||||
|
const options = await this.organizationAttributeOptionRepository.getOrganizationAttributeAssignedOptions({
|
||||||
|
organizationId,
|
||||||
|
...(attributeId ? { attributeId } : { attributeSlug }),
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
return options.map((opt) => plainToClass(AssignedOptionOutput, opt, { strategy: "excludeAll" }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discriminative Union on attributeSlug / attributeId
|
||||||
|
export type GetOrganizationAttributeAssignedOptionsProp =
|
||||||
|
| GetOrganizationAttributeAssignedOptionsPropById
|
||||||
|
| GetOrganizationAttributeAssignedOptionsPropBySlug;
|
||||||
|
|
||||||
|
type GetOrganizationAttributeAssignedOptionsPropById = {
|
||||||
|
organizationId: number;
|
||||||
|
attributeId: string;
|
||||||
|
attributeSlug?: undefined;
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
filters?: { assignedOptionIds?: string[]; teamIds?: number[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetOrganizationAttributeAssignedOptionsPropBySlug = {
|
||||||
|
organizationId: number;
|
||||||
|
attributeId?: undefined;
|
||||||
|
attributeSlug?: string;
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
filters?: { assignedOptionIds?: string[]; teamIds?: number[] };
|
||||||
|
};
|
||||||
+43
-1
@@ -22,6 +22,7 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci
|
|||||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@@ -36,10 +37,13 @@ import {
|
|||||||
Headers,
|
Headers,
|
||||||
Req,
|
Req,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
|
BadRequestException,
|
||||||
|
Redirect,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
|
||||||
import { plainToInstance } from "class-transformer";
|
import { plainToInstance } from "class-transformer";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
import { stringify } from "querystring";
|
||||||
|
|
||||||
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
|
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
|
||||||
|
|
||||||
@@ -60,7 +64,8 @@ export type OAuthCallbackState = {
|
|||||||
export class OrganizationsConferencingController {
|
export class OrganizationsConferencingController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly conferencingService: ConferencingService,
|
private readonly conferencingService: ConferencingService,
|
||||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
private readonly organizationsConferencingService: OrganizationsConferencingService,
|
||||||
|
private readonly tokensRepository: TokensRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
@@ -221,4 +226,41 @@ export class OrganizationsConferencingController {
|
|||||||
|
|
||||||
return { status: SUCCESS_STATUS };
|
return { status: SUCCESS_STATUS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@Get("/teams/:teamId/conferencing/:app/oauth/callback")
|
||||||
|
@Redirect(undefined, 301)
|
||||||
|
@ApiOperation({ summary: "Save conferencing app OAuth credentials" })
|
||||||
|
async saveTeamOauthCredentials(
|
||||||
|
@Query("state") state: string,
|
||||||
|
@Query("code") code: string,
|
||||||
|
@Query("error") error: string | undefined,
|
||||||
|
@Query("error_description") error_description: string | undefined,
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number,
|
||||||
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
|
@Param("app") app: string
|
||||||
|
): Promise<{ url: string }> {
|
||||||
|
if (!state) {
|
||||||
|
throw new BadRequestException("Missing `state` query param");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||||
|
try {
|
||||||
|
return await this.organizationsConferencingService.connectTeamOauthApps({
|
||||||
|
decodedCallbackState,
|
||||||
|
code,
|
||||||
|
app,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
|
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||||
|
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||||
|
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||||
|
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||||
|
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||||
|
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||||
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
|
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||||
|
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, ConfigModule, ConferencingModule],
|
||||||
|
providers: [
|
||||||
|
ConferencingService,
|
||||||
|
ConferencingRepository,
|
||||||
|
GoogleMeetService,
|
||||||
|
UsersRepository,
|
||||||
|
TeamsRepository,
|
||||||
|
OrganizationsConferencingService,
|
||||||
|
ZoomVideoService,
|
||||||
|
Office365VideoService,
|
||||||
|
CredentialsRepository,
|
||||||
|
TokensRepository,
|
||||||
|
AppsRepository,
|
||||||
|
OrganizationsRepository,
|
||||||
|
StripeService,
|
||||||
|
MembershipsRepository,
|
||||||
|
RedisService,
|
||||||
|
OrganizationsTeamsRepository,
|
||||||
|
],
|
||||||
|
exports: [OrganizationsConferencingService],
|
||||||
|
controllers: [OrganizationsConferencingController],
|
||||||
|
})
|
||||||
|
export class OrganizationsConferencingModule {}
|
||||||
+5
-163
@@ -1,10 +1,3 @@
|
|||||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
|
||||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
|
||||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
|
||||||
import { PlatformPlanType } from "@/modules/billing/types";
|
|
||||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||||
import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||||
@@ -13,12 +6,7 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
|||||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import {
|
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||||
BadRequestException,
|
|
||||||
ForbiddenException,
|
|
||||||
InternalServerErrorException,
|
|
||||||
Logger,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { GOOGLE_MEET } from "@calcom/platform-constants";
|
import { GOOGLE_MEET } from "@calcom/platform-constants";
|
||||||
@@ -35,129 +23,9 @@ export class OrganizationsConferencingService {
|
|||||||
private teamsRepository: TeamsRepository,
|
private teamsRepository: TeamsRepository,
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
private readonly googleMeetService: GoogleMeetService,
|
private readonly googleMeetService: GoogleMeetService,
|
||||||
private readonly conferencingService: ConferencingService,
|
private readonly conferencingService: ConferencingService
|
||||||
private readonly platformPlanGuard: PlatformPlanGuard,
|
|
||||||
private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard,
|
|
||||||
private readonly isOrgGuard: IsOrgGuard,
|
|
||||||
private readonly isTeamInOrg: IsTeamInOrg,
|
|
||||||
private readonly rolesGuard: RolesGuard
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async verifyAccess({
|
|
||||||
user,
|
|
||||||
orgId,
|
|
||||||
teamId,
|
|
||||||
requiredRole,
|
|
||||||
minimumPlan,
|
|
||||||
}: {
|
|
||||||
user: UserWithProfile;
|
|
||||||
orgId: string;
|
|
||||||
teamId?: string;
|
|
||||||
requiredRole?: string;
|
|
||||||
minimumPlan?: PlatformPlanType;
|
|
||||||
}): Promise<{ orgId: number; teamId: number | null }> {
|
|
||||||
const userWithAdminFlag = {
|
|
||||||
...user,
|
|
||||||
isSystemAdmin: user.role === "ADMIN",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!orgId) {
|
|
||||||
throw new BadRequestException("Organization ID is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.ensureOrganizationAccess(orgId);
|
|
||||||
await this.ensureAdminAPIEnabled(orgId);
|
|
||||||
|
|
||||||
// Only validate team if a teamId is provided
|
|
||||||
if (teamId && teamId.trim() !== "") {
|
|
||||||
await this.ensureTeamBelongsToOrg(orgId, teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.ensureUserRoleAccess(userWithAdminFlag, orgId, teamId, requiredRole);
|
|
||||||
await this.ensurePlatformPlanAccess({ teamId, orgId, user: userWithAdminFlag, minimumPlan });
|
|
||||||
|
|
||||||
const parsedOrgId = parseInt(orgId, 10);
|
|
||||||
// For organization-level operations, teamId might be empty or null
|
|
||||||
const parsedTeamId = teamId && teamId.trim() !== "" ? parseInt(teamId, 10) : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
orgId: parsedOrgId,
|
|
||||||
teamId: parsedTeamId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureOrganizationAccess(orgId: string): Promise<void> {
|
|
||||||
const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId);
|
|
||||||
if (!canAccess) {
|
|
||||||
throw new ForbiddenException("Organization validation failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureAdminAPIEnabled(orgId: string): Promise<void> {
|
|
||||||
const { canAccess } = await this.isAdminAPIEnabledGuard.checkAdminAPIEnabled(orgId);
|
|
||||||
if (!canAccess) {
|
|
||||||
throw new ForbiddenException("Admin API is not enabled for this organization.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureTeamBelongsToOrg(orgId: string, teamId: string): Promise<void> {
|
|
||||||
const { canAccess } = await this.isTeamInOrg.checkIfTeamIsInOrg(orgId, teamId);
|
|
||||||
if (!canAccess) {
|
|
||||||
throw new ForbiddenException("Team is not part of the organization.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureUserRoleAccess(
|
|
||||||
user: ApiAuthGuardUser,
|
|
||||||
orgId: string,
|
|
||||||
teamId?: string,
|
|
||||||
requiredRole?: string
|
|
||||||
): Promise<void> {
|
|
||||||
if (!requiredRole) return;
|
|
||||||
|
|
||||||
// For team-level roles, we need both orgId and teamId
|
|
||||||
if (requiredRole.startsWith("TEAM_") && (!teamId || teamId.trim() === "")) {
|
|
||||||
throw new ForbiddenException("Team ID is required for team-level role access");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use empty string for teamId if it's an org-level operation
|
|
||||||
const effectiveTeamId = teamId && teamId.trim() !== "" ? teamId : "";
|
|
||||||
|
|
||||||
const { canAccess } = await this.rolesGuard.checkUserRoleAccess(
|
|
||||||
user,
|
|
||||||
orgId,
|
|
||||||
effectiveTeamId,
|
|
||||||
requiredRole
|
|
||||||
);
|
|
||||||
if (!canAccess) {
|
|
||||||
throw new ForbiddenException("User does not have the required role.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensurePlatformPlanAccess({
|
|
||||||
teamId,
|
|
||||||
orgId,
|
|
||||||
user,
|
|
||||||
minimumPlan,
|
|
||||||
}: {
|
|
||||||
teamId?: string;
|
|
||||||
orgId?: string;
|
|
||||||
user: ApiAuthGuardUser;
|
|
||||||
minimumPlan?: PlatformPlanType;
|
|
||||||
}): Promise<void> {
|
|
||||||
if (!minimumPlan) return;
|
|
||||||
|
|
||||||
const { canAccess } = await this.platformPlanGuard.checkPlatformPlanAccess({
|
|
||||||
teamId,
|
|
||||||
orgId,
|
|
||||||
user,
|
|
||||||
minimumPlan,
|
|
||||||
});
|
|
||||||
if (!canAccess) {
|
|
||||||
throw new ForbiddenException("User's organization does not meet the required subscription plan.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise<any> {
|
async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise<any> {
|
||||||
switch (app) {
|
switch (app) {
|
||||||
case GOOGLE_MEET:
|
case GOOGLE_MEET:
|
||||||
@@ -171,40 +39,14 @@ export class OrganizationsConferencingService {
|
|||||||
decodedCallbackState,
|
decodedCallbackState,
|
||||||
app,
|
app,
|
||||||
code,
|
code,
|
||||||
userId,
|
teamId,
|
||||||
}: {
|
}: {
|
||||||
userId: number;
|
|
||||||
app: string;
|
app: string;
|
||||||
decodedCallbackState: OAuthCallbackState;
|
decodedCallbackState: OAuthCallbackState;
|
||||||
code: string;
|
code: string;
|
||||||
|
teamId: number;
|
||||||
}) {
|
}) {
|
||||||
const user = await this.usersRepository.findByIdWithProfile(userId);
|
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState, teamId);
|
||||||
const { orgId, teamId } = decodedCallbackState;
|
|
||||||
|
|
||||||
if (!orgId) {
|
|
||||||
throw new BadRequestException("orgId required");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new BadRequestException("user not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if this is a team-level or organization-level operation
|
|
||||||
const isTeamLevel = !!teamId;
|
|
||||||
const requiredRole = isTeamLevel ? "TEAM_ADMIN" : "ORG_ADMIN";
|
|
||||||
|
|
||||||
// Verify access with appropriate parameters
|
|
||||||
const { orgId: validatedOrgId, teamId: validatedTeamId } = await this.verifyAccess({
|
|
||||||
user,
|
|
||||||
orgId,
|
|
||||||
teamId,
|
|
||||||
requiredRole,
|
|
||||||
minimumPlan: "ESSENTIALS",
|
|
||||||
});
|
|
||||||
|
|
||||||
const entityId = isTeamLevel && validatedTeamId !== null ? validatedTeamId : validatedOrgId;
|
|
||||||
|
|
||||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState, entityId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConferencingApps({ teamId }: { teamId: number }) {
|
async getConferencingApps({ teamId }: { teamId: number }) {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||||
import { AuthModule } from "@/modules/auth/auth.module";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||||
|
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||||
|
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||||
|
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||||
|
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||||
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { EmailModule } from "@/modules/email/email.module";
|
import { EmailModule } from "@/modules/email/email.module";
|
||||||
import { EmailService } from "@/modules/email/email.service";
|
import { EmailService } from "@/modules/email/email.service";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
@@ -14,6 +19,7 @@ import { OrganizationAttributeOptionRepository } from "@/modules/organizations/a
|
|||||||
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
|
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
|
||||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||||
|
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
|
||||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||||
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
|
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
|
||||||
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
|
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
|
||||||
@@ -31,6 +37,8 @@ import { OrganizationsOrganizationsModule } from "@/modules/organizations/organi
|
|||||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
|
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
|
||||||
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
||||||
|
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
|
||||||
|
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||||
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
|
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
|
||||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
||||||
@@ -50,13 +58,15 @@ import { OrganizationsWebhooksRepository } from "@/modules/organizations/webhook
|
|||||||
import { OrganizationsWebhooksService } from "@/modules/organizations/webhooks/services/organizations-webhooks.service";
|
import { OrganizationsWebhooksService } from "@/modules/organizations/webhooks/services/organizations-webhooks.service";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -69,11 +79,11 @@ import { forwardRef, Module } from "@nestjs/common";
|
|||||||
EventTypesModule_2024_06_14,
|
EventTypesModule_2024_06_14,
|
||||||
TeamsEventTypesModule,
|
TeamsEventTypesModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
forwardRef(() => ConferencingModule),
|
|
||||||
forwardRef(() => AuthModule),
|
|
||||||
OrganizationsDelegationCredentialModule,
|
OrganizationsDelegationCredentialModule,
|
||||||
OrganizationsOrganizationsModule,
|
OrganizationsOrganizationsModule,
|
||||||
|
OrganizationsStripeModule,
|
||||||
OrganizationsTeamsRoutingFormsModule,
|
OrganizationsTeamsRoutingFormsModule,
|
||||||
|
OrganizationsConferencingModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
OrganizationsRepository,
|
OrganizationsRepository,
|
||||||
@@ -108,6 +118,16 @@ import { forwardRef, Module } from "@nestjs/common";
|
|||||||
OrgUsersOOOService,
|
OrgUsersOOOService,
|
||||||
OrgUsersOOORepository,
|
OrgUsersOOORepository,
|
||||||
OrganizationsConferencingService,
|
OrganizationsConferencingService,
|
||||||
|
OrganizationsStripeService,
|
||||||
|
CredentialsRepository,
|
||||||
|
AppsRepository,
|
||||||
|
RedisService,
|
||||||
|
ConferencingRepository,
|
||||||
|
GoogleMeetService,
|
||||||
|
ConferencingService,
|
||||||
|
ZoomVideoService,
|
||||||
|
Office365VideoService,
|
||||||
|
TokensRepository,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
OrganizationsService,
|
OrganizationsService,
|
||||||
@@ -129,6 +149,7 @@ import { forwardRef, Module } from "@nestjs/common";
|
|||||||
WebhooksService,
|
WebhooksService,
|
||||||
OrganizationsEventTypesService,
|
OrganizationsEventTypesService,
|
||||||
OrganizationsConferencingService,
|
OrganizationsConferencingService,
|
||||||
|
OrganizationsStripeService,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
OrganizationsTeamsController,
|
OrganizationsTeamsController,
|
||||||
@@ -142,7 +163,6 @@ import { forwardRef, Module } from "@nestjs/common";
|
|||||||
OrganizationsWebhooksController,
|
OrganizationsWebhooksController,
|
||||||
OrganizationsTeamsSchedulesController,
|
OrganizationsTeamsSchedulesController,
|
||||||
OrganizationsUsersOOOController,
|
OrganizationsUsersOOOController,
|
||||||
OrganizationsConferencingController,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class OrganizationsModule {}
|
export class OrganizationsModule {}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
|
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||||
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
|
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||||
|
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
|
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||||
|
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||||
|
import {
|
||||||
|
StripConnectOutputDto,
|
||||||
|
StripConnectOutputResponseDto,
|
||||||
|
StripCredentialsCheckOutputResponseDto,
|
||||||
|
StripCredentialsSaveOutputResponseDto,
|
||||||
|
} from "@/modules/stripe/outputs/stripe.output";
|
||||||
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
|
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
Param,
|
||||||
|
Headers,
|
||||||
|
Req,
|
||||||
|
ParseIntPipe,
|
||||||
|
Redirect,
|
||||||
|
BadRequestException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { stringify } from "querystring";
|
||||||
|
|
||||||
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
|
||||||
|
export type OAuthCallbackState = {
|
||||||
|
accessToken: string;
|
||||||
|
teamId?: string;
|
||||||
|
orgId?: string;
|
||||||
|
fromApp?: boolean;
|
||||||
|
returnTo?: string;
|
||||||
|
onErrorReturnTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/organizations/:orgId/teams/:teamId/stripe",
|
||||||
|
version: API_VERSIONS_VALUES,
|
||||||
|
})
|
||||||
|
@DocsTags("Organizations/Teams Stripe")
|
||||||
|
export class OrganizationsStripeController {
|
||||||
|
constructor(
|
||||||
|
private readonly stripeService: StripeService,
|
||||||
|
private readonly organizationsStripeService: OrganizationsStripeService,
|
||||||
|
private readonly tokensRepository: TokensRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@Get("/connect")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Get stripe connect URL for a team" })
|
||||||
|
async getTeamStripeConnectUrl(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Headers("Authorization") authorization: string,
|
||||||
|
@GetUser() user: UserWithProfile,
|
||||||
|
@Param("teamId") teamId: string,
|
||||||
|
@Param("orgId") orgId: string,
|
||||||
|
@Query("returnTo") returnTo?: string,
|
||||||
|
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||||
|
): Promise<StripConnectOutputResponseDto> {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
const accessToken = authorization.replace("Bearer ", "");
|
||||||
|
|
||||||
|
const state: OAuthCallbackState = {
|
||||||
|
onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin,
|
||||||
|
fromApp: false,
|
||||||
|
returnTo: !!returnTo ? returnTo : origin,
|
||||||
|
accessToken,
|
||||||
|
teamId,
|
||||||
|
orgId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripeRedirectUrl = await this.organizationsStripeService.getStripeTeamRedirectUrl({
|
||||||
|
state,
|
||||||
|
userEmail: user.email,
|
||||||
|
userName: user.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@Get("/check")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Check team stripe connection" })
|
||||||
|
async checkTeamStripeConnection(
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
|
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||||
|
return await this.organizationsStripeService.checkIfTeamStripeAccountConnected(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles("TEAM_ADMIN")
|
||||||
|
@PlatformPlan("ESSENTIALS")
|
||||||
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
|
@Get("/save")
|
||||||
|
@Redirect(undefined, 301)
|
||||||
|
@ApiOperation({ summary: "Save stripe credentials" })
|
||||||
|
async save(
|
||||||
|
@Query("state") state: string,
|
||||||
|
@Query("code") code: string,
|
||||||
|
@Query("error") error: string | undefined,
|
||||||
|
@Query("error_description") error_description: string | undefined,
|
||||||
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
|
): Promise<StripCredentialsSaveOutputResponseDto> {
|
||||||
|
if (!state) {
|
||||||
|
throw new BadRequestException("Missing `state` query param");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||||
|
try {
|
||||||
|
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||||
|
|
||||||
|
// user cancels flow
|
||||||
|
if (error === "access_denied") {
|
||||||
|
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new BadRequestException(stringify({ error, error_description }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.organizationsStripeService.saveStripeAccount(
|
||||||
|
decodedCallbackState,
|
||||||
|
code,
|
||||||
|
teamId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
|
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||||
|
import { OrganizationsStripeController } from "@/modules/organizations/stripe/organizations-stripe.controller";
|
||||||
|
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||||
|
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [StripeModule, PrismaModule],
|
||||||
|
exports: [OrganizationsStripeService],
|
||||||
|
providers: [
|
||||||
|
OrganizationsStripeService,
|
||||||
|
CredentialsRepository,
|
||||||
|
AppsRepository,
|
||||||
|
RedisService,
|
||||||
|
OrganizationsRepository,
|
||||||
|
MembershipsRepository,
|
||||||
|
OrganizationsTeamsRepository,
|
||||||
|
TokensRepository,
|
||||||
|
],
|
||||||
|
controllers: [OrganizationsStripeController],
|
||||||
|
})
|
||||||
|
export class OrganizationsStripeModule {}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
|
import { OAuthCallbackState, StripeService } from "@/modules/stripe/stripe.service";
|
||||||
|
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||||
|
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||||
|
import { Logger, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
import { ApiResponseWithoutData } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrganizationsStripeService {
|
||||||
|
private logger = new Logger("OrganizationsStripeService");
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly stripeService: StripeService,
|
||||||
|
private readonly credentialRepository: CredentialsRepository,
|
||||||
|
private readonly appsRepository: AppsRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getStripeTeamRedirectUrl({
|
||||||
|
state,
|
||||||
|
userEmail,
|
||||||
|
userName,
|
||||||
|
}: {
|
||||||
|
state: OAuthCallbackState;
|
||||||
|
userEmail?: string;
|
||||||
|
userName?: string | null;
|
||||||
|
}): Promise<string> {
|
||||||
|
return await this.stripeService.getStripeRedirectUrl(state, userEmail, userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveStripeAccount(
|
||||||
|
state: OAuthCallbackState,
|
||||||
|
code: string,
|
||||||
|
teamId: number,
|
||||||
|
userId?: number
|
||||||
|
): Promise<{ url: string }> {
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException("Invalid Access token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await stripeInstance.oauth.token({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: code?.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: StripeData = { ...response, default_currency: "" };
|
||||||
|
if (response["stripe_user_id"]) {
|
||||||
|
const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]);
|
||||||
|
data["default_currency"] = account.default_currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCredentials = await this.credentialRepository.findAllCredentialsByTypeAndTeamId(
|
||||||
|
"stripe_payment",
|
||||||
|
teamId
|
||||||
|
);
|
||||||
|
|
||||||
|
const credentialIdsToDelete = existingCredentials.map((item) => item.id);
|
||||||
|
if (credentialIdsToDelete.length > 0) {
|
||||||
|
await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.appsRepository.createTeamAppCredential(
|
||||||
|
"stripe_payment",
|
||||||
|
data as unknown as Prisma.InputJsonObject,
|
||||||
|
teamId,
|
||||||
|
"stripe"
|
||||||
|
);
|
||||||
|
|
||||||
|
return { url: state.returnTo ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkIfTeamStripeAccountConnected(teamId: number): Promise<ApiResponseWithoutData> {
|
||||||
|
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
|
||||||
|
"stripe_payment",
|
||||||
|
teamId
|
||||||
|
);
|
||||||
|
|
||||||
|
return await this.stripeService.validateStripeCredentials(stripeCredentials);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -66,9 +66,11 @@ export class OrganizationsUsersController {
|
|||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Query() query: GetOrganizationsUsersInput
|
@Query() query: GetOrganizationsUsersInput
|
||||||
): Promise<GetOrganizationUsersResponseDTO> {
|
): Promise<GetOrganizationUsersResponseDTO> {
|
||||||
|
const { emails, assignedOptionIds, attributeQueryOperator, teamIds } = query ?? {};
|
||||||
const users = await this.organizationsUsersService.getUsers(
|
const users = await this.organizationsUsersService.getUsers(
|
||||||
orgId,
|
orgId,
|
||||||
query.emails,
|
emails,
|
||||||
|
{ assignedOptionIds, attributeQueryOperator, teamIds },
|
||||||
query.skip ?? 0,
|
query.skip ?? 0,
|
||||||
query.take ?? 250
|
query.take ?? 250
|
||||||
);
|
);
|
||||||
|
|||||||
+246
-8
@@ -1,6 +1,7 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
import { EmailService } from "@/modules/email/email.service";
|
import { EmailService } from "@/modules/email/email.service";
|
||||||
|
import { GetOrganizationsUsersInput } from "@/modules/organizations/users/index/inputs/get-organization-users.input";
|
||||||
import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/users/index/outputs/get-organization-users.output";
|
import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/users/index/outputs/get-organization-users.output";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
@@ -10,6 +11,7 @@ import { INestApplication } from "@nestjs/common";
|
|||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
|
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
@@ -20,7 +22,7 @@ import { randomString } from "test/utils/randomString";
|
|||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { User, Team, EventType } from "@calcom/prisma/client";
|
import { User, Team, EventType, AttributeOption } from "@calcom/prisma/client";
|
||||||
|
|
||||||
describe("Organizations Users Endpoints", () => {
|
describe("Organizations Users Endpoints", () => {
|
||||||
const bio = "I am a bio";
|
const bio = "I am a bio";
|
||||||
@@ -114,8 +116,6 @@ describe("Organizations Users Endpoints", () => {
|
|||||||
await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]);
|
await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]);
|
||||||
await organizationsRepositoryFixture.delete(org.id);
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|
||||||
await app.close();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe("Admin role", () => {
|
describe("Admin role", () => {
|
||||||
@@ -249,11 +249,6 @@ describe("Organizations Users Endpoints", () => {
|
|||||||
|
|
||||||
expect(body.status).toBe(SUCCESS_STATUS);
|
expect(body.status).toBe(SUCCESS_STATUS);
|
||||||
expect(userData.length).toBe(4);
|
expect(userData.length).toBe(4);
|
||||||
console.log(
|
|
||||||
"profiles",
|
|
||||||
{ userData },
|
|
||||||
userData.map((u) => u.profile)
|
|
||||||
);
|
|
||||||
// Find and verify each member's data
|
// Find and verify each member's data
|
||||||
const member0 = userData.find((u) => u.profile.username === orgMembersData[0].username);
|
const member0 = userData.find((u) => u.profile.username === orgMembersData[0].username);
|
||||||
const member1 = userData.find((u) => u.profile.username === orgMembersData[1].username);
|
const member1 = userData.find((u) => u.profile.username === orgMembersData[1].username);
|
||||||
@@ -543,7 +538,250 @@ describe("Organizations Users Endpoints", () => {
|
|||||||
await userRepositoryFixture.deleteByEmail(createdUser.email);
|
await userRepositoryFixture.deleteByEmail(createdUser.email);
|
||||||
await organizationsRepositoryFixture.delete(org.id);
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Org Members with assigned attributes", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
|
let membershipFixtures: MembershipRepositoryFixture;
|
||||||
|
let teamsRepositoryFixtures: TeamRepositoryFixture;
|
||||||
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let attributeRepositoryFixture: AttributeRepositoryFixture;
|
||||||
|
|
||||||
|
const authEmail = `organizations-users-auth-${randomString()}@api.com`;
|
||||||
|
const user2Email = `organizations-users2-auth-${randomString()}@api.com`;
|
||||||
|
|
||||||
|
let user: User;
|
||||||
|
let user2: User;
|
||||||
|
|
||||||
|
let org: Team;
|
||||||
|
let team: Team;
|
||||||
|
let assignedOption1: AttributeOption;
|
||||||
|
let assignedOption2: AttributeOption;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await withApiAuth(
|
||||||
|
authEmail,
|
||||||
|
Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
|
})
|
||||||
|
).compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
|
teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
org = await organizationsRepositoryFixture.create({
|
||||||
|
name: `organizations-users-organization-${randomString()}`,
|
||||||
|
isOrganization: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } });
|
||||||
|
|
||||||
|
user = await userRepositoryFixture.create({
|
||||||
|
email: authEmail,
|
||||||
|
username: authEmail,
|
||||||
|
organization: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${user.id}`,
|
||||||
|
username: authEmail,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: org.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||||
|
|
||||||
|
await membershipFixtures.create({
|
||||||
|
role: "MEMBER",
|
||||||
|
accepted: true,
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
user2 = await userRepositoryFixture.create({
|
||||||
|
email: user2Email,
|
||||||
|
username: user2Email,
|
||||||
|
organization: { connect: { id: org.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await profileRepositoryFixture.create({
|
||||||
|
uid: `usr-${user2.id}`,
|
||||||
|
username: user2Email,
|
||||||
|
organization: {
|
||||||
|
connect: {
|
||||||
|
id: org.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const membership2 = await membershipFixtures.addUserToOrg(user2, org, "ADMIN", true);
|
||||||
|
|
||||||
|
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||||
|
const attribute = await attributeRepositoryFixture.create({
|
||||||
|
name: "Test Attribute",
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
type: "TEXT",
|
||||||
|
slug: `test-attribute-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const attribute2 = await attributeRepositoryFixture.create({
|
||||||
|
name: "Test Attribute 2",
|
||||||
|
team: { connect: { id: org.id } },
|
||||||
|
type: "TEXT",
|
||||||
|
slug: `test-attribute-2-${randomString()}`,
|
||||||
|
});
|
||||||
|
const attributeId = attribute.id;
|
||||||
|
|
||||||
|
assignedOption1 = await attributeRepositoryFixture.createOption({
|
||||||
|
slug: "option1",
|
||||||
|
value: "option1",
|
||||||
|
attribute: { connect: { id: attribute.id } },
|
||||||
|
assignedUsers: {
|
||||||
|
create: {
|
||||||
|
memberId: membership.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assignedOption2 = await attributeRepositoryFixture.createOption({
|
||||||
|
slug: "optionA",
|
||||||
|
value: "optionA",
|
||||||
|
attribute: { connect: { id: attribute2.id } },
|
||||||
|
assignedUsers: { createMany: { data: [{ memberId: membership.id }, { memberId: membership2.id }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(userRepositoryFixture).toBeDefined();
|
||||||
|
expect(organizationsRepositoryFixture).toBeDefined();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(org).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get users with all specified assigned attribute options", async () => {
|
||||||
|
const { body } = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/users`)
|
||||||
|
.query({
|
||||||
|
assignedOptionIds: [assignedOption1.id],
|
||||||
|
attributeQueryOperator: "AND",
|
||||||
|
} as GetOrganizationsUsersInput)
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.set("Accept", "application/json");
|
||||||
|
|
||||||
|
const userData = body.data;
|
||||||
|
|
||||||
|
expect(body.status).toBe(SUCCESS_STATUS);
|
||||||
|
expect(userData.length).toBe(1);
|
||||||
|
|
||||||
|
const userWithAssignedOptions = userData.find(
|
||||||
|
(u: GetOrgUsersWithProfileOutput) => u.email === user.email
|
||||||
|
);
|
||||||
|
expect(userWithAssignedOptions).toBeDefined();
|
||||||
|
expect(userWithAssignedOptions?.email).toBe(user.email);
|
||||||
|
expect(userWithAssignedOptions?.profile.username).toBe(user.username);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get users with at least one of the specified assigned attribute options", async () => {
|
||||||
|
const { body } = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/users`)
|
||||||
|
.query({
|
||||||
|
assignedOptionIds: [assignedOption1.id, assignedOption2.id],
|
||||||
|
attributeQueryOperator: "OR",
|
||||||
|
} as GetOrganizationsUsersInput)
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.set("Accept", "application/json");
|
||||||
|
|
||||||
|
const userData = body.data;
|
||||||
|
|
||||||
|
expect(body.status).toBe(SUCCESS_STATUS);
|
||||||
|
expect(userData.length).toBe(2);
|
||||||
|
|
||||||
|
const userWithAssignedOptions = userData.find(
|
||||||
|
(u: GetOrgUsersWithProfileOutput) => u.email === user.email
|
||||||
|
);
|
||||||
|
expect(userWithAssignedOptions).toBeDefined();
|
||||||
|
expect(userWithAssignedOptions?.email).toBe(user.email);
|
||||||
|
expect(userWithAssignedOptions?.profile.username).toBe(user.username);
|
||||||
|
|
||||||
|
const userWithAssignedOptions2 = userData.find(
|
||||||
|
(u: GetOrgUsersWithProfileOutput) => u.email === user2.email
|
||||||
|
);
|
||||||
|
expect(userWithAssignedOptions2).toBeDefined();
|
||||||
|
expect(userWithAssignedOptions2?.email).toBe(user2.email);
|
||||||
|
expect(userWithAssignedOptions2?.profile.username).toBe(user2.username);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get users with at least one of the specified assigned attribute options filtered by teams", async () => {
|
||||||
|
const { body } = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/users`)
|
||||||
|
.query({
|
||||||
|
assignedOptionIds: [assignedOption1.id, assignedOption2.id],
|
||||||
|
attributeQueryOperator: "OR",
|
||||||
|
teamIds: [team.id],
|
||||||
|
} as GetOrganizationsUsersInput)
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.set("Accept", "application/json");
|
||||||
|
|
||||||
|
const userData = body.data;
|
||||||
|
|
||||||
|
expect(body.status).toBe(SUCCESS_STATUS);
|
||||||
|
expect(userData.length).toBe(1);
|
||||||
|
|
||||||
|
const userWithAssignedOptions = userData.find(
|
||||||
|
(u: GetOrgUsersWithProfileOutput) => u.email === user.email
|
||||||
|
);
|
||||||
|
expect(userWithAssignedOptions).toBeDefined();
|
||||||
|
expect(userWithAssignedOptions?.email).toBe(user.email);
|
||||||
|
expect(userWithAssignedOptions?.profile.username).toBe(user.username);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get users with none of the specified assigned attribute options", async () => {
|
||||||
|
const { body } = await request(app.getHttpServer())
|
||||||
|
.get(`/v2/organizations/${org.id}/users`)
|
||||||
|
.query({
|
||||||
|
assignedOptionIds: [assignedOption1.id, assignedOption2.id],
|
||||||
|
attributeQueryOperator: "NONE",
|
||||||
|
} as GetOrganizationsUsersInput)
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.set("Accept", "application/json");
|
||||||
|
|
||||||
|
const userData = body.data;
|
||||||
|
|
||||||
|
expect(body.status).toBe(SUCCESS_STATUS);
|
||||||
|
expect(userData.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// await membershipFixtures.delete(membership.id);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||||
|
await organizationsRepositoryFixture.delete(org.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+50
-1
@@ -1,3 +1,52 @@
|
|||||||
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
|
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
|
||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Transform } from "class-transformer";
|
||||||
|
import { Expose } from "class-transformer";
|
||||||
|
import { IsOptional, IsArray, ArrayMinSize, IsString, IsIn, IsNumber } from "class-validator";
|
||||||
|
|
||||||
export class GetOrganizationsUsersInput extends GetUsersInput {}
|
export class GetOrganizationsUsersInput extends GetUsersInput {
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.split(",").map((optId: string) => optId);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [String],
|
||||||
|
description: "Filter by assigned attribute option ids. ids must be separated by a comma.",
|
||||||
|
example: "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ArrayMinSize(1, { message: "assignedOptionIds must contain at least 1 attribute option id" })
|
||||||
|
assignedOptionIds?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
description: "Query operator used to filter assigned options, AND by default.",
|
||||||
|
example: "NONE",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(["OR", "AND", "NONE"])
|
||||||
|
attributeQueryOperator: "AND" | "OR" | "NONE" = "AND"; // Default value
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.split(",").map((teamId: string) => parseInt(teamId));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [Number],
|
||||||
|
description: "Filter by teamIds. Team ids must be separated by a comma.",
|
||||||
|
example: "?teamIds=100,200",
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@IsNumber({}, { each: true })
|
||||||
|
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
|
||||||
|
teamIds?: number[];
|
||||||
|
}
|
||||||
+54
-1
@@ -17,11 +17,64 @@ export class OrganizationsUsersRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrganizationUsersByEmails(orgId: number, emailArray?: string[], skip?: number, take?: number) {
|
async getOrganizationUsersByEmailsAndAttributeFilters(
|
||||||
|
orgId: number,
|
||||||
|
filters: {
|
||||||
|
teamIds?: number[];
|
||||||
|
assignedOptionIds: string[];
|
||||||
|
attributeQueryOperator?: "AND" | "OR" | "NONE";
|
||||||
|
},
|
||||||
|
emailArray?: string[],
|
||||||
|
skip?: number,
|
||||||
|
take?: number
|
||||||
|
) {
|
||||||
|
const { teamIds, assignedOptionIds, attributeQueryOperator } = filters ?? {};
|
||||||
|
const attributeToUsersWithProfile = await this.dbRead.prisma.attributeToUser.findMany({
|
||||||
|
include: {
|
||||||
|
member: { include: { user: { include: { profiles: { where: { organizationId: orgId } } } } } },
|
||||||
|
},
|
||||||
|
distinct: ["memberId"],
|
||||||
|
where: {
|
||||||
|
member: {
|
||||||
|
teamId: orgId,
|
||||||
|
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
|
||||||
|
// Filter to only get users which have ALL of the assigned attribue options
|
||||||
|
...(attributeQueryOperator === "AND" && {
|
||||||
|
AND: assignedOptionIds.map((optionId) => ({
|
||||||
|
AttributeToUser: { some: { attributeOptionId: optionId } },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
||||||
|
// Filter to get users which have AT LEAST ONE of the assigned attribue options
|
||||||
|
...(attributeQueryOperator === "OR" && {
|
||||||
|
attributeOption: { id: { in: assignedOptionIds } },
|
||||||
|
}),
|
||||||
|
// Filter to get users that have NONE the assigned attribue options
|
||||||
|
...(attributeQueryOperator === "NONE" && {
|
||||||
|
NOT: {
|
||||||
|
attributeOption: { id: { in: assignedOptionIds } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
});
|
||||||
|
return attributeToUsersWithProfile.map((attributeToUser) => attributeToUser.member.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrganizationUsersByEmails(
|
||||||
|
orgId: number,
|
||||||
|
emailArray?: string[],
|
||||||
|
teamIds?: number[],
|
||||||
|
skip?: number,
|
||||||
|
take?: number
|
||||||
|
) {
|
||||||
return await this.dbRead.prisma.user.findMany({
|
return await this.dbRead.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
...this.filterOnOrgMembership(orgId),
|
...this.filterOnOrgMembership(orgId),
|
||||||
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
||||||
|
...(teamIds && { teams: { some: { teamId: { in: teamIds } } } }),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
profiles: {
|
profiles: {
|
||||||
|
|||||||
+26
-1
@@ -16,12 +16,37 @@ export class OrganizationsUsersService {
|
|||||||
private readonly emailService: EmailService
|
private readonly emailService: EmailService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getUsers(orgId: number, emailInput?: string[], skip?: number, take?: number) {
|
async getUsers(
|
||||||
|
orgId: number,
|
||||||
|
emailInput?: string[],
|
||||||
|
filters?: {
|
||||||
|
teamIds?: number[];
|
||||||
|
assignedOptionIds?: string[];
|
||||||
|
attributeQueryOperator?: "AND" | "OR" | "NONE";
|
||||||
|
},
|
||||||
|
skip?: number,
|
||||||
|
take?: number
|
||||||
|
) {
|
||||||
const emailArray = !emailInput ? [] : emailInput;
|
const emailArray = !emailInput ? [] : emailInput;
|
||||||
|
|
||||||
|
if (filters?.assignedOptionIds && filters?.assignedOptionIds?.length) {
|
||||||
|
return await this.organizationsUsersRepository.getOrganizationUsersByEmailsAndAttributeFilters(
|
||||||
|
orgId,
|
||||||
|
{
|
||||||
|
assignedOptionIds: filters.assignedOptionIds,
|
||||||
|
attributeQueryOperator: filters?.attributeQueryOperator ?? "AND",
|
||||||
|
teamIds: filters?.teamIds,
|
||||||
|
},
|
||||||
|
emailArray,
|
||||||
|
skip,
|
||||||
|
take
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const users = await this.organizationsUsersRepository.getOrganizationUsersByEmails(
|
const users = await this.organizationsUsersRepository.getOrganizationUsersByEmails(
|
||||||
orgId,
|
orgId,
|
||||||
emailArray,
|
emailArray,
|
||||||
|
filters?.teamIds,
|
||||||
skip,
|
skip,
|
||||||
take
|
take
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export class OrgUsersOOORepository {
|
|||||||
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" },
|
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" },
|
||||||
filters?: { email?: string }
|
filters?: { email?: string }
|
||||||
) {
|
) {
|
||||||
console.log({ sort, filters });
|
|
||||||
return this.dbRead.prisma.outOfOfficeEntry.findMany({
|
return this.dbRead.prisma.outOfOfficeEntry.findMany({
|
||||||
where: {
|
where: {
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { ThrottlerGuard } from "@nestjs/throttler";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
|
|
||||||
// TODO: adapt if required for CF / AWS / FlightControl proxying.
|
|
||||||
protected async getTracker(req: Record<string, any>): Promise<string> {
|
|
||||||
return req.ips.length ? req.ips[0] : req.ip;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
StripCredentialsCheckOutputResponseDto,
|
StripCredentialsCheckOutputResponseDto,
|
||||||
StripCredentialsSaveOutputResponseDto,
|
StripCredentialsSaveOutputResponseDto,
|
||||||
} from "@/modules/stripe/outputs/stripe.output";
|
} from "@/modules/stripe/outputs/stripe.output";
|
||||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
import { OAuthCallbackState, StripeService } from "@/modules/stripe/stripe.service";
|
||||||
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||||
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Query,
|
Query,
|
||||||
@@ -22,8 +24,8 @@ import {
|
|||||||
Req,
|
Req,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Headers,
|
Headers,
|
||||||
Param,
|
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ApiTags as DocsTags, ApiOperation, ApiHeader } from "@nestjs/swagger";
|
import { ApiTags as DocsTags, ApiOperation, ApiHeader } from "@nestjs/swagger";
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -37,7 +39,12 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
|||||||
})
|
})
|
||||||
@DocsTags("Stripe")
|
@DocsTags("Stripe")
|
||||||
export class StripeController {
|
export class StripeController {
|
||||||
constructor(private readonly stripeService: StripeService) {}
|
constructor(
|
||||||
|
private readonly stripeService: StripeService,
|
||||||
|
private readonly tokensRepository: TokensRepository,
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly config: ConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get("/connect")
|
@Get("/connect")
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@@ -48,26 +55,20 @@ export class StripeController {
|
|||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Headers("Authorization") authorization: string,
|
@Headers("Authorization") authorization: string,
|
||||||
@GetUser() user: UserWithProfile,
|
@GetUser() user: UserWithProfile,
|
||||||
@Query("redir") redir?: string | null,
|
@Query("returnTo") returnTo?: string | null,
|
||||||
@Query("errorRedir") errorRedir?: string | null,
|
@Query("onErrorReturnTo") onErrorReturnTo?: string | null
|
||||||
@Query("teamId") teamId?: string | null
|
|
||||||
): Promise<StripConnectOutputResponseDto> {
|
): Promise<StripConnectOutputResponseDto> {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
const accessToken = authorization.replace("Bearer ", "");
|
const accessToken = authorization.replace("Bearer ", "");
|
||||||
|
|
||||||
const state = {
|
const state: OAuthCallbackState = {
|
||||||
onErrorReturnTo: !!errorRedir ? errorRedir : origin,
|
onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin,
|
||||||
fromApp: false,
|
fromApp: false,
|
||||||
returnTo: !!redir ? redir : origin,
|
returnTo: !!returnTo ? returnTo : origin,
|
||||||
accessToken,
|
accessToken,
|
||||||
teamId: Number(teamId) ?? null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
|
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(state, user.email, user.name);
|
||||||
JSON.stringify(state),
|
|
||||||
user.email,
|
|
||||||
user.name
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -79,24 +80,71 @@ export class StripeController {
|
|||||||
@UseGuards()
|
@UseGuards()
|
||||||
@Redirect(undefined, 301)
|
@Redirect(undefined, 301)
|
||||||
@ApiOperation({ summary: "Save stripe credentials" })
|
@ApiOperation({ summary: "Save stripe credentials" })
|
||||||
|
/**
|
||||||
|
* Handles saving Stripe credentials.
|
||||||
|
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
|
||||||
|
* otherwise, credentials are saved at the user level.
|
||||||
|
*
|
||||||
|
* Proxying ensures that permission checks—such as whether the user is allowed to install Stripe for a team or organization—
|
||||||
|
* are enforced via controller route guards, avoiding duplication of this logic within the service layer.
|
||||||
|
*/
|
||||||
async save(
|
async save(
|
||||||
@Query("state") state: string,
|
@Query("state") state: string,
|
||||||
@Query("code") code: string,
|
@Query("code") code: string,
|
||||||
@Query("error") error: string | undefined,
|
@Query("error") error: string | undefined,
|
||||||
@Query("error_description") error_description: string | undefined
|
@Query("error_description") error_description: string | undefined
|
||||||
): Promise<StripCredentialsSaveOutputResponseDto> {
|
): Promise<StripCredentialsSaveOutputResponseDto> {
|
||||||
const accessToken = JSON.parse(state).accessToken;
|
if (!state) {
|
||||||
|
throw new BadRequestException("Missing `state` query param");
|
||||||
// user cancels flow
|
|
||||||
if (error === "access_denied") {
|
|
||||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||||
throw new BadRequestException(stringify({ error, error_description }));
|
try {
|
||||||
}
|
// If teamId is present, proxy to team endpoint
|
||||||
|
if (decodedCallbackState.teamId && decodedCallbackState.orgId) {
|
||||||
|
let url = "";
|
||||||
|
const apiUrl = this.config.get("api.url");
|
||||||
|
url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/stripe/save`;
|
||||||
|
|
||||||
return await this.stripeService.saveStripeAccount(state, code, accessToken);
|
const params: Record<string, string | undefined> = { state, code, error, error_description };
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${decodedCallbackState.accessToken}`,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const response = await this.httpService.axiosRef.get(url, { params, headers });
|
||||||
|
const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || "";
|
||||||
|
return { url: redirectUrl };
|
||||||
|
} catch (err) {
|
||||||
|
const fallbackUrl = decodedCallbackState.onErrorReturnTo || "";
|
||||||
|
return { url: fallbackUrl };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// user-level fallback
|
||||||
|
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||||
|
|
||||||
|
// user cancels flow
|
||||||
|
if (error === "access_denied") {
|
||||||
|
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new BadRequestException(stringify({ error, error_description }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new BadRequestException("Invalid Access token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.stripeService.saveStripeAccount(decodedCallbackState, code, userId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(error.message);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/check")
|
@Get("/check")
|
||||||
@@ -107,15 +155,4 @@ export class StripeController {
|
|||||||
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||||
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
|
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/check/:teamId")
|
|
||||||
@UseGuards(ApiAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
|
||||||
@ApiOperation({ summary: "Check team stripe connection" })
|
|
||||||
async checkTeamStripeConnection(
|
|
||||||
@Param("teamId") teamId: string
|
|
||||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
|
||||||
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ export class StripConnectOutputResponseDto {
|
|||||||
|
|
||||||
export class StripCredentialsCheckOutputResponseDto {
|
export class StripCredentialsCheckOutputResponseDto {
|
||||||
@ApiProperty({ example: SUCCESS_STATUS })
|
@ApiProperty({ example: SUCCESS_STATUS })
|
||||||
status!: typeof SUCCESS_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StripCredentialsSaveOutputResponseDto {
|
export class StripCredentialsSaveOutputResponseDto {
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { StripeController } from "@/modules/stripe/controllers/stripe.controller
|
|||||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, PrismaModule, UsersModule],
|
imports: [ConfigModule, PrismaModule, UsersModule, HttpModule],
|
||||||
exports: [StripeService],
|
exports: [StripeService],
|
||||||
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
|
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
|
||||||
controllers: [StripeController],
|
controllers: [StripeController],
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import { AppConfig } from "@/config/type";
|
|||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
|
||||||
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||||
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
|
||||||
import { UsersRepository } from "@/modules/users/users.repository";
|
import { UsersRepository } from "@/modules/users/users.repository";
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -25,12 +23,13 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
|
|||||||
|
|
||||||
import stringify = require("qs-stringify");
|
import stringify = require("qs-stringify");
|
||||||
|
|
||||||
type IntegrationOAuthCallbackState = {
|
export type OAuthCallbackState = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
returnTo: string;
|
teamId?: string;
|
||||||
onErrorReturnTo: string;
|
orgId?: string;
|
||||||
fromApp: boolean;
|
fromApp?: boolean;
|
||||||
teamId?: number | null;
|
returnTo?: string;
|
||||||
|
onErrorReturnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -45,8 +44,7 @@ export class StripeService {
|
|||||||
configService: ConfigService<AppConfig>,
|
configService: ConfigService<AppConfig>,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly appsRepository: AppsRepository,
|
private readonly appsRepository: AppsRepository,
|
||||||
private readonly credentialRepository: CredentialsRepository,
|
private readonly credentialsRepository: CredentialsRepository,
|
||||||
private readonly tokensRepository: TokensRepository,
|
|
||||||
private readonly membershipRepository: MembershipsRepository,
|
private readonly membershipRepository: MembershipsRepository,
|
||||||
private readonly usersRepository: UsersRepository
|
private readonly usersRepository: UsersRepository
|
||||||
) {
|
) {
|
||||||
@@ -59,7 +57,7 @@ export class StripeService {
|
|||||||
return this.stripe;
|
return this.stripe;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
|
async getStripeRedirectUrl(state: OAuthCallbackState, userEmail?: string, userName?: string | null) {
|
||||||
const { client_id } = await this.getStripeAppKeys();
|
const { client_id } = await this.getStripeAppKeys();
|
||||||
|
|
||||||
const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = {
|
const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = {
|
||||||
@@ -73,7 +71,7 @@ export class StripeService {
|
|||||||
country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined,
|
country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined,
|
||||||
},
|
},
|
||||||
redirect_uri: this.redirectUri,
|
redirect_uri: this.redirectUri,
|
||||||
state: state,
|
state: JSON.stringify(state),
|
||||||
};
|
};
|
||||||
|
|
||||||
const params = z.record(z.any()).parse(stripeConnectParams);
|
const params = z.record(z.any()).parse(stripeConnectParams);
|
||||||
@@ -99,10 +97,7 @@ export class StripeService {
|
|||||||
return { client_id, client_secret };
|
return { client_id, client_secret };
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
|
async saveStripeAccount(state: OAuthCallbackState, code: string, userId: number): Promise<{ url: string }> {
|
||||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
|
||||||
const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
|
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new UnauthorizedException("Invalid Access token.");
|
throw new UnauthorizedException("Invalid Access token.");
|
||||||
}
|
}
|
||||||
@@ -118,17 +113,14 @@ export class StripeService {
|
|||||||
data["default_currency"] = account.default_currency;
|
data["default_currency"] = account.default_currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oAuthCallbackState.teamId) {
|
const existingCredentials = await this.credentialsRepository.findAllCredentialsByTypeAndUserId(
|
||||||
await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
|
"stripe_payment",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
await this.appsRepository.createTeamAppCredential(
|
const credentialIdsToDelete = existingCredentials.map((item) => item.id);
|
||||||
"stripe_payment",
|
if (credentialIdsToDelete.length > 0) {
|
||||||
data as unknown as Prisma.InputJsonObject,
|
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||||
oAuthCallbackState.teamId,
|
|
||||||
"stripe"
|
|
||||||
);
|
|
||||||
|
|
||||||
return { url: getReturnToValueFromQueryState(state) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.appsRepository.createAppCredential(
|
await this.appsRepository.createAppCredential(
|
||||||
@@ -138,11 +130,11 @@ export class StripeService {
|
|||||||
"stripe"
|
"stripe"
|
||||||
);
|
);
|
||||||
|
|
||||||
return { url: getReturnToValueFromQueryState(state) };
|
return { url: state.returnTo ?? "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
const stripeCredentials = await this.credentialsRepository.findCredentialByTypeAndUserId(
|
||||||
"stripe_payment",
|
"stripe_payment",
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
@@ -150,24 +142,6 @@ export class StripeService {
|
|||||||
return await this.validateStripeCredentials(stripeCredentials);
|
return await this.validateStripeCredentials(stripeCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
|
||||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
|
|
||||||
"stripe_payment",
|
|
||||||
teamId
|
|
||||||
);
|
|
||||||
|
|
||||||
return await this.validateStripeCredentials(stripeCredentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) {
|
|
||||||
const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId);
|
|
||||||
const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER";
|
|
||||||
|
|
||||||
if (!hasAdminAccessToTeam) {
|
|
||||||
throw new BadRequestException("You must be team owner or admin to do this");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateStripeCredentials(
|
async validateStripeCredentials(
|
||||||
credentials?: Credential | null
|
credentials?: Credential | null
|
||||||
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||||
import { AuthModule } from "@/modules/auth/auth.module";
|
|
||||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
|
||||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||||
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
|
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
|
||||||
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
|
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
|
||||||
@@ -14,7 +12,7 @@ import { TeamsEventTypesService } from "@/modules/teams/event-types/services/tea
|
|||||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,8 +22,7 @@ import { forwardRef, Module } from "@nestjs/common";
|
|||||||
EventTypesModule_2024_06_14,
|
EventTypesModule_2024_06_14,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
forwardRef(() => ConferencingModule),
|
OrganizationsConferencingModule,
|
||||||
forwardRef(() => AuthModule),
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TeamsEventTypesRepository,
|
TeamsEventTypesRepository,
|
||||||
@@ -34,7 +31,6 @@ import { forwardRef, Module } from "@nestjs/common";
|
|||||||
OrganizationsTeamsRepository,
|
OrganizationsTeamsRepository,
|
||||||
OutputTeamEventTypesResponsePipe,
|
OutputTeamEventTypesResponsePipe,
|
||||||
OutputOrganizationsEventTypesService,
|
OutputOrganizationsEventTypesService,
|
||||||
OrganizationsConferencingService,
|
|
||||||
],
|
],
|
||||||
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
|
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
|
||||||
controllers: [TeamsEventTypesController],
|
controllers: [TeamsEventTypesController],
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ export class TeamsEventTypesRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getEventTypeByTeamIdAndSlugWithOwnerAndTeam(teamId: number, eventTypeSlug: string) {
|
||||||
|
return this.dbRead.prisma.eventType.findUnique({
|
||||||
|
where: {
|
||||||
|
teamId_slug: {
|
||||||
|
teamId,
|
||||||
|
slug: eventTypeSlug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { owner: true, team: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getTeamEventTypes(teamId: number) {
|
async getTeamEventTypes(teamId: number) {
|
||||||
return this.dbRead.prisma.eventType.findMany({
|
return this.dbRead.prisma.eventType.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -32,21 +32,25 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
|
|||||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new ForbiddenException("User not authenticated");
|
throw new ForbiddenException("IsOAuthClientWebhookGuard - User not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!webhookId) {
|
if (!webhookId) {
|
||||||
throw new BadRequestException("webhookId parameter not specified in the request");
|
throw new BadRequestException(
|
||||||
|
"IsOAuthClientWebhookGuard - webhookId parameter not specified in the request"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!oAuthClientId) {
|
if (!oAuthClientId) {
|
||||||
throw new BadRequestException("oAuthClientId parameter not specified in the request");
|
throw new BadRequestException(
|
||||||
|
"IsOAuthClientWebhookGuard - oAuthClientId parameter not specified in the request"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
|
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
|
||||||
|
|
||||||
if (!oAuthClient) {
|
if (!oAuthClient) {
|
||||||
throw new NotFoundException(`OAuthClient (${oAuthClientId}) not found`);
|
throw new NotFoundException(`IsOAuthClientWebhookGuard - OAuthClient (${oAuthClientId}) not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhook = await this.webhooksService.getWebhookById(webhookId);
|
const webhook = await this.webhooksService.getWebhookById(webhookId);
|
||||||
@@ -56,7 +60,7 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (webhook.platformOAuthClientId !== oAuthClientId) {
|
if (webhook.platformOAuthClientId !== oAuthClientId) {
|
||||||
throw new ForbiddenException("Webhook does not belong to this oAuthClient");
|
throw new ForbiddenException("IsOAuthClientWebhookGuard - Webhook does not belong to this oAuthClient");
|
||||||
}
|
}
|
||||||
|
|
||||||
request.webhook = webhook;
|
request.webhook = webhook;
|
||||||
|
|||||||
@@ -28,16 +28,18 @@ export class IsUserEventTypeWebhookGuard implements CanActivate {
|
|||||||
const eventTypeId = request.params.eventTypeId;
|
const eventTypeId = request.params.eventTypeId;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return false;
|
throw new ForbiddenException("IsUserEventTypeWebhookGuard - No user associated with the request.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventTypeId) {
|
if (eventTypeId) {
|
||||||
const eventType = await this.eventtypesRepository.getEventTypeById(parseInt(eventTypeId));
|
const eventType = await this.eventtypesRepository.getEventTypeById(parseInt(eventTypeId));
|
||||||
if (!eventType) {
|
if (!eventType) {
|
||||||
throw new NotFoundException(`Event type (${eventTypeId}) not found`);
|
throw new NotFoundException(`IsUserEventTypeWebhookGuard - Event type (${eventTypeId}) not found`);
|
||||||
}
|
}
|
||||||
if (eventType.userId !== user.id) {
|
if (eventType.userId !== user.id) {
|
||||||
throw new ForbiddenException(`User (${user.id}) is not the owner of event type (${eventTypeId})`);
|
throw new ForbiddenException(
|
||||||
|
`IsUserEventTypeWebhookGuard - User (${user.id}) is not the owner of event type (${eventTypeId})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
request.eventType = eventType;
|
request.eventType = eventType;
|
||||||
}
|
}
|
||||||
@@ -45,11 +47,13 @@ export class IsUserEventTypeWebhookGuard implements CanActivate {
|
|||||||
if (webhookId) {
|
if (webhookId) {
|
||||||
const webhook = await this.webhooksService.getWebhookById(webhookId);
|
const webhook = await this.webhooksService.getWebhookById(webhookId);
|
||||||
if (!webhook.eventTypeId) {
|
if (!webhook.eventTypeId) {
|
||||||
throw new BadRequestException(`Webhook (${webhookId}) is not associated with an event type`);
|
throw new BadRequestException(
|
||||||
|
`IsUserEventTypeWebhookGuard - Webhook (${webhookId}) is not associated with an event type`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (webhook.eventTypeId !== parseInt(eventTypeId)) {
|
if (webhook.eventTypeId !== parseInt(eventTypeId)) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Webhook (${webhookId}) is not associated with event type (${eventTypeId})`
|
`IsUserEventTypeWebhookGuard - Webhook (${webhookId}) is not associated with event type (${eventTypeId})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
request.webhook = webhook;
|
request.webhook = webhook;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
|
||||||
import { Webhook } from "@calcom/prisma/client";
|
import { Webhook } from "@calcom/prisma/client";
|
||||||
@@ -14,18 +14,23 @@ export class IsUserWebhookGuard implements CanActivate {
|
|||||||
const user = request.user as ApiAuthGuardUser;
|
const user = request.user as ApiAuthGuardUser;
|
||||||
const webhookId = request.params.webhookId;
|
const webhookId = request.params.webhookId;
|
||||||
|
|
||||||
if (!user || !webhookId) {
|
if (!user) {
|
||||||
return false;
|
throw new ForbiddenException("IsUserWebhookGuard - No user associated with the request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhookId) {
|
||||||
|
throw new ForbiddenException("IsUserWebhookGuard - No webhook id found in request params.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhook = await this.webhooksService.getWebhookById(webhookId);
|
const webhook = await this.webhooksService.getWebhookById(webhookId);
|
||||||
|
|
||||||
if (webhook.userId !== user.id) {
|
if (webhook.userId !== user.id && !user.isSystemAdmin) {
|
||||||
return user.isSystemAdmin;
|
throw new ForbiddenException(
|
||||||
|
`IsUserWebhookGuard - user with id=(${user.id}) is not the owner of webhook with id=(${webhookId})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.webhook = webhook;
|
request.webhook = webhook;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1110,6 +1110,190 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v2/organizations/{orgId}/attributes/{attributeId}/options/assigned": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptions",
|
||||||
|
"summary": "Get by attribute id all of the attribute options that are assigned to users",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header",
|
||||||
|
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "orgId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attributeId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "skip",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Number of responses to skip",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "take",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Number of responses to take",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assignedOptionIds",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by assigned attribute option ids. ids must be separated by a comma.",
|
||||||
|
"example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teamIds",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by teamIds. Team ids must be separated by a comma.",
|
||||||
|
"example": "?teamIds=100,200",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/GetAllAttributeAssignedOptionOutput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Orgs / Attributes / Options"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v2/organizations/{orgId}/attributes/slugs/{attributeSlug}/options/assigned": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptionsBySlug",
|
||||||
|
"summary": "Get by attribute slug all of the attribute options that are assigned to users",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header",
|
||||||
|
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "orgId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attributeSlug",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "skip",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Number of responses to skip",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "take",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Number of responses to take",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assignedOptionIds",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by assigned attribute option ids. ids must be separated by a comma.",
|
||||||
|
"example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teamIds",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by teamIds. Team ids must be separated by a comma.",
|
||||||
|
"example": "?teamIds=100,200",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/GetAllAttributeAssignedOptionOutput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Orgs / Attributes / Options"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v2/organizations/{orgId}/attributes/options/{userId}": {
|
"/v2/organizations/{orgId}/attributes/options/{userId}": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "OrganizationsAttributesOptionsController_assignOrganizationAttributeOptionToUser",
|
"operationId": "OrganizationsAttributesOptionsController_assignOrganizationAttributeOptionToUser",
|
||||||
@@ -3393,6 +3577,62 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/oauth/callback": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "OrganizationsConferencingController_saveTeamOauthCredentials",
|
||||||
|
"summary": "Save conferencing app OAuth credentials",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "state",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teamId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "orgId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Orgs / Teams / Conferencing"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v2/organizations/{orgId}/teams/{teamId}/event-types": {
|
"/v2/organizations/{orgId}/teams/{teamId}/event-types": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "OrganizationsEventTypesController_createTeamEventType",
|
"operationId": "OrganizationsEventTypesController_createTeamEventType",
|
||||||
@@ -4777,6 +5017,48 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assignedOptionIds",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by assigned attribute option ids. ids must be separated by a comma.",
|
||||||
|
"example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attributeQueryOperator",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Query operator used to filter assigned options, AND by default.",
|
||||||
|
"example": "NONE",
|
||||||
|
"schema": {
|
||||||
|
"default": "AND",
|
||||||
|
"enum": [
|
||||||
|
"OR",
|
||||||
|
"AND",
|
||||||
|
"NONE"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teamIds",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Filter by teamIds. Team ids must be separated by a comma.",
|
||||||
|
"example": "?teamIds=100,200",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -6353,7 +6635,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"operationId": "BookingsController_2024_08_13_createBooking",
|
"operationId": "BookingsController_2024_08_13_createBooking",
|
||||||
"summary": "Create a booking",
|
"summary": "Create a booking",
|
||||||
"description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n\n Finally, there are 2 ways to book an event type:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `username` and optionally `organizationSlug` if the user with the username is within an organization.\n ",
|
"description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n\n Finally, there are 2 ways to book an event type belonging to an individual user:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `username` and optionally `organizationSlug` if the user with the username is within an organization.\n\n And 2 ways to book and event type belonging to a team:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `teamSlug` and optionally `organizationSlug` if the team with the teamSlug is within an organization.\n ",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "cal-api-version",
|
"name": "cal-api-version",
|
||||||
@@ -9023,6 +9305,147 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
|
||||||
|
"summary": "Get stripe connect URL for a team",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"required": true,
|
||||||
|
"in": "header",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teamId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "orgId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "returnTo",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "onErrorReturnTo",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/StripConnectOutputResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Organizations/Teams Stripe"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v2/organizations/{orgId}/teams/{teamId}/stripe/check": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "OrganizationsStripeController_checkTeamStripeConnection",
|
||||||
|
"summary": "Check team stripe connection",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "teamId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Organizations/Teams Stripe"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v2/organizations/{orgId}/teams/{teamId}/stripe/save": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "OrganizationsStripeController_save",
|
||||||
|
"summary": "Save stripe credentials",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "state",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "teamId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Organizations/Teams Stripe"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/v2/routing-forms/{routingFormId}/calculate-slots": {
|
"/v2/routing-forms/{routingFormId}/calculate-slots": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "RoutingFormsController_calculateSlotsBasedOnRoutingFormResponse",
|
"operationId": "RoutingFormsController_calculateSlotsBasedOnRoutingFormResponse",
|
||||||
@@ -10008,46 +10431,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v2/stripe/check/{teamId}": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "StripeController_checkTeamStripeConnection",
|
|
||||||
"summary": "Check team stripe connection",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "teamId",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Authorization",
|
|
||||||
"in": "header",
|
|
||||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"Stripe"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/v2/teams": {
|
"/v2/teams": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "TeamsController_createTeam",
|
"operationId": "TeamsController_createTeam",
|
||||||
@@ -18416,6 +18799,72 @@
|
|||||||
"data"
|
"data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"AssignedOptionOutput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ID of the option",
|
||||||
|
"example": "attr_option_id"
|
||||||
|
},
|
||||||
|
"attributeId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ID of the attribute",
|
||||||
|
"example": "attr_id"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The value of the option",
|
||||||
|
"example": "option_value"
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The slug of the option",
|
||||||
|
"example": "option-slug"
|
||||||
|
},
|
||||||
|
"assignedUserIds": {
|
||||||
|
"description": "Ids of the users assigned to the attribute option.",
|
||||||
|
"example": [
|
||||||
|
124,
|
||||||
|
224
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"attributeId",
|
||||||
|
"value",
|
||||||
|
"slug",
|
||||||
|
"assignedUserIds"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GetAllAttributeAssignedOptionOutput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "success",
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssignedOptionOutput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
},
|
||||||
"AssignOrganizationAttributeOptionToUserInput": {
|
"AssignOrganizationAttributeOptionToUserInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -18864,7 +19313,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
"type": "string",
|
"type": "object",
|
||||||
"example": "success"
|
"example": "success"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -20666,7 +21115,7 @@
|
|||||||
},
|
},
|
||||||
"eventTypeSlug": {
|
"eventTypeSlug": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
|
"description": "The slug of the event type. Required along with username / teamSlug and optionally organizationSlug if eventTypeId is not provided.",
|
||||||
"example": "my-event-type"
|
"example": "my-event-type"
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
@@ -20674,9 +21123,14 @@
|
|||||||
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
|
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
|
||||||
"example": "john-doe"
|
"example": "john-doe"
|
||||||
},
|
},
|
||||||
|
"teamSlug": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Team slug for team that owns event type for which slots are fetched. Required along with eventTypeSlug and optionally organizationSlug if the team is part of organization",
|
||||||
|
"example": "john-doe"
|
||||||
|
},
|
||||||
"organizationSlug": {
|
"organizationSlug": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
|
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.",
|
||||||
"example": "acme-corp"
|
"example": "acme-corp"
|
||||||
},
|
},
|
||||||
"attendee": {
|
"attendee": {
|
||||||
@@ -20773,7 +21227,7 @@
|
|||||||
},
|
},
|
||||||
"eventTypeSlug": {
|
"eventTypeSlug": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
|
"description": "The slug of the event type. Required along with username / teamSlug and optionally organizationSlug if eventTypeId is not provided.",
|
||||||
"example": "my-event-type"
|
"example": "my-event-type"
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
@@ -20781,9 +21235,14 @@
|
|||||||
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
|
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
|
||||||
"example": "john-doe"
|
"example": "john-doe"
|
||||||
},
|
},
|
||||||
|
"teamSlug": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Team slug for team that owns event type for which slots are fetched. Required along with eventTypeSlug and optionally organizationSlug if the team is part of organization",
|
||||||
|
"example": "john-doe"
|
||||||
|
},
|
||||||
"organizationSlug": {
|
"organizationSlug": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
|
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.",
|
||||||
"example": "acme-corp"
|
"example": "acme-corp"
|
||||||
},
|
},
|
||||||
"attendee": {
|
"attendee": {
|
||||||
@@ -20886,7 +21345,7 @@
|
|||||||
},
|
},
|
||||||
"eventTypeSlug": {
|
"eventTypeSlug": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
|
"description": "The slug of the event type. Required along with username / teamSlug and optionally organizationSlug if eventTypeId is not provided.",
|
||||||
"example": "my-event-type"
|
"example": "my-event-type"
|
||||||
},
|
},
|
||||||
"username": {
|
"username": {
|
||||||
@@ -20894,9 +21353,14 @@
|
|||||||
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
|
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
|
||||||
"example": "john-doe"
|
"example": "john-doe"
|
||||||
},
|
},
|
||||||
|
"teamSlug": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Team slug for team that owns event type for which slots are fetched. Required along with eventTypeSlug and optionally organizationSlug if the team is part of organization",
|
||||||
|
"example": "john-doe"
|
||||||
|
},
|
||||||
"organizationSlug": {
|
"organizationSlug": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
|
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.",
|
||||||
"example": "acme-corp"
|
"example": "acme-corp"
|
||||||
},
|
},
|
||||||
"attendee": {
|
"attendee": {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/api/docs
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
|
|
||||||
|
|
||||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
import * as OpenAPISnippet from "openapi-snippet";
|
|
||||||
|
|
||||||
export const requestSnippets = {
|
|
||||||
generators: {
|
|
||||||
curl_bash: {
|
|
||||||
title: "cURL (bash)",
|
|
||||||
syntax: "bash",
|
|
||||||
},
|
|
||||||
curl_powershell: {
|
|
||||||
title: "cURL (PowerShell)",
|
|
||||||
syntax: "powershell",
|
|
||||||
},
|
|
||||||
curl_cmd: {
|
|
||||||
title: "cURL (CMD)",
|
|
||||||
syntax: "bash",
|
|
||||||
},
|
|
||||||
node: {
|
|
||||||
title: "Node",
|
|
||||||
syntax: "node",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultExpanded: true,
|
|
||||||
languages: ["node", "curl_bash"],
|
|
||||||
};
|
|
||||||
// Since swagger-ui-react was not configured to change the request snippets some workarounds required
|
|
||||||
// configuration will be added programatically
|
|
||||||
// Custom Plugin
|
|
||||||
export const SnippedGenerator = {
|
|
||||||
statePlugins: {
|
|
||||||
// extend some internals to gain information about current path, method and spec in the generator function metioned later
|
|
||||||
spec: {
|
|
||||||
wrapSelectors: {
|
|
||||||
requestFor: (ori) => (state, path, method) => {
|
|
||||||
return ori(path, method)
|
|
||||||
?.set("spec", state.get("json", {}))
|
|
||||||
?.setIn(["oasPathMethod", "path"], path)
|
|
||||||
?.setIn(["oasPathMethod", "method"], method);
|
|
||||||
},
|
|
||||||
mutatedRequestFor: (ori) => (state, path, method) => {
|
|
||||||
return ori(path, method)
|
|
||||||
?.set("spec", state.get("json", {}))
|
|
||||||
?.setIn(["oasPathMethod", "path"], path)
|
|
||||||
?.setIn(["oasPathMethod", "method"], method);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// extend the request snippets core plugin
|
|
||||||
requestSnippets: {
|
|
||||||
wrapSelectors: {
|
|
||||||
// add additional snippet generators here
|
|
||||||
getSnippetGenerators:
|
|
||||||
(ori, system) =>
|
|
||||||
(state, ...args) =>
|
|
||||||
ori(state, ...args)
|
|
||||||
// add node native snippet generator
|
|
||||||
// .set(
|
|
||||||
// // key
|
|
||||||
// "node_native",
|
|
||||||
// // config and generator function
|
|
||||||
// system.Im.fromJS({
|
|
||||||
// title: "NodeJs Native",
|
|
||||||
// syntax: "javascript",
|
|
||||||
// hostname: "test",
|
|
||||||
// fn: (req) => {
|
|
||||||
// // get extended info about request
|
|
||||||
// const { spec, oasPathMethod } = req.toJS();
|
|
||||||
// const { path, method } = oasPathMethod;
|
|
||||||
|
|
||||||
// // run OpenAPISnippet for target node
|
|
||||||
// const targets = ["node_native"];
|
|
||||||
// let snippet;
|
|
||||||
// try {
|
|
||||||
// // set request snippet content
|
|
||||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
|
||||||
// spec,
|
|
||||||
// path,
|
|
||||||
// method,
|
|
||||||
// targets
|
|
||||||
// // Since I don't know why hostname was undefinedundefined, I harcoded it here
|
|
||||||
// ).snippets[0].content;
|
|
||||||
// } catch (err) {
|
|
||||||
// // set to error in case it happens the npm package has some flaws
|
|
||||||
// snippet = JSON.stringify(snippet);
|
|
||||||
// }
|
|
||||||
// // return stringified snipped
|
|
||||||
// return snippet;
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// )
|
|
||||||
.set(
|
|
||||||
// key
|
|
||||||
"node_fetch",
|
|
||||||
// config and generator function
|
|
||||||
system.Im.fromJS({
|
|
||||||
title: "NodeJS",
|
|
||||||
syntax: "javascript",
|
|
||||||
fn: (req) => {
|
|
||||||
// get extended info about request
|
|
||||||
const { spec, oasPathMethod } = req.toJS();
|
|
||||||
const { path, method } = oasPathMethod;
|
|
||||||
|
|
||||||
// run OpenAPISnippet for target node
|
|
||||||
const targets = ["node_fetch"];
|
|
||||||
let snippet;
|
|
||||||
try {
|
|
||||||
// set request snippet content
|
|
||||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
|
||||||
.content;
|
|
||||||
} catch (err) {
|
|
||||||
// set to error in case it happens the npm package has some flaws
|
|
||||||
snippet = JSON.stringify(snippet);
|
|
||||||
}
|
|
||||||
// return stringified snipped
|
|
||||||
return snippet;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.set(
|
|
||||||
// key
|
|
||||||
"shell_httpie",
|
|
||||||
// config and generator function
|
|
||||||
system.Im.fromJS({
|
|
||||||
title: "HTTPie",
|
|
||||||
syntax: "bash",
|
|
||||||
fn: (req) => {
|
|
||||||
// get extended info about request
|
|
||||||
const { spec, oasPathMethod } = req.toJS();
|
|
||||||
const { path, method } = oasPathMethod;
|
|
||||||
|
|
||||||
// run OpenAPISnippet for target node
|
|
||||||
const targets = ["shell_httpie"];
|
|
||||||
let snippet;
|
|
||||||
try {
|
|
||||||
// set request snippet content
|
|
||||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
|
||||||
.content;
|
|
||||||
} catch (err) {
|
|
||||||
// set to error in case it happens the npm package has some flaws
|
|
||||||
snippet = JSON.stringify(snippet);
|
|
||||||
}
|
|
||||||
// return stringified snipped
|
|
||||||
return snippet;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.set(
|
|
||||||
// key
|
|
||||||
"php_curl",
|
|
||||||
// config and generator function
|
|
||||||
system.Im.fromJS({
|
|
||||||
title: "PHP",
|
|
||||||
syntax: "php",
|
|
||||||
fn: (req) => {
|
|
||||||
// get extended info about request
|
|
||||||
const { spec, oasPathMethod } = req.toJS();
|
|
||||||
const { path, method } = oasPathMethod;
|
|
||||||
|
|
||||||
// run OpenAPISnippet for target node
|
|
||||||
const targets = ["php_curl"];
|
|
||||||
let snippet;
|
|
||||||
try {
|
|
||||||
// set request snippet content
|
|
||||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
|
||||||
.content;
|
|
||||||
} catch (err) {
|
|
||||||
// set to error in case it happens the npm package has some flaws
|
|
||||||
snippet = JSON.stringify(snippet);
|
|
||||||
}
|
|
||||||
// return stringified snipped
|
|
||||||
return snippet;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.set(
|
|
||||||
// key
|
|
||||||
"java_okhttp",
|
|
||||||
// config and generator function
|
|
||||||
system.Im.fromJS({
|
|
||||||
title: "Java",
|
|
||||||
syntax: "java",
|
|
||||||
fn: (req) => {
|
|
||||||
// get extended info about request
|
|
||||||
const { spec, oasPathMethod } = req.toJS();
|
|
||||||
const { path, method } = oasPathMethod;
|
|
||||||
console.log(spec, oasPathMethod, path, method);
|
|
||||||
// run OpenAPISnippet for target node
|
|
||||||
const targets = ["java_okhttp"];
|
|
||||||
let snippet;
|
|
||||||
try {
|
|
||||||
// set request snippet content
|
|
||||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
|
||||||
.content;
|
|
||||||
} catch (err) {
|
|
||||||
// set to error in case it happens the npm package has some flaws
|
|
||||||
snippet = JSON.stringify(snippet);
|
|
||||||
}
|
|
||||||
// return stringified snipped
|
|
||||||
return snippet;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
// .set(
|
|
||||||
// // key
|
|
||||||
// "java",
|
|
||||||
// // config and generator function
|
|
||||||
// system.Im.fromJS({
|
|
||||||
// title: "Java (Unirest)",
|
|
||||||
// syntax: "java",
|
|
||||||
// fn: (req) => {
|
|
||||||
// // get extended info about request
|
|
||||||
// const { spec, oasPathMethod } = req.toJS();
|
|
||||||
// const { path, method } = oasPathMethod;
|
|
||||||
|
|
||||||
// // run OpenAPISnippet for target node
|
|
||||||
// const targets = ["java"];
|
|
||||||
// let snippet;
|
|
||||||
// try {
|
|
||||||
// // set request snippet content
|
|
||||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
|
||||||
// spec,
|
|
||||||
// path,
|
|
||||||
// method,
|
|
||||||
// targets
|
|
||||||
// ).snippets[0].content;
|
|
||||||
// } catch (err) {
|
|
||||||
// // set to error in case it happens the npm package has some flaws
|
|
||||||
// snippet = JSON.stringify(snippet);
|
|
||||||
// }
|
|
||||||
// // return stringified snipped
|
|
||||||
// return snippet;
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// )
|
|
||||||
// .set(
|
|
||||||
// // key
|
|
||||||
// "c_libcurl",
|
|
||||||
// // config and generator function
|
|
||||||
// system.Im.fromJS({
|
|
||||||
// title: "C (libcurl) ",
|
|
||||||
// syntax: "bash",
|
|
||||||
// fn: (req) => {
|
|
||||||
// // get extended info about request
|
|
||||||
// const { spec, oasPathMethod } = req.toJS();
|
|
||||||
// const { path, method } = oasPathMethod;
|
|
||||||
|
|
||||||
// // run OpenAPISnippet for target node
|
|
||||||
// const targets = ["c_libcurl"];
|
|
||||||
// let snippet;
|
|
||||||
// try {
|
|
||||||
// // set request snippet content
|
|
||||||
// snippet = OpenAPISnippet.getEndpointSnippets(
|
|
||||||
// spec,
|
|
||||||
// path,
|
|
||||||
// method,
|
|
||||||
// targets
|
|
||||||
// ).snippets[0].content;
|
|
||||||
// } catch (err) {
|
|
||||||
// // set to error in case it happens the npm package has some flaws
|
|
||||||
// snippet = JSON.stringify(snippet);
|
|
||||||
// }
|
|
||||||
// // return stringified snipped
|
|
||||||
// return snippet;
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// )
|
|
||||||
.set(
|
|
||||||
// key
|
|
||||||
"go_native",
|
|
||||||
// config and generator function
|
|
||||||
system.Im.fromJS({
|
|
||||||
title: "Go",
|
|
||||||
syntax: "bash",
|
|
||||||
fn: (req) => {
|
|
||||||
// get extended info about request
|
|
||||||
const { spec, oasPathMethod } = req.toJS();
|
|
||||||
const { path, method } = oasPathMethod;
|
|
||||||
|
|
||||||
// run OpenAPISnippet for target node
|
|
||||||
const targets = ["go_native"];
|
|
||||||
let snippet;
|
|
||||||
try {
|
|
||||||
// set request snippet content
|
|
||||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
|
||||||
.content;
|
|
||||||
} catch (err) {
|
|
||||||
// set to error in case it happens the npm package has some flaws
|
|
||||||
snippet = JSON.stringify(snippet);
|
|
||||||
}
|
|
||||||
// return stringified snipped
|
|
||||||
return snippet;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.set(
|
|
||||||
// key
|
|
||||||
"ruby",
|
|
||||||
// config and generator function
|
|
||||||
system.Im.fromJS({
|
|
||||||
title: "Ruby",
|
|
||||||
syntax: "ruby",
|
|
||||||
fn: (req) => {
|
|
||||||
// get extended info about request
|
|
||||||
const { spec, oasPathMethod } = req.toJS();
|
|
||||||
const { path, method } = oasPathMethod;
|
|
||||||
|
|
||||||
// run OpenAPISnippet for target node
|
|
||||||
const targets = ["ruby"];
|
|
||||||
let snippet;
|
|
||||||
try {
|
|
||||||
// set request snippet content
|
|
||||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
|
||||||
.content;
|
|
||||||
} catch (err) {
|
|
||||||
// set to error in case it happens the npm package has some flaws
|
|
||||||
snippet = JSON.stringify(snippet);
|
|
||||||
}
|
|
||||||
// return stringified snipped
|
|
||||||
return snippet;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.set(
|
|
||||||
// key
|
|
||||||
"python",
|
|
||||||
// config and generator function
|
|
||||||
system.Im.fromJS({
|
|
||||||
title: "Python",
|
|
||||||
syntax: "python",
|
|
||||||
fn: (req) => {
|
|
||||||
// get extended info about request
|
|
||||||
const { spec, oasPathMethod } = req.toJS();
|
|
||||||
const { path, method } = oasPathMethod;
|
|
||||||
|
|
||||||
// run OpenAPISnippet for target node
|
|
||||||
const targets = ["python"];
|
|
||||||
let snippet;
|
|
||||||
try {
|
|
||||||
// set request snippet content
|
|
||||||
snippet = OpenAPISnippet.getEndpointSnippets(spec, path, method, targets).snippets[0]
|
|
||||||
.content;
|
|
||||||
} catch (err) {
|
|
||||||
// set to error in case it happens the npm package has some flaws
|
|
||||||
snippet = JSON.stringify(snippet);
|
|
||||||
}
|
|
||||||
// return stringified snipped
|
|
||||||
return snippet;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@calcom/swagger",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"build": "next build",
|
|
||||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
|
||||||
"dev": "PORT=4200 next dev",
|
|
||||||
"lint": "eslint . --ignore-path .gitignore",
|
|
||||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
|
||||||
"lint:report": "eslint . --format json --output-file ../../lint-results/swagger.json",
|
|
||||||
"start": "PORT=4200 next start"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"highlight.js": "^11.6.0",
|
|
||||||
"isarray": "2.0.5",
|
|
||||||
"next": "^13.5.6",
|
|
||||||
"openapi-snippet": "^0.13.0",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"swagger-ui-react": "4.11.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "18.0.26",
|
|
||||||
"@types/react-dom": "^18.0.9",
|
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
|
||||||
"typescript": "^4.9.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import "highlight.js/styles/default.css";
|
|
||||||
import { type AppProps } from "next/app";
|
|
||||||
import "swagger-ui-react/swagger-ui.css";
|
|
||||||
|
|
||||||
import "../styles/globals.css";
|
|
||||||
|
|
||||||
function MyApp({ Component, pageProps }: AppProps) {
|
|
||||||
return <Component {...pageProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MyApp;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
import { SnippedGenerator, requestSnippets } from "@lib/snippets";
|
|
||||||
|
|
||||||
const SwaggerUIDynamic = dynamic(() => import("swagger-ui-react"), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function APIDocs() {
|
|
||||||
return (
|
|
||||||
<SwaggerUIDynamic
|
|
||||||
url={process.env.NEXT_PUBLIC_SWAGGER_DOCS_URL || "https://api.cal.com/docs"}
|
|
||||||
persistAuthorization={true}
|
|
||||||
supportedSubmitMethods={["get", "post", "delete", "put", "options", "patch"]}
|
|
||||||
requestSnippetsEnabled={true}
|
|
||||||
requestSnippets={requestSnippets}
|
|
||||||
plugins={[SnippedGenerator]}
|
|
||||||
tryItOutEnabled={true}
|
|
||||||
docExpansion="list"
|
|
||||||
filter={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,103 +0,0 @@
|
|||||||
html,
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans,
|
|
||||||
Droid Sans, Helvetica Neue, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.swagger-ui .opblock-tag {
|
|
||||||
font-size: 90% !important;
|
|
||||||
}
|
|
||||||
.swagger-ui .opblock .opblock-summary {
|
|
||||||
display: grid;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.opblock-summary-path {
|
|
||||||
flex-shrink: 0;
|
|
||||||
max-width: 100% !important;
|
|
||||||
padding: 10px 5px !important;
|
|
||||||
}
|
|
||||||
.opblock-summary-description {
|
|
||||||
font-size: 16px !important;
|
|
||||||
padding: 0px 5px;
|
|
||||||
}
|
|
||||||
.swagger-ui .scheme-container .schemes {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.swagger-ui .info .title {
|
|
||||||
color: #3b4151;
|
|
||||||
font-family: sans-serif;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
.swagger-ui .scheme-container {
|
|
||||||
padding: 14px 0;
|
|
||||||
}
|
|
||||||
.swagger-ui .info {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
.swagger-ui .auth-wrapper {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
.swagger-ui .authorization__btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.swagger-ui .opblock {
|
|
||||||
margin: 0 0 5px;
|
|
||||||
}
|
|
||||||
button.opblock-summary-control > svg {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.swagger-ui .filter .operation-filter-input {
|
|
||||||
border: 2px solid #d8dde7;
|
|
||||||
margin: 5px 5px;
|
|
||||||
padding: 5px;
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
.swagger-ui .wrapper {
|
|
||||||
padding: 0 4px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.swagger-ui .info .title small {
|
|
||||||
top: 5px;
|
|
||||||
}
|
|
||||||
.swagger-ui a.nostyle,
|
|
||||||
.swagger-ui a.nostyle:visited {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
div.request-snippets > div.curl-command > div:nth-child(1) {
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
overflow-x: scroll;
|
|
||||||
}
|
|
||||||
.swagger-ui .opblock-body pre.microlight {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
.swagger-ui table tbody tr td {
|
|
||||||
padding: 0px 0 0;
|
|
||||||
vertical-align: none;
|
|
||||||
}
|
|
||||||
td.response-col_description > div > div > p {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > tbody > tr {
|
|
||||||
display: flex;
|
|
||||||
width: 100vw;
|
|
||||||
flex-direction: column;
|
|
||||||
font-size: 60%;
|
|
||||||
}
|
|
||||||
div.no-margin > div > div.responses-wrapper > div.responses-inner > div > div > table > thead > tr {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@calcom/tsconfig/nextjs.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@lib/*": ["lib/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"next-env.d.ts",
|
|
||||||
"../../packages/types/*.d.ts",
|
|
||||||
"../../packages/types/next-auth.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx"
|
|
||||||
],
|
|
||||||
"exclude": ["**/node_modules/**"]
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ const getData = withAppDirSsr<OrgPageProps>(getServerSideProps);
|
|||||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||||
const props = await getData(legacyCtx);
|
const props = await getData(legacyCtx);
|
||||||
|
const decodedParams = decodeParams(await params);
|
||||||
|
|
||||||
if ((props as TeamPageProps)?.team) {
|
if ((props as TeamPageProps)?.team) {
|
||||||
const { team, markdownStrippedBio, isSEOIndexable, currentOrgDomain } = props as TeamPageProps;
|
const { team, markdownStrippedBio, isSEOIndexable, currentOrgDomain } = props as TeamPageProps;
|
||||||
@@ -30,7 +31,6 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
|||||||
image: getOrgOrTeamAvatar(team),
|
image: getOrgOrTeamAvatar(team),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const decodedParams = decodeParams(await params);
|
|
||||||
return {
|
return {
|
||||||
...(await generateMeetingMetadata(
|
...(await generateMeetingMetadata(
|
||||||
meeting,
|
meeting,
|
||||||
@@ -67,7 +67,8 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
|||||||
() => profile.name,
|
() => profile.name,
|
||||||
() => markdownStrippedBio,
|
() => markdownStrippedBio,
|
||||||
false,
|
false,
|
||||||
getOrgFullOrigin(entity.orgSlug ?? null)
|
getOrgFullOrigin(entity.orgSlug ?? null),
|
||||||
|
`/${decodedParams.user}`
|
||||||
)),
|
)),
|
||||||
robots: {
|
robots: {
|
||||||
index: allowSEOIndexing,
|
index: allowSEOIndexing,
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { ShellMainAppDir } from "../ShellMainAppDir";
|
|||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
return await _generateMetadata(
|
return await _generateMetadata(
|
||||||
(t) => t("availability"),
|
(t) => t("availability"),
|
||||||
(t) => t("configure_availability")
|
(t) => t("configure_availability"),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"/availability"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ const querySchema = z.object({
|
|||||||
status: z.enum(validStatuses),
|
status: z.enum(validStatuses),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async ({ params }: { params: Promise<{ status: string }> }) =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
(t) => t("bookings"),
|
(t) => t("bookings"),
|
||||||
(t) => t("bookings_description")
|
(t) => t("bookings_description"),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`/bookings/${(await params).status}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const Page = async ({ params }: PageProps) => {
|
const Page = async ({ params }: PageProps) => {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import EventTypes, { EventTypesCTA } from "~/event-types/views/event-types-listi
|
|||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
(t) => t("event_types_page_title"),
|
(t) => t("event_types_page_title"),
|
||||||
(t) => t("event_types_page_subtitle")
|
(t) => t("event_types_page_subtitle"),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"/event-types"
|
||||||
);
|
);
|
||||||
|
|
||||||
const Page = async ({ params, searchParams }: PageProps) => {
|
const Page = async ({ params, searchParams }: PageProps) => {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ const Layout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
if (session?.user?.id) SentrySetUser({ id: session.user.id });
|
if (session?.user?.id) SentrySetUser({ id: session.user.id });
|
||||||
|
|
||||||
return <Shell withoutMain={true}>{children}</Shell>;
|
return (
|
||||||
|
<>
|
||||||
|
<Shell withoutMain={true}>{children}</Shell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Layout;
|
export default Layout;
|
||||||
@@ -13,7 +13,10 @@ import TeamsView, { TeamsCTA } from "~/teams/teams-view";
|
|||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
(t) => t("teams"),
|
(t) => t("teams"),
|
||||||
(t) => t("create_manage_teams_collaborative")
|
(t) => t("create_manage_teams_collaborative"),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"/teams"
|
||||||
);
|
);
|
||||||
|
|
||||||
const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
|
const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { Button } from "@calcom/ui/components/button";
|
|||||||
export const generateMetadata = () =>
|
export const generateMetadata = () =>
|
||||||
_generateMetadata(
|
_generateMetadata(
|
||||||
(t) => `${t("access_denied")} | ${APP_NAME}`,
|
(t) => `${t("access_denied")} | ${APP_NAME}`,
|
||||||
() => ""
|
() => "",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"/403"
|
||||||
);
|
);
|
||||||
|
|
||||||
async function Error403() {
|
async function Error403() {
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import CopyButton from "./copy-button";
|
|||||||
export const generateMetadata = () =>
|
export const generateMetadata = () =>
|
||||||
_generateMetadata(
|
_generateMetadata(
|
||||||
(t) => `${t("something_unexpected_occurred")} | ${APP_NAME}`,
|
(t) => `${t("something_unexpected_occurred")} | ${APP_NAME}`,
|
||||||
() => ""
|
() => "",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"/500"
|
||||||
);
|
);
|
||||||
|
|
||||||
async function Error500({ searchParams }: { searchParams: { error?: string } }) {
|
async function Error500({ searchParams }: { searchParams: { error?: string } }) {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export const generateMetadata = async ({ params }: _PageProps) => {
|
|||||||
return await generateAppMetadata(
|
return await generateAppMetadata(
|
||||||
{ slug: logo, name, description },
|
{ slug: logo, name, description },
|
||||||
() => name,
|
() => name,
|
||||||
() => description
|
() => description,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`/apps/${p.data.slug}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export const generateMetadata = async ({ params: _params }: ServerPageProps) =>
|
|||||||
const params = await _params;
|
const params = await _params;
|
||||||
const metadata = await _generateMetadata(
|
const metadata = await _generateMetadata(
|
||||||
() => `${params.slug}`,
|
() => `${params.slug}`,
|
||||||
() => ""
|
() => "",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`/apps/${params.slug}/setup`
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...metadata,
|
...metadata,
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { _generateMetadata } from "app/_utils";
|
|||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async () => {
|
||||||
return await _generateMetadata(
|
return await _generateMetadata(
|
||||||
(t) => t("app_store"),
|
(t) => t("app_store"),
|
||||||
(t) => t("app_store_description")
|
(t) => t("app_store_description"),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"/apps/categories"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return children;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
@@ -13,9 +13,14 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
|||||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||||
|
|
||||||
const { appMetadata } = await getData(legacyCtx);
|
const { appMetadata } = await getData(legacyCtx);
|
||||||
|
const stepParam = (await params).step;
|
||||||
|
const step = stepParam && Array.isArray(stepParam) ? stepParam.join("/") : "";
|
||||||
return await _generateMetadata(
|
return await _generateMetadata(
|
||||||
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
|
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
|
||||||
() => ""
|
() => "",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`/apps/installation${step ? `/${step}` : ""}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ const querySchema = z.object({
|
|||||||
category: z.nativeEnum(AppCategories),
|
category: z.nativeEnum(AppCategories),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const generateMetadata = async () => {
|
export const generateMetadata = async ({ params }: { params: Promise<{ category: string }> }) => {
|
||||||
return await _generateMetadata(
|
return await _generateMetadata(
|
||||||
(t) => t("installed_apps"),
|
(t) => t("installed_apps"),
|
||||||
(t) => t("manage_your_connected_apps")
|
(t) => t("manage_your_connected_apps"),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`/apps/installed/${(await params).category}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user