Compare commits

...
36 Commits
Author SHA1 Message Date
Omar López 9dab915ddd chore: upgrade dependencies (#20740)
Post release / release (push) Canceled after 0s
2025-04-21 15:21:10 -03:00
GitHub Actions 8fca8f3910 chore: release v5.2.3 2025-04-21 18:20:38 +00:00
Omar López 1638465607 chore: remove swagger app and its dependencies (#20798) 2025-04-21 18:13:19 +00:00
Keith Williams ab7eb7f4b1 chore: Add layout.tsx to Foundation team CODEOWNERS (#20802) 2025-04-21 23:35:10 +05:30
Benny Joo 0592badd56 fix: semi-colon in the bottom of layout UI (#20799) 2025-04-21 15:46:23 +00:00
Morgan 7e82b8fa86 fix: sms workflow api v2 (#20795)
* fix: sms workflow api v2

* fixup! fix: sms workflow api v2
2025-04-21 14:41:59 +00:00
7dfb71ca30 fix: Location not changing when rescheduling (#20404)
* fix: location not chnging

* update

* update

* Update EventManager.ts

* update

* update

* Update reschedule.test.ts

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-04-21 14:45:59 +01:00
d99ecde0e2 fix: improved 'use-identity-login' error message for users trying to signInn using different provider (#20582)
* improved 'use-identity-login' error message for users trying to signin using different provider

* passing original identity provider as query parameter

* Update packages/features/auth/lib/next-auth-options.ts

* passed different error names for different types of error

---------

Co-authored-by: romit <romitgabani1@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-21 13:38:19 +00:00
Omar López d81b905ad7 refactor: replace timeZone string validation with timeZoneSchema (#20719)
* refactor: replace timeZone string validation with timeZoneSchema - Replace all instances of timeZone: z.string() with timeZoneSchema - Preserve modifiers (.optional(), .default(), .nullish()) where present - Fix VideoApiAdapter to use organizer's timezone value - Remove redundant isSupportedTimeZone import from booking.ts - Add timeZoneSchema import where needed

* Update index.ts

* fix: conflicts

Signed-off-by: Omar López <zomars@me.com>

* Update yarn.lock

* Discard changes to yarn.lock

* Update booking.ts

* Discard changes to packages/platform/types/me.ts

* Discard changes to packages/platform/types/me/outputs/me.ts

* Discard changes to packages/platform/types/schedules/schedules-2024-04-15/schedules.ts

* Discard changes to packages/features/ee/cal-ai-phone/zod-utils.ts

---------

Signed-off-by: Omar López <zomars@me.com>
2025-04-21 14:37:07 +01:00
Hariom Balhara 46c6582328 fix: Wrong text EmbedTheme -> Theme (#20790) 2025-04-21 10:16:09 +00:00
Hariom Balhara 396811b954 fix: Org onboarding handle error due to platform team being moved to regular org (#20786)
* Add better error logs and handle platform team

* Add better error logs and handle platform team
2025-04-21 14:58:55 +05:30
Lauris Skraucis f6c7d8d97f fix: v2 Sentry errors (#20788)
* fix: Only Team Event Types are supported for Instant Meeting

* refactor: Cannot read properties of undefined (reading 'timeZone')

* fix: Cannot read properties of undefined (reading 'map')

* refactor: rearrange function order

* refactor: booking_seats_full_error

* fix: invalid_round_robin_host

* refactor: booking_time_out_of_bounds_error

* refactor: Attempting to book a meeting in the past.

* fix: obj.phoneNumber.trim is not a function

* fix: Cannot use 'in' operator to search for 'disabled' in 1
2025-04-21 12:12:46 +03:00
Ronit Raj 930165b6e8 Update rateLimit.ts (#20785) 2025-04-21 05:33:00 +00:00
Hariom Balhara a77bd7bbe0 fx 404 for embed (#20120) 2025-04-20 16:16:48 +00:00
51212cdacf fix: header overlaps in month view on short viewport (#20749)
* fix: header overlaps in month view on short viewport

* refactor: Merge classes into same condition

* fix: Remove booker margin top if isEmbed

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
2025-04-20 08:03:12 +05:30
Lauris Skraucis 3731b1ef3d fix: calendar connect atoms (#20766) 2025-04-18 11:29:11 +00:00
amrit f631c4aa79 feat: make skeleton for booking embed accurate (#20765)
* fix: trigger lingo.dev by removing duplicate value

* feat: make skeleton for booking embed accurate
2025-04-18 10:28:37 +00:00
Lauris Skraucis 95cd76e732 fix: only managed users assignable to managed team event (#20754)
* fix: only managed users assignable to managed team event

* chore: bump platform libraries
2025-04-18 12:21:02 +05:30
Joe Au-Yeung 7e525779b7 Remove trailing } from utm param (#20763) 2025-04-18 11:13:26 +05:30
Anik Dhabal Babu f176ebf1da fix: offer seats broken (#20758) 2025-04-18 05:32:31 +00:00
Keith Williams 5058e0e750 chore: Remove verbose logging (#20732) 2025-04-18 04:20:19 +00:00
Joe Au-Yeung dc409e1c5a chore: Add consent message when SMS workflows are related (#20741)
* Add consent message

* Add consent message if using SMS workflow

* Skip flakey tests

* Revert "Skip flakey tests"

This reverts commit 3bd6f3fd71469df4dbd67b69b8e17011657df9d8.
2025-04-18 09:23:15 +05:30
Joe Au-Yeung 89b5d1ebc3 Write UTM params to Salesforce (#20759) 2025-04-18 03:24:57 +00:00
Joe Au-Yeung 0d24664a72 Skip flakey tests (#20761) 2025-04-18 03:01:35 +00:00
69efc26f36 perf: slim down middleware matcher (#20735)
* wip

* wip

* refactor

* add pathname

* fix

* fix: manually construct pathname for metadata functions

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add pathname parameter to all metadata functions

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add pathname parameter to remaining metadata functions

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix

* fix

* fix type checks

* Refactor

* fix

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
2025-04-17 19:55:01 -07:00
GitHub Actions 95c48ac805 chore: release v5.2.2
Post release / release (push) Canceled after 0s
2025-04-17 20:49:03 +00:00
Somay Chauhan 59ab38db98 feat: add guards to stripe teams controller (#20540)
* feat: add guards to stripe teams controller

* remove logs and comments

* fix return type status

* refactor: move PlatformSubscription to a dedicated module

* reroute to `organizations/stripe/save` for teams

* fix: type errors

* feat: fixed it for conferencing apps

* feat: Add error handling and fallback URL support in Stripe callback

* Refactor OAuth callback handling and move token validation to service layer

* Add documentation for OAuth callback proxying in conferencing and stripe controllers

* Move OAuthCallbackState type from organizations to stripe service module
2025-04-17 16:36:31 +00:00
Benny Joo 11a41e0e39 fix: return react fragment from all layout.tsx 2025-04-17 16:26:15 +00:00
Lauris Skraucis 880fe151c0 fix: v2 automatic round robin reassign error message (#20755) 2025-04-17 15:50:16 +00:00
Anik Dhabal Babu 603f106389 fix: Admin/owner unable to confirm managed bookings (#20743)
* fix: Admin/owner can't confirm managed booking

* Update confirm.handler.ts
2025-04-17 12:27:11 +00:00
Morgan 5c7f1aebb2 feat: filter users by assigned attribute options (#20659)
* feat: assigned attributes options base

* feat: assigned attributes options base e2e

* feat: filter assigned options by other assigned options

* feat: filter assigned options by other assigned options

* fixup! feat: filter assigned options by other assigned options

* feat: get org users by assigned attributes

* adding missing input file

* push generated docs

* feat: add filter org users by teamIds

* feat: add filter assigned attribute options by teamIds

* chore; update doc

* chore: remove console log

* chore: minor e2e fix

* chore: minor e2e fix

* chore: improve docs

* fix: assigned options by teamIds + optionIds
2025-04-17 14:05:06 +02:00
Anik Dhabal Babu a8fe3a6466 fix: failing e2e test (#20751) 2025-04-17 17:33:29 +05:30
Lauris Skraucis 8974f8f9de feat: v2 book team events using teamSlug & eventTypeSlug (#20585)
* feat: v2 book team events using teamSlug & eventTypeSlug

* fix: tests
2025-04-17 13:23:25 +03:00
Anik Dhabal Babu a11c2cc52c fix: Wrong schedule ID set for child managed event (#20745)
* fix: wrong schule id set for managed event

* Update handleChildrenEventTypes.ts
2025-04-17 08:58:01 +00:00
amrit 14d319f992 fix: buggy skeleton rendering for month view (#20744)
* fix: trigger lingo.dev by removing duplicate value

* fix: buggy skeleton monthly view
2025-04-17 11:10:47 +05:30
Benny Joo 49f0759f31 fix: disable Google Translate from HTML 2025-04-17 00:10:25 +00:00
238 changed files with 4858 additions and 3434 deletions
+1
View File
@@ -1,5 +1,6 @@
/**/package.json @calcom/Foundation
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Foundation
/deploy/**/* @calcom/Foundation
/infra/**/* @calcom/Foundation
/packages/app-store/applecalendar/**/* @calcom/Foundation
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@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-types": "*",
"@calcom/platform-utils": "*",
@@ -46,6 +46,7 @@
"@golevelup/ts-jest": "^0.4.0",
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nest-lab/throttler-storage-redis": "1.0.0",
"@nestjs/axios": "^4.0.0",
"@nestjs/bull": "^10.1.1",
"@nestjs/common": "^10.0.0",
"@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 { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.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 { RedisModule } from "@/modules/redis/redis.module";
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 { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
@@ -38,6 +43,9 @@ import { Module } from "@nestjs/common";
SchedulesModule_2024_04_15,
EventTypesModule_2024_04_15,
EventTypesModule_2024_06_14,
StripeModule,
TeamsModule,
TeamsEventTypesModule,
],
providers: [
TokensRepository,
@@ -57,6 +65,8 @@ import { Module } from "@nestjs/common";
AppsRepository,
CalendarsRepository,
SelectedCalendarsRepository,
OrganizationsTeamsRepository,
OrganizationsRepository,
],
controllers: [BookingsController_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.
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.
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({
@@ -58,14 +58,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
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()}`;
@@ -126,22 +123,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
);
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();
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 () => {
return request(app.getHttpServer())
.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 () => {
return request(app.getHttpServer())
.get("/v2/bookings?sortCreated=asc")
@@ -534,7 +338,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(3);
expect(responseBody.data.length).toEqual(1);
const seatedBooking = responseBody.data[0];
const seatedBookingExpected = structuredClone(createdSeatedBooking);
@@ -543,16 +347,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
// @ts-ignore
delete seatedBookingExpected.seatUid;
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();
});
});
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("Team bookings", () => {
let app: INestApplication;
const organizationSlug = `team-bookings-organization-${randomString()}`;
let organization: Team;
const team1Slug = `team-bookings-team1-${randomString()}`;
const team2Slug = `team-bookings-team2-${randomString()}`;
let team1: 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);
organization = await organizationsRepositoryFixture.create({
name: `team-bookings-organization-${randomString()}`,
name: organizationSlug,
slug: organizationSlug,
isOrganization: true,
});
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: `team-bookings-team1-${randomString()}`,
name: team1Slug,
slug: team1Slug,
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
@@ -111,7 +117,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
team2 = await teamRepositoryFixture.create({
name: `team-bookings-team2-${randomString()}`,
name: team2Slug,
slug: team2Slug,
isOrganization: false,
parent: { connect: { id: organization.id } },
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) {
const data = {
logo: "logo-url",
@@ -34,6 +34,7 @@ import {
} from "@calcom/platform-constants";
import {
CreateEventTypeInput_2024_06_14,
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
GetBookingOutput_2024_08_13,
GetBookingsOutput_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)
.expect(400);
expect(
response.body.error.message.includes(
"Either eventTypeId OR (eventTypeSlug + username) must be provided"
)
).toBe(true);
expect(response.body.error.message.includes(FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE)).toBe(
true
);
});
it("should create a booking by username and event type slug", async () => {
@@ -8,10 +8,14 @@ import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
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 { 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 { 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 { Request } from "express";
import { z } from "zod";
@@ -77,7 +81,11 @@ export class BookingsService_2024_08_13 {
private readonly usersService: UsersService,
private readonly usersRepository: UsersRepository,
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) {
@@ -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.`
);
}
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.`);
}
@@ -123,6 +141,12 @@ export class BookingsService_2024_08_13 {
if (error instanceof Error) {
if (error.message === "no_available_users_found_error") {
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;
@@ -141,10 +165,34 @@ export class BookingsService_2024_08_13 {
user.id,
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;
}
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) {
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
if (!eventType?.bookingFields) {
@@ -181,6 +229,12 @@ export class BookingsService_2024_08_13 {
body: CreateInstantBookingInput_2024_08_13,
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 booking = await handleInstantMeeting(bookingRequest);
@@ -271,29 +325,37 @@ export class BookingsService_2024_08_13 {
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleNewBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
});
try {
const booking = await handleNewBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
});
if (!booking.uid) {
throw new Error("Booking missing uid");
if (!booking.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) {
@@ -604,13 +666,24 @@ export class BookingsService_2024_08_13 {
const profile = this.usersService.getUserMainProfile(requestUser);
await roundRobinReassignment({
bookingId: booking.id,
orgId: profile?.organizationId || null,
emailsEnabled,
platformClientParams,
reassignedById: requestUser.id,
});
try {
await roundRobinReassignment({
bookingId: booking.id,
orgId: profile?.organizationId || null,
emailsEnabled,
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);
if (!reassigned) {
@@ -644,17 +717,28 @@ export class BookingsService_2024_08_13 {
const profile = this.usersService.getUserMainProfile(user);
const reassigned = await roundRobinManualReassignment({
bookingId: booking.id,
newUserId,
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
emailsEnabled,
platformClientParams,
});
try {
const reassigned = await roundRobinManualReassignment({
bookingId: booking.id,
newUserId,
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
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) {
@@ -150,7 +150,7 @@ export class CalendarsController {
@Headers("Authorization") authorization: string,
@Param("calendar") calendar: string,
@Query("redir") redir?: string | null,
@Query("isDryRun", ParseBoolPipe) isDryRun?: boolean
@Query("isDryRun", new ParseBoolPipe({ optional: true })) isDryRun?: boolean
): Promise<ApiResponse<{ authUrl: string }>> {
switch (calendar) {
case OFFICE_365_CALENDAR:
+4
View File
@@ -38,6 +38,10 @@ export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environme
const value = process.env[key] as Environment[K] | 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) {
return fallback;
}
@@ -81,6 +81,10 @@ export class EventTypesAtomService {
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;
}
+3 -29
View File
@@ -1,22 +1,16 @@
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
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 { 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 { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
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 { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { forwardRef, Module } from "@nestjs/common";
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
@Module({
@@ -29,28 +23,8 @@ import { PassportModule } from "@nestjs/passport";
TokensModule,
DeploymentsModule,
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 {}
@@ -6,15 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
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 { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensRepository } from "@/modules/tokens/tokens.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";
@Module({
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
imports: [PrismaModule, ConfigModule, HttpModule],
providers: [
ConferencingService,
ConferencingRepository,
@@ -15,9 +15,8 @@ import {
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
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 { HttpService } from "@nestjs/axios";
import { Logger } from "@nestjs/common";
import {
Controller,
@@ -32,10 +31,10 @@ import {
Delete,
Headers,
Redirect,
UnauthorizedException,
Req,
HttpException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { Request } from "express";
@@ -60,9 +59,9 @@ export class ConferencingController {
private readonly logger = new Logger("ConferencingController");
constructor(
private readonly tokensRepository: TokensRepository,
private readonly conferencingService: ConferencingService,
private readonly organizationsConferencingService: OrganizationsConferencingService
private readonly config: ConfigService,
private readonly httpService: HttpService
) {}
@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")
@UseGuards()
@Redirect(undefined, 301)
@@ -143,23 +150,28 @@ export class ConferencingController {
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
try {
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
if (error) {
throw new BadRequestException(error_description);
}
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
if (decodedCallbackState.orgId) {
return this.organizationsConferencingService.connectTeamOauthApps({
app,
code,
userId,
decodedCallbackState,
});
} else {
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
if (decodedCallbackState.teamId && decodedCallbackState.orgId) {
const apiUrl = this.config.get("api.url");
const url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/conferencing/${app}/oauth/callback`;
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 };
}
}
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState);
} catch (error) {
if (error instanceof HttpException || error instanceof Error) {
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 { Office365VideoService } from "@/modules/conferencing/services/office365-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 { 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 {
@@ -25,6 +31,7 @@ export class ConferencingService {
constructor(
private readonly conferencingRepository: ConferencingRepository,
private readonly usersRepository: UsersRepository,
private readonly tokensRepository: TokensRepository,
private readonly googleMeetService: GoogleMeetService,
private readonly zoomVideoService: ZoomVideoService,
private readonly office365VideoService: Office365VideoService
@@ -47,10 +54,13 @@ export class ConferencingService {
async connectOauthApps(
app: string,
code: string,
userId: number,
decodedCallbackState: OAuthCallbackState,
teamId?: number
) {
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
switch (app) {
case ZOOM:
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
@@ -16,8 +16,6 @@ export class DestinationCalendarsService {
userId: number,
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 allCalendars: Calendar[] = userCalendars.connectedCalendars
.map((cal: ConnectedCalendar) => cal.calendars ?? [])
@@ -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[];
}
@@ -7,6 +7,8 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
import { GetOrganizationAttributeAssignedOptionsProp } from "./services/organization-attributes-option.service";
@Injectable()
export class OrganizationAttributeOptionRepository {
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
@@ -90,6 +92,82 @@ export class OrganizationAttributeOptionRepository {
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({
organizationId,
membershipId,
@@ -3,6 +3,8 @@ import { AppModule } from "@/app.module";
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 { 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 { TokensModule } from "@/modules/tokens/tokens.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 { MembershipRepositoryFixture } from "test/fixtures/repository/membership.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 { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
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("User lacks required role", () => {
@@ -95,19 +98,37 @@ describe("Organizations Attributes Options Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixtures: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let attributeRepositoryFixture: AttributeRepositoryFixture;
const userEmail = `organization-attributes-options-admin-${randomString()}@api.com`;
const userEmail2 = `organization-attributes-options-member-${randomString()}@api.com`;
let user: User;
let user2: User;
let org: Team;
let team: Team;
let membership: Membership;
let membership2: Membership;
let attributeId: string;
let attributeSlug: string;
let createdOption: any;
let createdOption2: any;
let attribute2: Attribute;
let attribute2Option: AttributeOption;
const createOptionInput: CreateOrganizationAttributeOptionInput = {
value: "option1",
slug: "option1",
slug: `option1-${randomString()}`,
};
const createOption2Input: CreateOrganizationAttributeOptionInput = {
value: "option2",
slug: `option2${randomString()}`,
};
beforeAll(async () => {
@@ -120,6 +141,7 @@ describe("Organizations Attributes Options Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
@@ -128,23 +150,50 @@ describe("Organizations Attributes Options Endpoints", () => {
isOrganization: true,
});
team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } });
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
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);
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
const attribute = await attributeRepositoryFixture.create({
name: "Test Attribute",
team: { connect: { id: org.id } },
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;
attributeSlug = attribute.slug;
attribute2Option = await attributeRepositoryFixture.createOption({
slug: `optionA-${randomString()}`,
value: "optionA",
attribute: { connect: { id: attribute2.id } },
});
app = moduleRef.createNestApplication();
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 () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const options = response.body.data;
expect(options.length).toEqual(1);
expect(options[0].value).toEqual(createOptionInput.value);
expect(options[0].slug).toEqual(createOptionInput.slug);
const options = response.body.data as OptionOutput[];
expect(options.length).toEqual(2);
expect(options.find((opt) => opt.value === createOptionInput.value)).toBeDefined();
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 () => {
return request(app.getHttpServer())
.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 () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
@@ -243,7 +466,9 @@ describe("Organizations Attributes Options Endpoints", () => {
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await membershipFixtures.delete(membership2.id);
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
@@ -8,19 +8,32 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
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 { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
import {
AssignOptionUserOutput,
UnassignOptionUserOutput,
} 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 { DeleteAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/delete-option.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 { UpdateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/update-option.output";
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 { 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")
@PlatformPlan("ESSENTIALS")
@Post("/attributes/options/:userId")
@@ -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 { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
import { Expose } from "class-transformer";
import { IsString } from "class-validator";
export class OptionOutput {
@Expose()
@IsString()
@ApiProperty({
type: String,
@@ -12,10 +13,12 @@ export class OptionOutput {
})
id!: string;
@Expose()
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
attributeId!: string;
@Expose()
@IsString()
@ApiProperty({
type: String,
@@ -25,6 +28,7 @@ export class OptionOutput {
})
value!: string;
@Expose()
@IsString()
@ApiProperty({
type: String,
@@ -3,8 +3,10 @@ import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/
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 { 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 { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { plainToClass } from "class-transformer";
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
@@ -111,4 +113,45 @@ export class OrganizationAttributeOptionService {
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[] };
};
@@ -22,6 +22,7 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
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 {
Controller,
@@ -36,10 +37,13 @@ import {
Headers,
Req,
ParseIntPipe,
BadRequestException,
Redirect,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { Request } from "express";
import { stringify } from "querystring";
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 {
constructor(
private readonly conferencingService: ConferencingService,
private readonly organizationsConferencingService: OrganizationsConferencingService
private readonly organizationsConferencingService: OrganizationsConferencingService,
private readonly tokensRepository: TokensRepository
) {}
@Roles("TEAM_ADMIN")
@@ -221,4 +226,41 @@ export class OrganizationsConferencingController {
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 ?? "",
};
}
}
}
@@ -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 {}
@@ -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 { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
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 { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
BadRequestException,
ForbiddenException,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { GOOGLE_MEET } from "@calcom/platform-constants";
@@ -35,129 +23,9 @@ export class OrganizationsConferencingService {
private teamsRepository: TeamsRepository,
private usersRepository: UsersRepository,
private readonly googleMeetService: GoogleMeetService,
private readonly conferencingService: ConferencingService,
private readonly platformPlanGuard: PlatformPlanGuard,
private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard,
private readonly isOrgGuard: IsOrgGuard,
private readonly isTeamInOrg: IsTeamInOrg,
private readonly rolesGuard: RolesGuard
private readonly conferencingService: ConferencingService
) {}
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> {
switch (app) {
case GOOGLE_MEET:
@@ -171,40 +39,14 @@ export class OrganizationsConferencingService {
decodedCallbackState,
app,
code,
userId,
teamId,
}: {
userId: number;
app: string;
decodedCallbackState: OAuthCallbackState;
code: string;
teamId: number;
}) {
const user = await this.usersRepository.findByIdWithProfile(userId);
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);
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState, teamId);
}
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 { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { AuthModule } from "@/modules/auth/auth.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { AppsRepository } from "@/modules/apps/apps.repository";
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 { EmailService } from "@/modules/email/email.service";
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 { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
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 { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
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 { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
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 { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
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 { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
import { forwardRef, Module } from "@nestjs/common";
import { Module } from "@nestjs/common";
@Module({
imports: [
@@ -69,11 +79,11 @@ import { forwardRef, Module } from "@nestjs/common";
EventTypesModule_2024_06_14,
TeamsEventTypesModule,
TeamsModule,
forwardRef(() => ConferencingModule),
forwardRef(() => AuthModule),
OrganizationsDelegationCredentialModule,
OrganizationsOrganizationsModule,
OrganizationsStripeModule,
OrganizationsTeamsRoutingFormsModule,
OrganizationsConferencingModule,
],
providers: [
OrganizationsRepository,
@@ -108,6 +118,16 @@ import { forwardRef, Module } from "@nestjs/common";
OrgUsersOOOService,
OrgUsersOOORepository,
OrganizationsConferencingService,
OrganizationsStripeService,
CredentialsRepository,
AppsRepository,
RedisService,
ConferencingRepository,
GoogleMeetService,
ConferencingService,
ZoomVideoService,
Office365VideoService,
TokensRepository,
],
exports: [
OrganizationsService,
@@ -129,6 +149,7 @@ import { forwardRef, Module } from "@nestjs/common";
WebhooksService,
OrganizationsEventTypesService,
OrganizationsConferencingService,
OrganizationsStripeService,
],
controllers: [
OrganizationsTeamsController,
@@ -142,7 +163,6 @@ import { forwardRef, Module } from "@nestjs/common";
OrganizationsWebhooksController,
OrganizationsTeamsSchedulesController,
OrganizationsUsersOOOController,
OrganizationsConferencingController,
],
})
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);
}
}
@@ -66,9 +66,11 @@ export class OrganizationsUsersController {
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrganizationsUsersInput
): Promise<GetOrganizationUsersResponseDTO> {
const { emails, assignedOptionIds, attributeQueryOperator, teamIds } = query ?? {};
const users = await this.organizationsUsersService.getUsers(
orgId,
query.emails,
emails,
{ assignedOptionIds, attributeQueryOperator, teamIds },
query.skip ?? 0,
query.take ?? 250
);
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -10,6 +11,7 @@ import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
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 { MembershipRepositoryFixture } from "test/fixtures/repository/membership.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 { 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", () => {
const bio = "I am a bio";
@@ -114,8 +116,6 @@ describe("Organizations Users Endpoints", () => {
await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
await app.close();
});
});
describe("Admin role", () => {
@@ -249,11 +249,6 @@ describe("Organizations Users Endpoints", () => {
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(4);
console.log(
"profiles",
{ userData },
userData.map((u) => u.profile)
);
// Find and verify each member's data
const member0 = userData.find((u) => u.profile.username === orgMembersData[0].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 organizationsRepositoryFixture.delete(org.id);
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();
});
});
@@ -1,3 +1,52 @@
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[];
}
@@ -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({
where: {
...this.filterOnOrgMembership(orgId),
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
...(teamIds && { teams: { some: { teamId: { in: teamIds } } } }),
},
include: {
profiles: {
@@ -16,12 +16,37 @@ export class OrganizationsUsersService {
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;
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(
orgId,
emailArray,
filters?.teamIds,
skip,
take
);
@@ -11,7 +11,6 @@ export class OrgUsersOOORepository {
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" },
filters?: { email?: string }
) {
console.log({ sort, filters });
return this.dbRead.prisma.outOfOfficeEntry.findMany({
where: {
user: {
@@ -8,9 +8,11 @@ import {
StripCredentialsCheckOutputResponseDto,
StripCredentialsSaveOutputResponseDto,
} 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 { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { HttpService } from "@nestjs/axios";
import {
Controller,
Query,
@@ -22,8 +24,8 @@ import {
Req,
BadRequestException,
Headers,
Param,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiTags as DocsTags, ApiOperation, ApiHeader } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { Request } from "express";
@@ -37,7 +39,12 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
})
@DocsTags("Stripe")
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")
@UseGuards(ApiAuthGuard)
@@ -48,26 +55,20 @@ export class StripeController {
@Req() req: Request,
@Headers("Authorization") authorization: string,
@GetUser() user: UserWithProfile,
@Query("redir") redir?: string | null,
@Query("errorRedir") errorRedir?: string | null,
@Query("teamId") teamId?: string | null
@Query("returnTo") returnTo?: string | null,
@Query("onErrorReturnTo") onErrorReturnTo?: string | null
): Promise<StripConnectOutputResponseDto> {
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
const state = {
onErrorReturnTo: !!errorRedir ? errorRedir : origin,
const state: OAuthCallbackState = {
onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin,
fromApp: false,
returnTo: !!redir ? redir : origin,
returnTo: !!returnTo ? returnTo : origin,
accessToken,
teamId: Number(teamId) ?? null,
};
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
JSON.stringify(state),
user.email,
user.name
);
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(state, user.email, user.name);
return {
status: SUCCESS_STATUS,
@@ -79,24 +80,71 @@ export class StripeController {
@UseGuards()
@Redirect(undefined, 301)
@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(
@Query("state") state: string,
@Query("code") code: string,
@Query("error") error: string | undefined,
@Query("error_description") error_description: string | undefined
): Promise<StripCredentialsSaveOutputResponseDto> {
const accessToken = JSON.parse(state).accessToken;
// user cancels flow
if (error === "access_denied") {
return { url: getOnErrorReturnToValueFromQueryState(state) };
if (!state) {
throw new BadRequestException("Missing `state` query param");
}
if (error) {
throw new BadRequestException(stringify({ error, error_description }));
}
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
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")
@@ -107,15 +155,4 @@ export class StripeController {
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
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 {
@ApiProperty({ example: SUCCESS_STATUS })
status!: typeof SUCCESS_STATUS;
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
export class StripCredentialsSaveOutputResponseDto {
@@ -6,11 +6,12 @@ import { StripeController } from "@/modules/stripe/controllers/stripe.controller
import { StripeService } from "@/modules/stripe/stripe.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule, PrismaModule, UsersModule],
imports: [ConfigModule, PrismaModule, UsersModule, HttpModule],
exports: [StripeService],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
controllers: [StripeController],
@@ -2,10 +2,8 @@ import { AppConfig } from "@/config/type";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Injectable,
@@ -25,12 +23,13 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
import stringify = require("qs-stringify");
type IntegrationOAuthCallbackState = {
export type OAuthCallbackState = {
accessToken: string;
returnTo: string;
onErrorReturnTo: string;
fromApp: boolean;
teamId?: number | null;
teamId?: string;
orgId?: string;
fromApp?: boolean;
returnTo?: string;
onErrorReturnTo?: string;
};
@Injectable()
@@ -45,8 +44,7 @@ export class StripeService {
configService: ConfigService<AppConfig>,
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly membershipRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository
) {
@@ -59,7 +57,7 @@ export class StripeService {
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 stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = {
@@ -73,7 +71,7 @@ export class StripeService {
country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined,
},
redirect_uri: this.redirectUri,
state: state,
state: JSON.stringify(state),
};
const params = z.record(z.any()).parse(stripeConnectParams);
@@ -99,10 +97,7 @@ export class StripeService {
return { client_id, client_secret };
}
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
async saveStripeAccount(state: OAuthCallbackState, code: string, userId: number): Promise<{ url: string }> {
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
@@ -118,17 +113,14 @@ export class StripeService {
data["default_currency"] = account.default_currency;
}
if (oAuthCallbackState.teamId) {
await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
const existingCredentials = await this.credentialsRepository.findAllCredentialsByTypeAndUserId(
"stripe_payment",
userId
);
await this.appsRepository.createTeamAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
oAuthCallbackState.teamId,
"stripe"
);
return { url: getReturnToValueFromQueryState(state) };
const credentialIdsToDelete = existingCredentials.map((item) => item.id);
if (credentialIdsToDelete.length > 0) {
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
}
await this.appsRepository.createAppCredential(
@@ -138,11 +130,11 @@ export class StripeService {
"stripe"
);
return { url: getReturnToValueFromQueryState(state) };
return { url: state.returnTo ?? "" };
}
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
const stripeCredentials = await this.credentialsRepository.findCredentialByTypeAndUserId(
"stripe_payment",
userId
);
@@ -150,24 +142,6 @@ export class StripeService {
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(
credentials?: Credential | null
): 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 { AuthModule } from "@/modules/auth/auth.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.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 { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.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 { TeamsModule } from "@/modules/teams/teams/teams.module";
import { UsersModule } from "@/modules/users/users.module";
import { forwardRef, Module } from "@nestjs/common";
import { Module } from "@nestjs/common";
@Module({
imports: [
@@ -24,8 +22,7 @@ import { forwardRef, Module } from "@nestjs/common";
EventTypesModule_2024_06_14,
UsersModule,
TeamsModule,
forwardRef(() => ConferencingModule),
forwardRef(() => AuthModule),
OrganizationsConferencingModule,
],
providers: [
TeamsEventTypesRepository,
@@ -34,7 +31,6 @@ import { forwardRef, Module } from "@nestjs/common";
OrganizationsTeamsRepository,
OutputTeamEventTypesResponsePipe,
OutputOrganizationsEventTypesService,
OrganizationsConferencingService,
],
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
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) {
return this.dbRead.prisma.eventType.findMany({
where: {
+567 -102
View File
@@ -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}": {
"post": {
"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": {
"post": {
"operationId": "OrganizationsEventTypesController_createTeamEventType",
@@ -4777,6 +5017,48 @@
"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": {
@@ -6353,7 +6635,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_createBooking",
"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": [
{
"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": {
"post": {
"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": {
"post": {
"operationId": "TeamsController_createTeam",
@@ -12954,6 +13337,7 @@
},
"enabledLayouts": {
"type": "array",
"description": "Array of valid layouts - month, week or column",
"items": {
"type": "string",
"enum": [
@@ -18416,6 +18800,72 @@
"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": {
"type": "object",
"properties": {
@@ -18864,7 +19314,7 @@
"type": "object",
"properties": {
"status": {
"type": "string",
"type": "object",
"example": "success"
}
},
@@ -20654,10 +21104,20 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
"attendee": {
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
}
]
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values for custom booking fields added by you.",
"example": {
"customField": "customValue"
}
},
"eventTypeId": {
"type": "number",
@@ -20666,7 +21126,7 @@
},
"eventTypeSlug": {
"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"
},
"username": {
@@ -20674,19 +21134,16 @@
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
"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": {
"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"
},
"attendee": {
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
}
]
},
"guests": {
"description": "An optional list of guest emails attending the event.",
"example": [
@@ -20740,12 +21197,10 @@
"key": "value"
}
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
"example": {
"customField": "customValue"
}
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
}
},
"required": [
@@ -20761,10 +21216,20 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
"attendee": {
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
}
]
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values for custom booking fields added by you.",
"example": {
"customField": "customValue"
}
},
"eventTypeId": {
"type": "number",
@@ -20773,7 +21238,7 @@
},
"eventTypeSlug": {
"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"
},
"username": {
@@ -20781,19 +21246,16 @@
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
"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": {
"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"
},
"attendee": {
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
}
]
},
"guests": {
"description": "An optional list of guest emails attending the event.",
"example": [
@@ -20847,12 +21309,10 @@
"key": "value"
}
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
"example": {
"customField": "customValue"
}
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"instant": {
"type": "boolean",
@@ -20874,10 +21334,20 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
"attendee": {
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
}
]
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values for custom booking fields added by you.",
"example": {
"customField": "customValue"
}
},
"eventTypeId": {
"type": "number",
@@ -20886,7 +21356,7 @@
},
"eventTypeSlug": {
"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"
},
"username": {
@@ -20894,19 +21364,16 @@
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
"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": {
"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"
},
"attendee": {
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
}
]
},
"guests": {
"description": "An optional list of guest emails attending the event.",
"example": [
@@ -20960,12 +21427,10 @@
"key": "value"
}
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
"example": {
"customField": "customValue"
}
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"recurrenceCount": {
"type": "number",
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_SWAGGER_DOCS_URL=http://localhost:3002/api/docs
-34
View File
@@ -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
-34
View File
@@ -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.
-353
View File
@@ -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;
},
})
),
},
},
},
};
-5
View File
@@ -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.
-29
View File
@@ -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"
}
}
-11
View File
@@ -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;
-23
View File
@@ -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

-103
View File
@@ -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;
}
}
-17
View File
@@ -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) => {
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx);
const decodedParams = decodeParams(await params);
if ((props as TeamPageProps)?.team) {
const { team, markdownStrippedBio, isSEOIndexable, currentOrgDomain } = props as TeamPageProps;
@@ -30,7 +31,6 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
image: getOrgOrTeamAvatar(team),
},
};
const decodedParams = decodeParams(await params);
return {
...(await generateMeetingMetadata(
meeting,
@@ -67,7 +67,8 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
() => profile.name,
() => markdownStrippedBio,
false,
getOrgFullOrigin(entity.orgSlug ?? null)
getOrgFullOrigin(entity.orgSlug ?? null),
`/${decodedParams.user}`
)),
robots: {
index: allowSEOIndexing,
@@ -12,7 +12,10 @@ import { ShellMainAppDir } from "../ShellMainAppDir";
export const generateMetadata = async () => {
return await _generateMetadata(
(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),
});
export const generateMetadata = async () =>
export const generateMetadata = async ({ params }: { params: Promise<{ status: string }> }) =>
await _generateMetadata(
(t) => t("bookings"),
(t) => t("bookings_description")
(t) => t("bookings_description"),
undefined,
undefined,
`/bookings/${(await params).status}`
);
const Page = async ({ params }: PageProps) => {
@@ -16,7 +16,10 @@ import EventTypes, { EventTypesCTA } from "~/event-types/views/event-types-listi
export const generateMetadata = async () =>
await _generateMetadata(
(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) => {
@@ -11,7 +11,11 @@ const Layout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (session?.user?.id) SentrySetUser({ id: session.user.id });
return <Shell withoutMain={true}>{children}</Shell>;
return (
<>
<Shell withoutMain={true}>{children}</Shell>
</>
);
};
export default Layout;
@@ -13,7 +13,10 @@ import TeamsView, { TeamsCTA } from "~/teams/teams-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("teams"),
(t) => t("create_manage_teams_collaborative")
(t) => t("create_manage_teams_collaborative"),
undefined,
undefined,
"/teams"
);
const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
+4 -1
View File
@@ -6,7 +6,10 @@ import { Button } from "@calcom/ui/components/button";
export const generateMetadata = () =>
_generateMetadata(
(t) => `${t("access_denied")} | ${APP_NAME}`,
() => ""
() => "",
undefined,
undefined,
"/403"
);
async function Error403() {
+4 -1
View File
@@ -8,7 +8,10 @@ import CopyButton from "./copy-button";
export const generateMetadata = () =>
_generateMetadata(
(t) => `${t("something_unexpected_occurred")} | ${APP_NAME}`,
() => ""
() => "",
undefined,
undefined,
"/500"
);
async function Error500({ searchParams }: { searchParams: { error?: string } }) {
@@ -28,7 +28,10 @@ export const generateMetadata = async ({ params }: _PageProps) => {
return await generateAppMetadata(
{ slug: logo, name, description },
() => 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 metadata = await _generateMetadata(
() => `${params.slug}`,
() => ""
() => "",
undefined,
undefined,
`/apps/${params.slug}/setup`
);
return {
...metadata,
@@ -3,10 +3,13 @@ import { _generateMetadata } from "app/_utils";
export const generateMetadata = async () => {
return await _generateMetadata(
(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 }) {
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 { appMetadata } = await getData(legacyCtx);
const stepParam = (await params).step;
const step = stepParam && Array.isArray(stepParam) ? stepParam.join("/") : "";
return await _generateMetadata(
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
() => ""
() => "",
undefined,
undefined,
`/apps/installation${step ? `/${step}` : ""}`
);
};
@@ -11,10 +11,13 @@ const querySchema = z.object({
category: z.nativeEnum(AppCategories),
});
export const generateMetadata = async () => {
export const generateMetadata = async ({ params }: { params: Promise<{ category: string }> }) => {
return await _generateMetadata(
(t) => t("installed_apps"),
(t) => t("manage_your_connected_apps")
(t) => t("manage_your_connected_apps"),
undefined,
undefined,
`/apps/installed/${(await params).category}`
);
};
@@ -11,7 +11,10 @@ import AppsPage from "~/apps/apps-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("app_store"),
(t) => t("app_store_description")
(t) => t("app_store_description"),
undefined,
undefined,
"/apps"
);
};
@@ -25,7 +25,10 @@ export const generateMetadata = async ({ params }: { params: Promise<{ pages: st
return await _generateMetadata(
// TODO: Need to show the actual form name instead of "Form"
(t) => (mainPage === "routing-link" ? `Form | Cal.com Forms` : `${t("routing_forms")} | Cal.com Forms`),
() => ""
() => "",
undefined,
undefined,
`/apps/routing-forms/${(await params).pages.join("/")}`
);
};
@@ -3,6 +3,7 @@ import { _generateMetadata, getTranslate } from "app/_utils";
import Link from "next/link";
import { z } from "zod";
import { IdentityProvider } from "@calcom/prisma/client";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
@@ -11,18 +12,47 @@ import AuthContainer from "@components/ui/AuthContainer";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("error"),
() => ""
() => "",
undefined,
undefined,
"/auth/error"
);
};
const querySchema = z.object({
error: z.string().optional(),
provider: z.string().optional(),
});
const ServerPage = async ({ searchParams }: PageProps) => {
const t = await getTranslate();
const { error } = querySchema.parse({ error: (await searchParams)?.error || undefined });
const errorMsg = t("error_during_login") + (error ? ` Error code: ${error}` : "");
const { error, provider } = querySchema.parse({
error: (await searchParams)?.error || undefined,
provider: (await searchParams)?.provider || undefined,
});
// generate error message based on different errors
const getErrorMessage = (error: string | undefined, provider: string | undefined) => {
if (error === "user-creation-error") {
return t("user_creation_error");
} else if (error === "wrong-provider") {
// showing user their original identity provider by which account is managed.
const providerName =
provider === IdentityProvider.GOOGLE
? "Google"
: provider === IdentityProvider.CAL
? "Email and Password"
: provider === IdentityProvider.SAML
? "SAML (like Okta)"
: "your original login method";
return t("account_managed_by_identity_provider_error", { provider: providerName });
}
return t("error_during_login") + (error ? ` Error code: ${error}` : "");
};
const errorMsg = getErrorMessage(error, provider);
return (
<AuthContainer>
<div>
@@ -10,10 +10,13 @@ import { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/getSer
import type { PageProps as ClientPageProps } from "~/auth/forgot-password/[id]/forgot-password-single-view";
import SetNewUserPassword from "~/auth/forgot-password/[id]/forgot-password-single-view";
export const generateMetadata = async () => {
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) => {
return await _generateMetadata(
(t) => t("reset_password"),
(t) => t("change_your_password")
(t) => t("change_your_password"),
undefined,
undefined,
`/auth/forgot-password/${(await params).id}`
);
};
@@ -13,7 +13,10 @@ import ForgotPassword from "~/auth/forgot-password/forgot-password-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("forgot_password"),
(t) => t("request_password_reset")
(t) => t("request_password_reset"),
undefined,
undefined,
"/auth/forgot-password"
);
};
@@ -13,7 +13,10 @@ import Login from "~/auth/login-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("login"),
(t) => t("login")
(t) => t("login"),
undefined,
undefined,
"/auth/login"
);
};
@@ -9,7 +9,10 @@ import Logout from "~/auth/logout-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("logged_out"),
(t) => t("youve_been_logged_out")
(t) => t("youve_been_logged_out"),
undefined,
undefined,
"/auth/logout"
);
};
@@ -5,7 +5,10 @@ import Page from "~/auth/oauth2/authorize-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("authorize"),
() => ""
() => "",
undefined,
undefined,
"/auth/oauth2/authorize"
);
};
@@ -5,7 +5,10 @@ import Page from "~/auth/platform/authorize-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("authorize"),
() => ""
() => "",
undefined,
undefined,
"/auth/platform/authorize"
);
};
@@ -13,7 +13,10 @@ import type { PageProps as ClientPageProps } from "~/auth/setup-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("setup"),
(t) => t("setup_description")
(t) => t("setup_description"),
undefined,
undefined,
"/auth/setup"
);
};
@@ -13,7 +13,10 @@ import VerifyEmailChange from "~/auth/verify-email-change-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("verify_email_change"),
() => ""
() => "",
undefined,
undefined,
"/auth/verify-email-change"
);
};
@@ -5,7 +5,10 @@ import VerifyEmailPage from "~/auth/verify-email-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("verify_email_button"),
() => ""
() => "",
undefined,
undefined,
"/auth/verify-email"
);
};
@@ -26,7 +26,10 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
return await _generateMetadata(
() =>
hasPaymentFailed ? "Your payment failed" : sessionId ? "Payment successful!" : `Verify your email`,
() => ""
() => "",
undefined,
undefined,
"/auth/verify"
);
};
@@ -38,7 +38,10 @@ export const generateMetadata = async ({ params }: PageProps) => {
return await _generateMetadata(
(t) => (schedule.name ? `${schedule.name} | ${t("availability")}` : t("availability")),
() => ""
() => "",
undefined,
undefined,
`/availability/${parsed.data.schedule}`
);
};
@@ -7,10 +7,12 @@ import { Icon } from "@calcom/ui/components/icon";
export default function TroubleshooterLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-1 [&>*]:flex-1">
<ErrorBoundary>
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
</ErrorBoundary>
</div>
<>
<div className="flex flex-1 [&>*]:flex-1">
<ErrorBoundary>
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
</ErrorBoundary>
</div>
</>
);
}
@@ -5,7 +5,10 @@ import Troubleshoot from "~/availability/troubleshoot/troubleshoot-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("troubleshoot"),
(t) => t("troubleshoot_availability")
(t) => t("troubleshoot_availability"),
undefined,
undefined,
"/availability/troubleshoot"
);
};
@@ -26,7 +26,8 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
(t) =>
t(`booking_${needsConfirmation ? "submitted" : "confirmed"}${recurringBookings ? "_recurring" : ""}`),
false,
getOrgFullOrigin(orgSlug)
getOrgFullOrigin(orgSlug),
`/booking/${(await params).uid}`
);
};
@@ -5,7 +5,10 @@ import BookingDryRunSuccessView from "~/bookings/views/booking-dry-run-success-v
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("booking_dry_run_successful"),
(t) => t("booking_dry_run_successful_description")
(t) => t("booking_dry_run_successful_description"),
undefined,
undefined,
"/booking/dry-run-successful"
);
};
@@ -7,7 +7,10 @@ import LegacyPage from "~/connect-and-join/connect-and-join-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("connect_and_join"),
() => ""
() => "",
undefined,
undefined,
"/connect-and-join"
);
};
@@ -10,7 +10,8 @@ import { type PageProps } from "@lib/d/[link]/[slug]/getServerSideProps";
import Type from "~/d/[link]/d-type-view";
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const _params = await params;
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), _params, await searchParams);
const pageProps = await getData(legacyCtx);
const { booking, eventData, isBrandingHidden } = pageProps;
@@ -21,7 +22,9 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
return await _generateMetadata(
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`,
isBrandingHidden
isBrandingHidden,
undefined,
`/d/${_params.link}/${_params.slug}`
);
};
@@ -5,7 +5,10 @@ import EnterprisePage from "@components/EnterprisePage";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("create_your_org"),
(t) => t("create_your_org_description")
(t) => t("create_your_org_description"),
undefined,
undefined,
"/enterprise"
);
const ServerPageWrapper = async () => {
@@ -24,7 +24,10 @@ export const generateMetadata = async ({ params }: _PageProps) => {
if (!parsed.success) {
return await _generateMetadata(
(t) => `${t("event_type")}`,
() => ""
() => "",
undefined,
undefined,
"/event-types"
);
}
@@ -34,7 +37,10 @@ export const generateMetadata = async ({ params }: _PageProps) => {
return await _generateMetadata(
(t) => (data?.title ? `${data.title} | ${t("event_type")}` : `${t("event_type")}`),
() => ""
() => "",
undefined,
undefined,
`/event-types/${parsed.data.type}`
);
};
@@ -11,11 +11,15 @@ import { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSi
import type { PageProps as ClientPageProps } from "~/getting-started/[[...step]]/onboarding-view";
import Page from "~/getting-started/[[...step]]/onboarding-view";
export const generateMetadata = async () => {
export const generateMetadata = async ({ params }: ServerPageProps) => {
const stepParam = (await params).step;
const step = stepParam && Array.isArray(stepParam) ? stepParam.join("/") : "";
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("getting_started")}`,
() => "",
true
true,
undefined,
`/getting-started${step ? `/${step}` : ""}`
);
};
@@ -8,14 +8,16 @@ import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const t = await getTranslate();
return (
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
</Shell>
</div>
<>
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
</Shell>
</div>
</>
);
}
@@ -8,7 +8,10 @@ import InsightsPage from "~/insights/insights-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
(t) => t("insights_subtitle")
(t) => t("insights_subtitle"),
undefined,
undefined,
"/insights"
);
export default async function Page() {
@@ -8,7 +8,10 @@ import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
(t) => t("insights_subtitle")
(t) => t("insights_subtitle"),
undefined,
undefined,
"/insights/router-position"
);
export default async function Page() {
@@ -8,7 +8,10 @@ import InsightsRoutingPage from "~/insights/insights-routing-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
(t) => t("insights_subtitle")
(t) => t("insights_subtitle"),
undefined,
undefined,
"/insights/routing"
);
export default async function Page() {
@@ -7,7 +7,10 @@ import LegacyPage from "~/maintenance/maintenance-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("under_maintenance"),
(t) => t("under_maintenance_description", { appName: APP_NAME })
(t) => t("under_maintenance_description", { appName: APP_NAME }),
undefined,
undefined,
"/maintenance"
);
};
@@ -5,7 +5,10 @@ import Page from "~/more/more-page-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("more"),
() => ""
() => "",
undefined,
undefined,
"/more"
);
};
@@ -16,7 +16,10 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const eventName = props.booking.title;
return await _generateMetadata(
(t) => `${t("payment")} | ${eventName} | ${APP_NAME}`,
() => ""
() => "",
undefined,
undefined,
`/payment/${(await params).uid}`
);
};
@@ -4,10 +4,13 @@ import { getTranslate } from "app/_utils";
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
export const generateMetadata = async ({ params }: { params: Promise<{ category: string }> }) =>
await _generateMetadata(
(t) => t("apps"),
(t) => t("admin_apps_description")
(t) => t("admin_apps_description"),
undefined,
undefined,
`/settings/admin/apps/${(await params).category}`
);
const Page = async () => {

Some files were not shown because too many files have changed in this diff Show More