feat: v2 admin GET bookings, user information (#20221)

* feat: membership user return bio and metadata

* feat: input and output bio and metadata for users and manager users

* test: managed event type appears when fetching user event types

* test: managed event type appears when fetching user event types

* test: managed event type appears when fetching user event types

* test: managed event type appears when fetching non-org team user event types

* feat: organizations users bookings endpoint

* feat: book using eventSlug & username & organizationSlug

* chore: generate latest docs
This commit is contained in:
Lauris Skraucis
2025-04-04 10:25:10 +00:00
committed by GitHub
parent 7c6b055cd1
commit 277da187d0
48 changed files with 2264 additions and 526 deletions
@@ -99,6 +99,10 @@ export class BookingsController_2024_08_13 {
For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body.
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:
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.
`,
})
@ApiBody({
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { EventType, User } from "@prisma/client";
import { advanceTo, clear } from "jest-date-mock";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
@@ -66,6 +66,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
let user: User;
let eventTypeId: number;
let eventType: EventType;
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
let recurringEventTypeId: number;
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
@@ -128,6 +129,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
user.id
);
eventTypeId = event.id;
eventType = event;
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
@@ -1663,6 +1665,135 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("book by username and event type slug", () => {
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
const body = {
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: googleMeetUrl,
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
guests: ["bob@gmail.com"],
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.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);
});
it("should create a booking by username and event type slug", async () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
eventTypeSlug: eventType.slug,
username: user.username || "",
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: googleMeetUrl,
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
guests: ["bob@gmail.com"],
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
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[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2036, 0, 8, 16, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(eventTypeId);
expect(data.eventType).toEqual({
id: eventTypeId,
slug: eventTypeSlug,
});
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.location).toEqual(body.location);
expect(data.meetingUrl).toEqual(body.location);
expect(data.absentHost).toEqual(false);
expect(data.bookingFieldsResponses).toEqual({
name: body.attendee.name,
email: body.attendee.email,
...body.bookingFieldsResponses,
guests: body.guests,
location: {
optionValue: googleMeetUrl,
value: "link",
},
});
expect(data.guests).toEqual(body.guests);
// Check createdAt date is between the time of the request and after the request
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
// Check updatedAt date is between the time of the request and after the request
expect(data.updatedAt).toBeDefined();
const updatedAtDate = data.updatedAt ? new Date(data.updatedAt) : null;
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
expect(data.metadata).toEqual(body.metadata);
createdBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
describe("booking location", () => {
const address = "123 Main St";
const link = "https://cal.com/join/123456";
@@ -45,7 +45,7 @@ import {
CancelBookingInput,
} from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
import { EventType } from "@calcom/prisma/client";
import { EventType, User, Team } from "@calcom/prisma/client";
type CreatedBooking = {
hosts: { id: number }[];
@@ -61,6 +61,8 @@ const eventTypeBookingFieldSchema = z.object({
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
@Injectable()
export class BookingsService_2024_08_13 {
private readonly logger = new Logger("BookingsService");
@@ -80,27 +82,43 @@ export class BookingsService_2024_08_13 {
async createBooking(request: Request, body: CreateBookingInput) {
try {
if ("instant" in body && body.instant) {
return await this.createInstantBooking(request, body);
const eventType = await this.getBookedEventType(body);
if (!eventType) {
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
);
}
if (body.username && body.eventTypeSlug && body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
);
}
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
}
body.eventTypeId = eventType.id;
if ("instant" in body && body.instant) {
return await this.createInstantBooking(request, body, eventType);
}
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
const isRecurring = !!eventType?.recurringEvent;
const isSeated = !!eventType?.seatsPerTimeSlot;
await this.hasRequiredBookingFieldsResponses(body, eventType);
if (isRecurring && isSeated) {
return await this.createRecurringSeatedBooking(request, body);
return await this.createRecurringSeatedBooking(request, body, eventType);
}
if (isRecurring && !isSeated) {
return await this.createRecurringBooking(request, body);
return await this.createRecurringBooking(request, body, eventType);
}
if (isSeated) {
return await this.createSeatedBooking(request, body);
return await this.createSeatedBooking(request, body, eventType);
}
return await this.createRegularBooking(request, body);
return await this.createRegularBooking(request, body, eventType);
} catch (error) {
if (error instanceof Error) {
if (error.message === "no_available_users_found_error") {
@@ -111,6 +129,22 @@ export class BookingsService_2024_08_13 {
}
}
async getBookedEventType(body: CreateBookingInput) {
if (body.eventTypeId) {
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
} else if (body.username && body.eventTypeSlug) {
const user = await this.usersRepository.findByUsername(body.username, body.organizationSlug);
if (!user) {
throw new NotFoundException(`User with username ${body.username} not found`);
}
return await this.eventTypesRepository.getUserEventTypeBySlugWithOwnerAndTeam(
user.id,
body.eventTypeSlug
);
}
return null;
}
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
const bookingFields = body.bookingFieldsResponses;
if (!bookingFields || !eventType || !eventType.bookingFields) {
@@ -133,8 +167,12 @@ export class BookingsService_2024_08_13 {
return true;
}
async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
async createInstantBooking(
request: Request,
body: CreateInstantBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleInstantMeeting(bookingRequest);
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
@@ -147,8 +185,8 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputBooking(databaseBooking);
}
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
const bookings = await handleNewRecurringBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
@@ -164,8 +202,8 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputRecurringBookings(ids);
}
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
const bookings = await handleNewRecurringBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
@@ -181,8 +219,8 @@ export class BookingsService_2024_08_13 {
);
}
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleNewBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
@@ -206,8 +244,8 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputBooking(databaseBooking);
}
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleNewBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
@@ -1,4 +1,5 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
import {
bookingResponsesSchema,
seatedBookingDataSchema,
@@ -95,10 +96,15 @@ export class InputBookingsService_2024_08_13 {
async createBookingRequest(
request: Request,
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
): Promise<BookingRequest> {
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId);
const bodyTransformed = await this.transformInputCreateBooking(body, oAuthClientParams?.platformClientId);
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType);
const bodyTransformed = await this.transformInputCreateBooking(
body,
eventType,
oAuthClientParams?.platformClientId
);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
@@ -124,15 +130,11 @@ export class InputBookingsService_2024_08_13 {
return newRequest as unknown as BookingRequest;
}
async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13, platformClientId?: string) {
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
inputBooking.eventTypeId
);
if (!eventType) {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
async transformInputCreateBooking(
inputBooking: CreateBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam,
platformClientId?: string
) {
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
@@ -199,12 +201,14 @@ export class InputBookingsService_2024_08_13 {
async createRecurringBookingRequest(
request: Request,
body: CreateRecurringBookingInput_2024_08_13
body: CreateRecurringBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
): Promise<BookingRequest> {
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId);
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType);
// note(Lauris): update to this.transformInputCreate when rescheduling is implemented
const bodyTransformed = await this.transformInputCreateRecurringBooking(
body,
eventType,
oAuthClientParams?.platformClientId
);
@@ -375,14 +379,9 @@ export class InputBookingsService_2024_08_13 {
async transformInputCreateRecurringBooking(
inputBooking: CreateRecurringBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam,
platformClientId?: string
) {
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
inputBooking.eventTypeId
);
if (!eventType) {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
if (!eventType.recurringEvent) {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
}
@@ -59,4 +59,28 @@ export class PlatformBookingsService {
return undefined;
}
async getOAuthClientParamsForEventType(eventType: {
userId: number | null | undefined;
teamId: number | null | undefined;
}) {
let oAuthClient: PlatformOAuthClient | null = null;
if (eventType.userId) {
oAuthClient = await this.oAuthClientRepository.getByUserId(eventType.userId);
} else if (eventType.teamId) {
oAuthClient = await this.oAuthClientRepository.getByTeamId(eventType.teamId);
}
if (oAuthClient) {
return {
platformClientId: oAuthClient.id,
platformCancelUrl: oAuthClient.bookingCancelRedirectUri,
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
platformBookingUrl: oAuthClient.bookingRedirectUri,
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
};
}
return undefined;
}
}
@@ -111,6 +111,18 @@ export class EventTypesRepository_2024_06_14 {
});
}
async getUserEventTypeBySlugWithOwnerAndTeam(userId: number, slug: string) {
return this.dbRead.prisma.eventType.findUnique({
where: {
userId_slug: {
userId: userId,
slug: slug,
},
},
include: { owner: true, team: true },
});
}
async deleteEventType(eventTypeId: number) {
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
}
@@ -6,6 +6,7 @@ import { DestinationCalendarsModule } from "@/modules/destination-calendars/dest
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { OrganizationsBookingsModule } from "@/modules/organizations/bookings/organizations.bookings.module";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
@@ -28,6 +29,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
StripeModule,
ConferencingModule,
OrganizationsTeamsBookingsModule,
OrganizationsUsersBookingsModule,
OrganizationsBookingsModule,
RouterModule,
],
@@ -208,6 +208,10 @@ describe("OAuth Client Users Endpoints", () => {
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
bio: "I am a bio",
metadata: {
key: "value",
},
};
const response = await request(app.getHttpServer())
@@ -231,6 +235,8 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
expect(responseBody.data.user.bio).toEqual(requestBody.bio);
expect(responseBody.data.user.metadata).toEqual(requestBody.metadata);
const [emailUser, emailDomain] = responseBody.data.user.email.split("@");
const [domainName, TLD] = emailDomain.split(".");
expect(responseBody.data.user.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`));
@@ -272,6 +278,10 @@ describe("OAuth Client Users Endpoints", () => {
locale: Locales.FR,
name: "Bob Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
bio: "I am a bio",
metadata: {
key: "value",
},
};
const response = await request(app.getHttpServer())
@@ -295,6 +305,8 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
expect(responseBody.data.user.bio).toEqual(requestBody.bio);
expect(responseBody.data.user.metadata).toEqual(requestBody.metadata);
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
expect(accessToken).toBeDefined();
@@ -333,6 +345,10 @@ describe("OAuth Client Users Endpoints", () => {
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
bio: "I am a bio",
metadata: {
key: "value",
},
};
const response = await request(app.getHttpServer())
@@ -356,6 +372,8 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
expect(responseBody.data.user.bio).toEqual(requestBody.bio);
expect(responseBody.data.user.metadata).toEqual(requestBody.metadata);
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
expect(accessToken).toBeDefined();
@@ -479,6 +497,10 @@ describe("OAuth Client Users Endpoints", () => {
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
});
it(`/GET: managed user by original email`, async () => {
@@ -496,6 +518,8 @@ describe("OAuth Client Users Endpoints", () => {
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
expect(userOne?.email).toEqual(postResponseData.user.email);
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
});
it(`/GET: managed users by original emails`, async () => {
@@ -518,6 +542,10 @@ describe("OAuth Client Users Endpoints", () => {
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
});
it(`/GET: managed user by oAuth email`, async () => {
@@ -540,6 +568,8 @@ describe("OAuth Client Users Endpoints", () => {
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
expect(userOne?.email).toEqual(postResponseData.user.email);
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
});
it(`should error /GET if managed user email is invalid`, async () => {
@@ -576,6 +606,10 @@ describe("OAuth Client Users Endpoints", () => {
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
});
it(`/GET/:id`, async () => {
@@ -592,6 +626,9 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.email).toEqual(
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userEmail)
);
expect(responseBody.data.name).toEqual(postResponseData.user.name);
expect(responseBody.data.bio).toEqual(postResponseData.user.bio);
expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata);
});
it(`/PUT/:id`, async () => {
@@ -612,6 +649,9 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.email).toEqual(
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userUpdatedEmail)
);
expect(responseBody.data.name).toEqual(postResponseData.user.name);
expect(responseBody.data.bio).toEqual(postResponseData.user.bio);
expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata);
const [emailUser, emailDomain] = responseBody.data.email.split("@");
const [domainName, TLD] = emailDomain.split(".");
expect(responseBody.data.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`));
@@ -12,6 +12,7 @@ import { TOKENS_DOCS } from "@/modules/oauth-clients/controllers/oauth-flow/oaut
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
@@ -34,6 +35,7 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
import { User, MembershipRole } from "@prisma/client";
import { plainToInstance } from "class-transformer";
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@@ -55,7 +57,8 @@ export class OAuthClientUsersController {
private readonly userRepository: UsersRepository,
private readonly oAuthClientUsersService: OAuthClientUsersService,
private readonly oauthRepository: OAuthClientRepository,
private readonly tokensRepository: TokensRepository
private readonly tokensRepository: TokensRepository,
private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService
) {}
@Get("/")
@@ -70,7 +73,7 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: managedUsers.map((user) => this.getResponseUser(user)),
data: managedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)),
};
}
@@ -94,7 +97,7 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: {
user: this.getResponseUser(user),
user: this.oAuthClientUsersOutputService.getResponseUser(user),
...tokens,
},
};
@@ -112,7 +115,7 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: this.getResponseUser(user),
data: this.oAuthClientUsersOutputService.getResponseUser(user),
};
}
@@ -132,7 +135,7 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: this.getResponseUser(user),
data: this.oAuthClientUsersOutputService.getResponseUser(user),
};
}
@@ -151,7 +154,7 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: this.getResponseUser(user),
data: this.oAuthClientUsersOutputService.getResponseUser(user),
};
}
@@ -189,20 +192,4 @@ export class OAuthClientUsersController {
return user;
}
private getResponseUser(user: User): ManagedUserOutput {
return {
id: user.id,
email: user.email,
username: user.username,
name: user.name,
timeZone: user.timeZone,
weekStart: user.weekStart,
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
locale: user.locale as Locales,
avatarUrl: user.avatarUrl,
};
}
}
@@ -1,40 +1,54 @@
import { Locales } from "@/lib/enums/locales";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsEnum, IsOptional, IsUrl } from "class-validator";
import { Expose, Transform } from "class-transformer";
import { IsEnum, IsObject, IsOptional, IsUrl } from "class-validator";
export class ManagedUserOutput {
@ApiProperty({ example: 1 })
@Expose()
id!: number;
@ApiProperty({ example: "alice+cluo37fwd0001khkzqqynkpj3@example.com" })
@Expose()
email!: string;
@ApiProperty({ example: "alice", nullable: true })
@Expose()
username!: string | null;
@ApiProperty({ example: "alice", nullable: true })
@Expose()
name!: string | null;
@ApiProperty({ example: "bio", nullable: true })
@Expose()
bio!: string | null;
@ApiProperty({ example: "America/New_York" })
@Expose()
timeZone!: string;
@ApiProperty({ example: "Sunday" })
@Expose()
weekStart!: string;
@ApiProperty({ type: String, example: "2024-04-01T00:00:00.000Z" })
@Transform(({ value }) => value.toISOString())
@Expose()
createdDate!: Date;
@ApiProperty({ type: Number, example: 12, nullable: true })
@Expose()
timeFormat!: number | null;
@ApiProperty({ type: Number, example: null, nullable: true })
@Expose()
defaultScheduleId!: number | null;
@IsEnum(Locales)
@IsOptional()
@ApiPropertyOptional({ type: String, example: Locales.EN, enum: Locales })
@Expose()
locale?: Locales;
@IsUrl()
@@ -45,5 +59,21 @@ export class ManagedUserOutput {
description: `URL of the user's avatar image`,
nullable: true,
})
@Expose()
avatarUrl?: string | null;
@ApiPropertyOptional({
type: Object,
example: { key: "value" },
})
@IsObject()
@IsOptional()
@Expose()
@Transform(
// note(Lauris): added this transform because without it metadata is removed for some reason
({ obj }: { obj: { metadata: Record<string, unknown> | null | undefined } }) => {
return obj.metadata || undefined;
}
)
metadata?: Record<string, string | boolean | number>;
}
@@ -10,6 +10,8 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
import { OAuthClientsService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { UsersRepository } from "@/modules/users/users.repository";
@@ -51,6 +53,7 @@ export class OAuthClientsController {
private readonly logger = new Logger("OAuthClientController");
constructor(
private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService,
private readonly oAuthClientsService: OAuthClientsService,
private readonly userRepository: UsersRepository,
private readonly teamsRepository: OrganizationsRepository
@@ -124,7 +127,10 @@ export class OAuthClientsController {
limit ?? 50
);
return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) };
return {
status: SUCCESS_STATUS,
data: existingManagedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)),
};
}
@Patch("/:clientId")
@@ -151,18 +157,4 @@ export class OAuthClientsController {
const client = await this.oAuthClientsService.deleteOAuthClient(clientId);
return { status: SUCCESS_STATUS, data: client };
}
private getResponseUser(user: User): ManagedUserOutput {
return {
id: user.id,
email: user.email,
username: user.username,
name: user.name,
timeZone: user.timeZone,
weekStart: user.weekStart,
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
};
}
}
@@ -11,6 +11,7 @@ import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthClientsInputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-input.service";
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
@@ -58,8 +59,9 @@ import { JwtService } from "@nestjs/jwt";
OAuthClientsInputService,
OAuthClientsOutputService,
JwtService,
OAuthClientUsersOutputService,
],
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
exports: [OAuthClientRepository],
exports: [OAuthClientRepository, OAuthClientUsersOutputService],
})
export class OAuthClientModule {}
@@ -0,0 +1,11 @@
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
import { Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { plainToInstance } from "class-transformer";
@Injectable()
export class OAuthClientUsersOutputService {
getResponseUser(user: User) {
return plainToInstance(ManagedUserOutput, user, { strategy: "excludeAll" });
}
}
@@ -43,7 +43,7 @@ export class OAuthClientUsersService {
);
} else {
const email = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email);
user = (
const createdUser = (
await createNewUsersConnectToOrgIfExists({
invitations: [
{
@@ -69,15 +69,14 @@ export class OAuthClientUsersService {
language: body.locale ?? Locales.EN,
})
)[0];
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
const updatedUser = await this.userRepository.update(user.id, {
await this.userRepository.addToOAuthClient(createdUser.id, oAuthClientId);
user = await this.userRepository.update(createdUser.id, {
name: body.name,
locale: body.locale,
avatarUrl: body.avatarUrl,
bio: body.bio,
metadata: body.metadata,
});
user.locale = updatedUser.locale;
user.name = updatedUser.name;
user.avatarUrl = updatedUser.avatarUrl;
}
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
@@ -1,10 +1,5 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
import {
GetBookingsDataEntry,
GetBookingsOutput_2024_04_15,
} from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { Locales } from "@/lib/enums/locales";
@@ -15,7 +10,7 @@ import {
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { OrgTeamMembershipOutputResponseDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
@@ -32,18 +27,10 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_06_14,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
CreateTeamEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
Host,
OrgTeamOutputDto,
TeamEventTypeOutput_2024_06_14,
@@ -238,7 +225,7 @@ describe("Assign all team members", () => {
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: OrgTeamMembershipOutputResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
});
});
@@ -257,7 +244,7 @@ describe("Assign all team members", () => {
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: OrgTeamMembershipOutputResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
});
});
@@ -17,7 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookerLayoutsInputEnum_2024_06_14,
@@ -27,6 +27,7 @@ import {
import {
ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
Host,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
@@ -62,6 +63,8 @@ describe("Organizations Event Types Endpoints", () => {
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
let managedEventType: TeamEventTypeOutput_2024_06_14;
const managedEventTypeSlug = `organizations-event-types-managed-${randomString()}`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -101,6 +104,7 @@ describe("Organizations Event Types Endpoints", () => {
org = await organizationsRepositoryFixture.create({
name: `organizations-event-types-organization-${randomString()}`,
isOrganization: true,
slug: `organizations-event-types-organization-${randomString()}`,
});
falseTestOrg = await organizationsRepositoryFixture.create({
@@ -135,6 +139,36 @@ describe("Organizations Event Types Endpoints", () => {
},
});
await profileRepositoryFixture.create({
uid: `usr-${teammate1.id}`,
username: teammate1Email,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: teammate1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${teammate2.id}`,
username: teammate2Email,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: teammate2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: userAdmin.id } },
@@ -347,7 +381,7 @@ describe("Organizations Event Types Endpoints", () => {
it("should create a managed team event-type", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation managed",
slug: `organizations-event-types-managed-${randomString()}`,
slug: managedEventTypeSlug,
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
@@ -387,7 +421,9 @@ describe("Organizations Event Types Endpoints", () => {
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate1EventTypes[0].slug).toEqual(managedEventTypeSlug);
expect(teammate2EventTypes.length).toEqual(1);
expect(teammate2EventTypes[0].slug).toEqual(managedEventTypeSlug);
expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual(
1
);
@@ -410,6 +446,42 @@ describe("Organizations Event Types Endpoints", () => {
});
});
it("managed team event types should be returned when fetching event types of users", async () => {
return request(app.getHttpServer())
.get(`/v2/event-types?username=${teammate1.username}&orgSlug=${org.slug}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].slug).toEqual(managedEventTypeSlug);
expect(data[0].ownerId).toEqual(teammate1.id);
expect(data[0].id).not.toEqual(managedEventType.id);
});
});
it("managed team event type should be returned when fetching event types of users", async () => {
return request(app.getHttpServer())
.get(
`/v2/event-types?username=${teammate1.username}&orgSlug=${org.slug}&eventSlug=${managedEventTypeSlug}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].slug).toEqual(managedEventTypeSlug);
expect(data[0].ownerId).toEqual(teammate1.id);
expect(data[0].id).not.toEqual(managedEventType.id);
});
});
it("should not get an event-type of team outside org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
@@ -2,7 +2,13 @@ import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { UpdateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/update-organization-membership.input";
import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/outputs/create-membership.output";
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
@@ -32,7 +38,7 @@ describe("Organizations Memberships Endpoints", () => {
let org: Team;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: Membership;
let membershipCreatedViaApi: TeamMembershipOutput;
const userEmail = `organizations-memberships-admin-${randomString()}@api.com`;
const userEmail2 = `organizations-memberships-member-${randomString()}@api.com`;
@@ -43,6 +49,11 @@ describe("Organizations Memberships Endpoints", () => {
let userToInviteViaApi: User;
const metadata = {
some: "key",
};
const bio = "This is a bio";
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -58,15 +69,21 @@ describe("Organizations Memberships Endpoints", () => {
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
bio,
metadata,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
bio,
metadata,
});
userToInviteViaApi = await userRepositoryFixture.create({
email: invitedUserEmail,
username: invitedUserEmail,
bio,
metadata,
});
org = await organizationsRepositoryFixture.create({
@@ -104,10 +121,24 @@ describe("Organizations Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/memberships`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
const responseBody: GetAllOrgMemberships = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership.id);
expect(responseBody.data[0].userId).toEqual(user.id);
expect(responseBody.data[0].role).toEqual("ADMIN");
expect(responseBody.data[0].user.bio).toEqual(bio);
expect(responseBody.data[0].user.metadata).toEqual(metadata);
expect(responseBody.data[0].user.email).toEqual(user.email);
expect(responseBody.data[0].user.username).toEqual(user.username);
expect(responseBody.data[0].teamId).toEqual(org.id);
expect(responseBody.data[1].id).toEqual(membership2.id);
expect(responseBody.data[1].userId).toEqual(user2.id);
expect(responseBody.data[1].role).toEqual("MEMBER");
expect(responseBody.data[1].user.bio).toEqual(bio);
expect(responseBody.data[1].user.metadata).toEqual(metadata);
expect(responseBody.data[1].user.email).toEqual(user2.email);
expect(responseBody.data[1].user.username).toEqual(user2.username);
expect(responseBody.data[1].teamId).toEqual(org.id);
});
});
@@ -116,10 +147,16 @@ describe("Organizations Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
const responseBody: GetAllOrgMemberships = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership2.id);
expect(responseBody.data[0].role).toEqual("MEMBER");
expect(responseBody.data[0].user.bio).toEqual(bio);
expect(responseBody.data[0].user.metadata).toEqual(metadata);
expect(responseBody.data[0].user.email).toEqual(user2.email);
expect(responseBody.data[0].user.username).toEqual(user2.username);
expect(responseBody.data[0].userId).toEqual(user2.id);
expect(responseBody.data[0].teamId).toEqual(org.id);
});
});
@@ -132,10 +169,16 @@ describe("Organizations Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/memberships/${membership.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: GetOrgMembership = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membership.id);
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.role).toEqual("ADMIN");
expect(responseBody.data.user.bio).toEqual(bio);
expect(responseBody.data.user.metadata).toEqual(metadata);
expect(responseBody.data.user.email).toEqual(user.email);
expect(responseBody.data.user.username).toEqual(user.username);
expect(responseBody.data.teamId).toEqual(org.id);
});
});
@@ -149,12 +192,16 @@ describe("Organizations Memberships Endpoints", () => {
} satisfies CreateOrgMembershipDto)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: CreateOrgMembershipOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.teamId).toEqual(org.id);
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
expect(membershipCreatedViaApi.user.bio).toEqual(bio);
expect(membershipCreatedViaApi.user.metadata).toEqual(metadata);
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username);
});
});
@@ -166,7 +213,7 @@ describe("Organizations Memberships Endpoints", () => {
} satisfies UpdateOrgMembershipDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: UpdateOrgMembership = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.role).toEqual("OWNER");
@@ -178,7 +225,7 @@ describe("Organizations Memberships Endpoints", () => {
.delete(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: DeleteOrgMembership = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
});
@@ -18,9 +18,9 @@ import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/o
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
UseGuards,
@@ -71,7 +71,7 @@ export class OrganizationsMembershipsController {
return {
status: SUCCESS_STATUS,
data: memberships.map((membership) =>
plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" })
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
),
};
}
@@ -88,7 +88,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@@ -105,7 +105,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@@ -122,7 +122,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@@ -144,7 +144,7 @@ export class OrganizationsMembershipsController {
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
}
@@ -1,6 +1,7 @@
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable } from "@nestjs/common";
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
@@ -15,6 +16,7 @@ export class OrganizationsMembershipRepository {
id: membershipId,
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -24,6 +26,7 @@ export class OrganizationsMembershipRepository {
teamId: organizationId,
userId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -33,6 +36,7 @@ export class OrganizationsMembershipRepository {
id: membershipId,
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -41,12 +45,14 @@ export class OrganizationsMembershipRepository {
create: { ...data, teamId: organizationId },
update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation },
where: { userId_teamId: { userId: data.userId, teamId: organizationId } },
include: { user: { select: MembershipUserSelect } },
});
}
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
return this.dbWrite.prisma.membership.update({
data: { ...data },
where: { id: membershipId, teamId: organizationId },
include: { user: { select: MembershipUserSelect } },
});
}
@@ -55,6 +61,7 @@ export class OrganizationsMembershipRepository {
where: {
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
skip,
take,
});
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class CreateOrgMembershipOutput {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgMembershipOutputDto,
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgMembershipOutputDto)
data!: OrgMembershipOutputDto;
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
@@ -9,5 +9,5 @@ export class DeleteOrgMembership {
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data!: OrgMembershipOutputDto;
data!: TeamMembershipOutput;
}
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
@@ -11,11 +11,11 @@ export class GetAllOrgMemberships {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgMembershipOutputDto,
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested({ each: true })
@Type(() => OrgMembershipOutputDto)
@Type(() => TeamMembershipOutput)
@IsArray()
data!: OrgMembershipOutputDto[];
data!: TeamMembershipOutput[];
}
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class GetOrgMembership {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgMembershipOutputDto,
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgMembershipOutputDto)
data!: OrgMembershipOutputDto;
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -1,37 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
export class OrgMembershipOutputDto {
@IsInt()
@Expose()
@ApiProperty()
readonly id!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly userId!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly teamId!: number;
@IsBoolean()
@Expose()
@ApiProperty()
readonly accepted!: boolean;
@IsString()
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
@Expose()
readonly role!: MembershipRole;
@IsOptional()
@IsBoolean()
@Expose()
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
}
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class UpdateOrgMembership {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgMembershipOutputDto,
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgMembershipOutputDto)
data!: OrgMembershipOutputDto;
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -2,8 +2,8 @@ import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
@@ -43,7 +43,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
let managedEventType: EventType;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: OrgTeamMembershipOutputDto;
let membershipCreatedViaApi: TeamMembershipOutput;
const userEmail = `organizations-teams-memberships-admin-${randomString()}@api.com`;
const userEmail2 = `organizations-teams-memberships-member-${randomString()}@api.com`;
@@ -56,6 +56,11 @@ describe("Organizations Teams Memberships Endpoints", () => {
let userToInviteViaApi: User;
const metadata = {
some: "key",
};
const bio = "This is a bio";
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -75,10 +80,14 @@ describe("Organizations Teams Memberships Endpoints", () => {
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
bio,
metadata,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
bio,
metadata,
});
nonOrgUser = await userRepositoryFixture.create({
@@ -89,6 +98,8 @@ describe("Organizations Teams Memberships Endpoints", () => {
userToInviteViaApi = await userRepositoryFixture.create({
email: invitedUserEmail,
username: invitedUserEmail,
bio,
metadata,
});
org = await organizationsRepositoryFixture.create({
@@ -232,11 +243,25 @@ describe("Organizations Teams Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership.id);
expect(responseBody.data[0].userId).toEqual(user.id);
expect(responseBody.data[0].role).toEqual("ADMIN");
expect(responseBody.data[0].user.bio).toEqual(bio);
expect(responseBody.data[0].user.metadata).toEqual(metadata);
expect(responseBody.data[0].user.email).toEqual(user.email);
expect(responseBody.data[0].user.username).toEqual(user.username);
expect(responseBody.data[1].id).toEqual(membership2.id);
expect(responseBody.data[1].userId).toEqual(user2.id);
expect(responseBody.data[1].role).toEqual("MEMBER");
expect(responseBody.data[1].user.bio).toEqual(user2.bio);
expect(responseBody.data[1].user.metadata).toEqual(user2.metadata);
expect(responseBody.data[1].user.email).toEqual(user2.email);
expect(responseBody.data[1].user.username).toEqual(user2.username);
expect(responseBody.data.length).toEqual(2);
expect(responseBody.data[0].teamId).toEqual(orgTeam.id);
expect(responseBody.data[1].teamId).toEqual(orgTeam.id);
});
});
@@ -251,10 +276,15 @@ describe("Organizations Teams Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership2.id);
expect(responseBody.data[0].userId).toEqual(user2.id);
expect(responseBody.data[0].role).toEqual("MEMBER");
expect(responseBody.data[0].user.bio).toEqual(bio);
expect(responseBody.data[0].user.metadata).toEqual(metadata);
expect(responseBody.data[0].user.email).toEqual(user2.email);
expect(responseBody.data[0].user.username).toEqual(user2.username);
expect(responseBody.data.length).toEqual(1);
});
});
@@ -270,11 +300,16 @@ describe("Organizations Teams Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membership.id);
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.user.email).toEqual(user.email);
expect(responseBody.data.user.username).toEqual(user.username);
expect(responseBody.data.user.bio).toEqual(bio);
expect(responseBody.data.user.metadata).toEqual(metadata);
expect(responseBody.data.role).toEqual("ADMIN");
expect(responseBody.data.teamId).toEqual(orgTeam.id);
});
});
@@ -305,13 +340,16 @@ describe("Organizations Teams Memberships Endpoints", () => {
} satisfies CreateOrgTeamMembershipDto)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id);
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username);
expect(membershipCreatedViaApi.user.bio).toEqual(userToInviteViaApi.bio);
expect(membershipCreatedViaApi.user.metadata).toEqual(userToInviteViaApi.metadata);
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
});
});
@@ -347,10 +385,14 @@ describe("Organizations Teams Memberships Endpoints", () => {
} satisfies UpdateOrgTeamMembershipDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.role).toEqual("OWNER");
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username);
expect(membershipCreatedViaApi.user.bio).toEqual(userToInviteViaApi.bio);
expect(membershipCreatedViaApi.user.metadata).toEqual(userToInviteViaApi.metadata);
});
});
@@ -359,7 +401,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
.delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
});
@@ -16,12 +16,12 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
import {
OrgTeamMembershipOutputDto,
OrgTeamMembershipsOutputResponseDto,
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
UseGuards,
@@ -84,7 +84,7 @@ export class OrganizationsTeamsMembershipsController {
return {
status: SUCCESS_STATUS,
data: orgTeamMemberships.map((membership) =>
plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" })
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
),
};
}
@@ -107,7 +107,7 @@ export class OrganizationsTeamsMembershipsController {
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, orgTeamMembership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, orgTeamMembership, { strategy: "excludeAll" }),
};
}
@@ -131,7 +131,7 @@ export class OrganizationsTeamsMembershipsController {
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@@ -168,7 +168,7 @@ export class OrganizationsTeamsMembershipsController {
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, updatedMembership, { strategy: "excludeAll" }),
};
}
@@ -198,7 +198,7 @@ export class OrganizationsTeamsMembershipsController {
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
}
@@ -2,6 +2,7 @@ import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/member
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
@@ -16,7 +17,7 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
skip,
take,
});
@@ -31,7 +32,7 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
});
}
async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) {
@@ -43,6 +44,7 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -61,14 +63,14 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
});
}
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
});
}
}
@@ -1,74 +1,10 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested } from "class-validator";
import { IsArray, IsEnum, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class MembershipUserOutputDto {
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly avatarUrl?: string;
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly username?: string;
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly name?: string;
@IsBoolean()
@Expose()
@ApiProperty()
readonly email!: string;
}
export class OrgTeamMembershipOutputDto {
@IsInt()
@Expose()
@ApiProperty()
readonly id!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly userId!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly teamId!: number;
@IsBoolean()
@Expose()
@ApiProperty()
readonly accepted!: boolean;
@IsString()
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
@Expose()
readonly role!: MembershipRole;
@IsOptional()
@IsBoolean()
@Expose()
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
@ValidateNested()
@Type(() => MembershipUserOutputDto)
@Expose()
@ApiProperty({ type: MembershipUserOutputDto })
user!: MembershipUserOutputDto;
}
export class OrgTeamMembershipsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -76,10 +12,10 @@ export class OrgTeamMembershipsOutputResponseDto {
@Expose()
@ValidateNested()
@Type(() => OrgTeamMembershipOutputDto)
@Type(() => TeamMembershipOutput)
@IsArray()
@ApiProperty({ type: [OrgTeamMembershipOutputDto] })
data!: OrgTeamMembershipOutputDto[];
@ApiProperty({ type: [TeamMembershipOutput] })
data!: TeamMembershipOutput[];
}
export class OrgTeamMembershipOutputResponseDto {
@@ -89,7 +25,7 @@ export class OrgTeamMembershipOutputResponseDto {
@Expose()
@ValidateNested()
@Type(() => OrgTeamMembershipOutputDto)
@ApiProperty({ type: OrgTeamMembershipOutputDto })
data!: OrgTeamMembershipOutputDto;
@Type(() => TeamMembershipOutput)
@ApiProperty({ type: TeamMembershipOutput })
data!: TeamMembershipOutput;
}
@@ -0,0 +1,48 @@
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 { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
import { OrganizationUsersBookingsService } from "@/modules/organizations/users/bookings/services/organization-users-bookings.service";
import { Controller, UseGuards, Get, Query, ParseIntPipe, Param } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetBookingsInput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
import { User } from "@calcom/prisma/client";
@Controller({
path: "/v2/organizations/:orgId/users/:userId/bookings",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsUserInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Users / Bookings")
export class OrganizationsUsersBookingsController {
constructor(private readonly organizationUsersBookingsService: OrganizationUsersBookingsService) {}
@Get("/")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Get all bookings of an organization user" })
async getOrganizationUserBookings(
@GetUser() user: User,
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetBookingsInput_2024_08_13
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings(
orgId,
user,
query
);
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
}
@@ -0,0 +1,416 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations UsersBookings Endpoints 2024-08-13", () => {
describe("Organization User bookings", () => {
let app: INestApplication;
let organization: Team;
let team1: 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;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const teamUserEmail = `organizations-users-team-user-${randomString()}@api.com`;
let teamUser: User;
let team1EventTypeId: number;
let personalEventTypeId: number;
const personalEventTypeSlug = `organizations-users-bookings-personal-event-type-${randomString()}`;
let createdPersonalBooking: BookingOutput_2024_08_13;
let createdPersonalBookingUsingUsername: BookingOutput_2024_08_13;
let createdTeamBooking: BookingOutput_2024_08_13;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
Test.createTestingModule({
imports: [AppModule, OrganizationsTeamsBookingsModule],
})
)
.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);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organizations user bookings" });
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
teamUser = await userRepositoryFixture.create({
email: teamUserEmail,
username: teamUserEmail,
locale: "it",
name: "orgUser1team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const personalEvent = await eventTypesRepositoryFixture.create(
{
title: `user-bookings-2024-08-13-event-type-${randomString()}`,
slug: personalEventTypeSlug,
length: 15,
},
teamUser.id
);
personalEventTypeId = personalEvent.id;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(teamUser.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${teamUser.id}`,
username: teamUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: team1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
team1EventTypeId = team1EventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: teamUser.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("create bookings", () => {
it("should create a personal booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: personalEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "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[0].id).toEqual(teamUser.id);
expect(data.hosts[0].username).toEqual(teamUser.username);
expect(data.hosts[0].email).toEqual(teamUser.email);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(personalEventTypeId);
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.location).toEqual(body.location);
expect(data.meetingUrl).toEqual(body.location);
expect(data.absentHost).toEqual(false);
expect(data.bookingFieldsResponses.email).toEqual(body.attendee.email);
createdPersonalBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create a personal booking using username and event slug and organization slug", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
eventTypeSlug: personalEventTypeSlug,
username: teamUser.username!,
organizationSlug: organization.slug!,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "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[0].id).toEqual(teamUser.id);
expect(data.hosts[0].username).toEqual(teamUser.username);
expect(data.hosts[0].email).toEqual(teamUser.email);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(personalEventTypeId);
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.location).toEqual(body.location);
expect(data.meetingUrl).toEqual(body.location);
expect(data.absentHost).toEqual(false);
expect(data.bookingFieldsResponses.email).toEqual(body.attendee.email);
createdPersonalBookingUsingUsername = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create a team booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: team1EventTypeId,
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(2030, 0, 8, 16, 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);
createdTeamBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
describe("get bookings", () => {
it("should get individual and team organization user bookings", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/users/${teamUser.id}/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined();
expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined();
expect(
data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id)
).toBeDefined();
});
});
});
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;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(teamUser.email);
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
await app.close();
});
});
});
@@ -0,0 +1,24 @@
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsUsersBookingsController } from "@/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller";
import { OrganizationUsersBookingsService } from "@/modules/organizations/users/bookings/services/organization-users-bookings.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [
BookingsModule_2024_08_13,
UsersModule,
PrismaModule,
StripeModule,
RedisModule,
MembershipsModule,
],
providers: [OrganizationUsersBookingsService, OrganizationsRepository],
controllers: [OrganizationsUsersBookingsController],
})
export class OrganizationsUsersBookingsModule {}
@@ -0,0 +1,18 @@
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { Injectable } from "@nestjs/common";
import { GetBookingsInput_2024_08_13 } from "@calcom/platform-types";
import { User } from "@calcom/prisma/client";
@Injectable()
export class OrganizationUsersBookingsService {
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
async getOrganizationUserBookings(
orgId: number,
user: Pick<User, "id" | "email">,
queryParams: GetBookingsInput_2024_08_13
) {
return await this.bookingsService.getBookings(queryParams, { orgId, id: user.id, email: user.email });
}
}
@@ -90,7 +90,6 @@ export class OrganizationsUsersController {
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Create a user" })
async createOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@GetOrg() org: Team,
@Body() input: CreateOrganizationUserInput,
@GetUser() inviter: UserWithProfile
@@ -118,7 +117,6 @@ export class OrganizationsUsersController {
async updateOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number,
@GetOrg() org: Team,
@Body() input: UpdateOrganizationUserInput
): Promise<GetOrganizationUserOutput> {
const user = await this.organizationsUsersService.updateUser(orgId, userId, input);
@@ -4,6 +4,7 @@ import { EmailService } from "@/modules/email/email.service";
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";
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
@@ -22,6 +23,9 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, EventType } from "@calcom/prisma/client";
describe("Organizations Users Endpoints", () => {
const bio = "I am a bio";
const metadata = { foo: "bar" };
describe("Member role", () => {
let app: INestApplication;
@@ -56,6 +60,8 @@ describe("Organizations Users Endpoints", () => {
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
bio,
metadata,
});
await profileRepositoryFixture.create({
@@ -170,6 +176,8 @@ describe("Organizations Users Endpoints", () => {
email: member.email,
username: member.username,
organization: { connect: { id: org.id } },
bio,
metadata,
})
)
);
@@ -197,6 +205,8 @@ describe("Organizations Users Endpoints", () => {
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
bio,
metadata,
});
await profileRepositoryFixture.create({
@@ -244,9 +254,31 @@ describe("Organizations Users Endpoints", () => {
{ userData },
userData.map((u) => u.profile)
);
expect(userData.find((u) => u.profile.username === orgMembersData[0].username)).toBeDefined();
expect(userData.find((u) => u.profile.username === orgMembersData[1].username)).toBeDefined();
expect(userData.find((u) => u.profile.username === orgMembersData[2].username)).toBeDefined();
// 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);
const member2 = userData.find((u) => u.profile.username === orgMembersData[2].username);
// Verify member 0
expect(member0).toBeDefined();
expect(member0?.email).toBe(orgMembersData[0].email);
expect(member0?.profile.username).toBe(orgMembersData[0].username);
expect(member0?.bio).toBe(bio);
expect(member0?.metadata).toEqual(metadata);
// Verify member 1
expect(member1).toBeDefined();
expect(member1?.email).toBe(orgMembersData[1].email);
expect(member1?.profile.username).toBe(orgMembersData[1].username);
expect(member1?.bio).toBe(bio);
expect(member1?.metadata).toEqual(metadata);
// Verify member 2
expect(member2).toBeDefined();
expect(member2?.email).toBe(orgMembersData[2].email);
expect(member2?.profile.username).toBe(orgMembersData[2].username);
expect(member2?.bio).toBe(bio);
expect(member2?.metadata).toEqual(metadata);
expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0);
});
@@ -265,8 +297,12 @@ describe("Organizations Users Endpoints", () => {
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(1);
expect(userData.filter((user) => user.email === userEmail).length).toBe(1);
expect(userData.find((u) => u.profile.username === user.username)).toBeDefined();
const foundUser = userData.find((u) => u.email === userEmail);
expect(foundUser).toBeDefined();
expect(foundUser?.email).toBe(userEmail);
expect(foundUser?.profile.username).toBe(user.username);
expect(foundUser?.bio).toBe(bio);
expect(foundUser?.metadata).toEqual(metadata);
});
it("should get users within the specified emails array", async () => {
@@ -285,8 +321,20 @@ describe("Organizations Users Endpoints", () => {
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(2);
expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
expect(userData.filter((user: { email: string }) => user.email === orgMemberEmail).length).toBe(1);
const adminUser = userData.find((u: GetOrgUsersWithProfileOutput) => u.email === userEmail);
const orgMember = userData.find((u: GetOrgUsersWithProfileOutput) => u.email === orgMemberEmail);
expect(adminUser).toBeDefined();
expect(adminUser?.email).toBe(userEmail);
expect(adminUser?.profile.username).toBe(user.username);
expect(adminUser?.bio).toBe(bio);
expect(adminUser?.metadata).toEqual(metadata);
expect(orgMember).toBeDefined();
expect(orgMember?.email).toBe(orgMemberEmail);
expect(orgMember?.profile.username).toBe(orgMembersData[0].username);
expect(orgMember?.bio).toBe(bio);
expect(orgMember?.metadata).toEqual(metadata);
});
it("should update an org user", async () => {
@@ -304,10 +352,10 @@ describe("Organizations Users Endpoints", () => {
});
it("should create a new org user", async () => {
const newOrgUser = {
const newOrgUser: CreateUserInput = {
email: `organizations-users-new-member-${randomString()}@api.com`,
organizationRole: "MEMBER",
autoAccept: true,
bio,
metadata,
};
const emailSpy = jest
@@ -315,15 +363,15 @@ describe("Organizations Users Endpoints", () => {
.mockImplementation(() => Promise.resolve());
const { body } = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users`)
.send({
email: newOrgUser.email,
})
.send(newOrgUser)
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.email).toBe(newOrgUser.email);
expect(userData.bio).toBe(newOrgUser.bio);
expect(userData.metadata).toEqual(newOrgUser.metadata);
expect(emailSpy).toHaveBeenCalledWith({
usernameOrEmail: newOrgUser.email,
orgName: org.name,
@@ -1,9 +1,7 @@
import { CreateOrganizationUserInput } from "@/modules/organizations/users/index/inputs/create-organization-user.input";
import { UpdateOrganizationUserInput } from "@/modules/organizations/users/index/inputs/update-organization-user.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { CreationSource } from "@prisma/client";
import { Prisma } from "@prisma/client";
@Injectable()
export class OrganizationsUsersRepository {
@@ -69,15 +67,7 @@ export class OrganizationsUsersRepository {
});
}
async createOrganizationUser(orgId: number, createUserBody: CreateOrganizationUserInput) {
const createdUser = await this.dbWrite.prisma.user.create({
data: { ...createUserBody, creationSource: CreationSource.API_V2 },
});
return createdUser;
}
async updateOrganizationUser(orgId: number, userId: number, updateUserBody: UpdateOrganizationUserInput) {
async updateOrganizationUser(orgId: number, userId: number, updateUserBody: Prisma.UserUpdateInput) {
return await this.dbWrite.prisma.user.update({
where: {
id: userId,
@@ -117,7 +117,7 @@ export class SlotsController_2024_09_04 {
name: "eventTypeSlug",
required: false,
description:
"The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.",
"The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too and if relevant organizationSlug too.",
example: "event-type-slug",
})
@ApiQuery({
@@ -126,6 +126,12 @@ export class SlotsController_2024_09_04 {
description: "The username of the user to get event types for.",
example: "bob",
})
@ApiQuery({
name: "organizationSlug",
required: false,
description: "The slug of the organization to which user with username belongs.",
example: "org-slug",
})
@ApiQuery({
name: "end",
required: true,
@@ -17,7 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookerLayoutsInputEnum_2024_06_14,
@@ -27,6 +27,7 @@ import {
import {
ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
Host,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
@@ -416,6 +417,40 @@ describe("Organizations Event Types Endpoints", () => {
});
});
it("managed team event types should be returned when fetching event types of users", async () => {
return request(app.getHttpServer())
.get(`/v2/event-types?username=${teamMember1.username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].slug).toEqual(managedEventType.slug);
expect(data[0].ownerId).toEqual(teamMember1.id);
expect(data[0].id).not.toEqual(managedEventType.id);
});
});
it("managed team event type should be returned when fetching event types of users", async () => {
return request(app.getHttpServer())
.get(`/v2/event-types?username=${teamMember1.username}&eventSlug=${managedEventType.slug}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].slug).toEqual(managedEventType.slug);
expect(data[0].ownerId).toEqual(teamMember1.id);
expect(data[0].id).not.toEqual(managedEventType.id);
});
});
it("should not get a non existing event-type", async () => {
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/event-types/999999`).expect(404);
});
@@ -1,8 +1,5 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
@@ -63,6 +60,11 @@ describe("Teams Memberships Endpoints", () => {
let teammateInvitedViaApi: User;
const metadata = {
some: "key",
};
const bio = "This is a bio";
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamAdminEmail,
@@ -82,18 +84,27 @@ describe("Teams Memberships Endpoints", () => {
teamAdmin = await userRepositoryFixture.create({
email: teamAdminEmail,
username: teamAdminEmail,
bio,
metadata,
});
teamMember = await userRepositoryFixture.create({
email: teamMemberEmail,
username: teamMemberEmail,
bio,
metadata,
});
nonTeamUser = await userRepositoryFixture.create({
email: nonTeamUserEmail,
username: nonTeamUserEmail,
});
teammateInvitedViaApi = await userRepositoryFixture.create({
email: invitedUserEmail,
username: invitedUserEmail,
bio,
metadata,
});
team = await teamsRepositoryFixture.create({
@@ -185,6 +196,20 @@ describe("Teams Memberships Endpoints", () => {
expect(responseBody.data.length).toEqual(2);
expect(responseBody.data[0].id).toEqual(teamAdminMembership.id);
expect(responseBody.data[1].id).toEqual(teamMemberMembership.id);
expect(responseBody.data[0].teamId).toEqual(team.id);
expect(responseBody.data[1].teamId).toEqual(team.id);
expect(responseBody.data[0].userId).toEqual(teamAdmin.id);
expect(responseBody.data[1].userId).toEqual(teamMember.id);
expect(responseBody.data[0].role).toEqual("ADMIN");
expect(responseBody.data[1].role).toEqual("MEMBER");
expect(responseBody.data[0].user.bio).toEqual(teamAdmin.bio);
expect(responseBody.data[1].user.bio).toEqual(teamMember.bio);
expect(responseBody.data[0].user.metadata).toEqual(teamAdmin.metadata);
expect(responseBody.data[1].user.metadata).toEqual(teamMember.metadata);
expect(responseBody.data[0].user.email).toEqual(teamAdmin.email);
expect(responseBody.data[1].user.email).toEqual(teamMember.email);
expect(responseBody.data[0].user.username).toEqual(teamAdmin.username);
expect(responseBody.data[1].user.username).toEqual(teamMember.username);
});
});
@@ -201,7 +226,13 @@ describe("Teams Memberships Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(teamMemberMembership.id);
expect(responseBody.data[0].userId).toEqual(teamMember.id);
expect(responseBody.data[0].role).toEqual("MEMBER");
expect(responseBody.data[0].user.bio).toEqual(teamMember.bio);
expect(responseBody.data[0].user.metadata).toEqual(teamMember.metadata);
expect(responseBody.data[0].user.email).toEqual(teamMember.email);
expect(responseBody.data[0].user.username).toEqual(teamMember.username);
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].teamId).toEqual(team.id);
});
});
@@ -214,6 +245,11 @@ describe("Teams Memberships Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(teamAdminMembership.id);
expect(responseBody.data.userId).toEqual(teamAdmin.id);
expect(responseBody.data.role).toEqual("ADMIN");
expect(responseBody.data.user.bio).toEqual(teamAdmin.bio);
expect(responseBody.data.user.metadata).toEqual(teamAdmin.metadata);
expect(responseBody.data.user.email).toEqual(teamAdmin.email);
expect(responseBody.data.user.username).toEqual(teamAdmin.username);
});
});
@@ -234,6 +270,10 @@ describe("Teams Memberships Endpoints", () => {
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.teamId).toEqual(team.id);
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.user.bio).toEqual(teammateInvitedViaApi.bio);
expect(membershipCreatedViaApi.user.metadata).toEqual(teammateInvitedViaApi.metadata);
expect(membershipCreatedViaApi.user.email).toEqual(teammateInvitedViaApi.email);
expect(membershipCreatedViaApi.user.username).toEqual(teammateInvitedViaApi.username);
expect(membershipCreatedViaApi.userId).toEqual(teammateInvitedViaApi.id);
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
});
@@ -265,6 +305,10 @@ describe("Teams Memberships Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.role).toEqual("OWNER");
expect(membershipCreatedViaApi.user.bio).toEqual(teammateInvitedViaApi.bio);
expect(membershipCreatedViaApi.user.metadata).toEqual(teammateInvitedViaApi.metadata);
expect(membershipCreatedViaApi.user.email).toEqual(teammateInvitedViaApi.email);
expect(membershipCreatedViaApi.user.username).toEqual(teammateInvitedViaApi.username);
});
});
@@ -273,7 +317,7 @@ describe("Teams Memberships Endpoints", () => {
.delete(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
});
@@ -1,7 +1,53 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
import { Expose, Transform, Type } from "class-transformer";
import { IsBoolean, IsInt, IsObject, IsOptional, IsString, ValidateNested } from "class-validator";
class MembershipUserOutputDto {
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly avatarUrl?: string;
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly username?: string;
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly name?: string;
@IsBoolean()
@Expose()
@ApiProperty()
readonly email!: string;
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly bio?: string;
@ApiPropertyOptional({
type: Object,
example: { key: "value" },
})
@IsObject()
@IsOptional()
@Expose()
@Transform(
// note(Lauris): added this transform because without it metadata is removed for some reason
({ obj }: { obj: { metadata: Record<string, unknown> | null | undefined } }) => {
return obj.metadata || undefined;
}
)
metadata?: Record<string, unknown>;
}
export class TeamMembershipOutput {
@IsInt()
@@ -34,4 +80,10 @@ export class TeamMembershipOutput {
@Expose()
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
@ValidateNested()
@Type(() => MembershipUserOutputDto)
@Expose()
@ApiProperty({ type: MembershipUserOutputDto })
user!: MembershipUserOutputDto;
}
@@ -4,6 +4,17 @@ import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/cr
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
export const MembershipUserSelect: Prisma.UserSelect = {
username: true,
email: true,
avatarUrl: true,
name: true,
metadata: true,
bio: true,
};
@Injectable()
export class TeamsMembershipsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -11,7 +22,7 @@ export class TeamsMembershipsRepository {
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
});
}
@@ -20,7 +31,7 @@ export class TeamsMembershipsRepository {
where: {
teamId: teamId,
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
skip,
take,
});
@@ -32,7 +43,7 @@ export class TeamsMembershipsRepository {
id: membershipId,
teamId: teamId,
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
});
}
@@ -63,7 +74,7 @@ export class TeamsMembershipsRepository {
id: membershipId,
teamId: teamId,
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
});
}
}
@@ -1,7 +1,9 @@
import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl } from "class-validator";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator";
import { ValidateMetadata } from "@calcom/platform-types";
export type WeekDay = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
export type TimeFormat = 12 | 24;
@@ -51,4 +53,27 @@ export class CreateManagedUserInput {
description: `URL of the user's avatar image`,
})
avatarUrl?: string;
@ApiPropertyOptional({
type: String,
description: "Bio",
example: "I am a bio",
})
@IsOptional()
@IsString()
bio?: string;
@ApiPropertyOptional({
type: Object,
description:
"You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
example: { key: "value" },
})
@IsObject()
@IsOptional()
@ValidateMetadata({
message:
"Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
})
metadata?: Record<string, string | boolean | number>;
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Transform } from "class-transformer";
import {
IsBoolean,
@@ -9,8 +9,11 @@ import {
IsString,
Validate,
Min,
IsObject,
} from "class-validator";
import { ValidateMetadata } from "@calcom/platform-types";
import { AvatarValidator } from "../validators/avatarValidator";
import { LocaleValidator } from "../validators/localeValidator";
import { ThemeValidator } from "../validators/themeValidator";
@@ -58,6 +61,37 @@ export class CreateUserInput {
@Expose()
brandColor?: string;
@ApiPropertyOptional({
type: String,
description: "Bio",
example: "I am a bio",
})
@IsOptional()
@IsString()
@Expose()
bio?: string;
@ApiPropertyOptional({
type: Object,
description:
"You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
example: { key: "value" },
})
@IsObject()
@IsOptional()
@ValidateMetadata({
message:
"Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
})
@Expose()
@Transform(
// note(Lauris): added this transform because without it metadata is removed for some reason
({ obj }: { obj: { metadata: Record<string, unknown> | null | undefined } }) => {
return obj.metadata || undefined;
}
)
metadata?: Record<string, string | boolean | number>;
@ApiProperty({
type: String,
required: false,
@@ -2,7 +2,9 @@ import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
import { IsEnum, IsIn, IsNumber, IsObject, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
import { ValidateMetadata } from "@calcom/platform-types";
export class UpdateManagedUserInput {
@IsString()
@@ -53,4 +55,27 @@ export class UpdateManagedUserInput {
description: `URL of the user's avatar image`,
})
avatarUrl?: string;
@ApiPropertyOptional({
type: String,
description: "Bio",
example: "I am a bio",
})
@IsOptional()
@IsString()
bio?: string;
@ApiPropertyOptional({
type: Object,
description:
"You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
example: { key: "value" },
})
@IsObject()
@IsOptional()
@ValidateMetadata({
message:
"Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
})
metadata?: Record<string, string | boolean | number>;
}
@@ -1,7 +1,16 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { Expose } from "class-transformer";
import { IsBoolean, IsDateString, IsInt, IsString, ValidateNested, IsArray } from "class-validator";
import {
IsBoolean,
IsDateString,
IsInt,
IsString,
ValidateNested,
IsArray,
IsObject,
IsOptional,
} from "class-validator";
export class GetUserOutput {
@IsInt()
@@ -223,6 +232,21 @@ export class GetUserOutput {
example: 1,
})
invitedTo!: number | null;
@ApiPropertyOptional({
type: Object,
example: { key: "value" },
})
@IsObject()
@IsOptional()
@Expose()
@Transform(
// note(Lauris): added this transform because without it metadata is removed for some reason
({ obj }: { obj: { metadata: Record<string, unknown> | null | undefined } }) => {
return obj.metadata || undefined;
}
)
metadata?: Record<string, string | boolean | number>;
}
export class GetUsersOutput {
+394 -119
View File
@@ -4106,14 +4106,6 @@
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
@@ -4283,6 +4275,246 @@
]
}
},
"/v2/organizations/{orgId}/users/{userId}/bookings": {
"get": {
"operationId": "OrganizationsUsersBookingsController_getOrganizationUserBookings",
"summary": "Get all bookings of an organization user",
"parameters": [
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "status",
"required": false,
"in": "query",
"description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.",
"example": "?status=upcoming,past",
"schema": {
"type": "array",
"items": {
"type": "string",
"enum": [
"upcoming",
"recurring",
"past",
"cancelled",
"unconfirmed"
]
}
}
},
{
"name": "attendeeEmail",
"required": false,
"in": "query",
"description": "Filter bookings by the attendee's email address.",
"example": "example@domain.com",
"schema": {
"type": "string"
}
},
{
"name": "attendeeName",
"required": false,
"in": "query",
"description": "Filter bookings by the attendee's name.",
"example": "John Doe",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
"in": "query",
"description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.",
"example": "?eventTypeIds=100,200",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeId",
"required": false,
"in": "query",
"description": "Filter bookings by event type id belonging to the user.",
"example": "?eventTypeId=100",
"schema": {
"type": "string"
}
},
{
"name": "teamsIds",
"required": false,
"in": "query",
"description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.",
"example": "?teamIds=50,60",
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": false,
"in": "query",
"description": "Filter bookings by team id that user is part of",
"example": "?teamId=50",
"schema": {
"type": "string"
}
},
{
"name": "afterStart",
"required": false,
"in": "query",
"description": "Filter bookings with start after this date string.",
"example": "?afterStart=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeEnd",
"required": false,
"in": "query",
"description": "Filter bookings with end before this date string.",
"example": "?beforeEnd=2025-03-07T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "afterCreatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been created after this date string.",
"example": "?afterCreatedAt=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeCreatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been created before this date string.",
"example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "afterUpdatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been updated after this date string.",
"example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeUpdatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been updated before this date string.",
"example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "sortStart",
"required": false,
"in": "query",
"description": "Sort results by their start time in ascending or descending order.",
"example": "?sortStart=asc OR ?sortStart=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "sortEnd",
"required": false,
"in": "query",
"description": "Sort results by their end time in ascending or descending order.",
"example": "?sortEnd=asc OR ?sortEnd=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "sortCreated",
"required": false,
"in": "query",
"description": "Sort results by their creation time (when booking was made) in ascending or descending order.",
"example": "?sortCreated=asc OR ?sortCreated=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "sortUpdatedAt",
"required": false,
"in": "query",
"description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.",
"example": "?sortUpdated=asc OR ?sortUpdated=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Orgs / Users / Bookings"
]
}
},
"/v2/organizations/{orgId}/users/{userId}/ooo": {
"get": {
"operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO",
@@ -5367,7 +5599,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 ",
"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 ",
"parameters": [
{
"name": "cal-api-version",
@@ -8476,6 +8708,14 @@
"example": "2050-09-06",
"schema": {}
},
{
"name": "organizationSlug",
"required": false,
"in": "query",
"description": "The slug of the organization to which user with username belongs.",
"example": "org-slug",
"schema": {}
},
{
"name": "username",
"required": false,
@@ -8488,7 +8728,7 @@
"name": "eventTypeSlug",
"required": false,
"in": "query",
"description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.",
"description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too and if relevant organizationSlug too.",
"example": "event-type-slug",
"schema": {}
},
@@ -9965,6 +10205,11 @@
"nullable": true,
"example": "alice"
},
"bio": {
"type": "string",
"nullable": true,
"example": "bio"
},
"timeZone": {
"type": "string",
"example": "America/New_York"
@@ -10040,6 +10285,12 @@
"nullable": true,
"example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
"description": "URL of the user's avatar image"
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
}
},
"required": [
@@ -10047,6 +10298,7 @@
"email",
"username",
"name",
"bio",
"timeZone",
"weekStart",
"createdDate",
@@ -10168,6 +10420,18 @@
"type": "string",
"example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
"description": "URL of the user's avatar image"
},
"bio": {
"type": "string",
"description": "Bio",
"example": "I am a bio"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
"example": {
"key": "value"
}
}
},
"required": [
@@ -10336,6 +10600,18 @@
"type": "string",
"example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
"description": "URL of the user's avatar image"
},
"bio": {
"type": "string",
"description": "Bio",
"example": "I am a bio"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
"example": {
"key": "value"
}
}
}
},
@@ -15248,6 +15524,12 @@
"description": "The ID of the user who invited this user",
"example": 1
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
},
"profile": {
"description": "organization user profile, contains user data within the organizaton context",
"allOf": [
@@ -15313,6 +15595,18 @@
"description": "Brand color in HEX format",
"example": "#FFFFFF"
},
"bio": {
"type": "string",
"description": "Bio",
"example": "I am a bio"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
"example": {
"key": "value"
}
},
"darkBrandColor": {
"type": "string",
"description": "Dark brand color in HEX format",
@@ -15405,7 +15699,36 @@
"type": "object",
"properties": {}
},
"OrgMembershipOutputDto": {
"MembershipUserOutputDto": {
"type": "object",
"properties": {
"avatarUrl": {
"type": "string"
},
"username": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
},
"bio": {
"type": "string"
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
}
},
"required": [
"email"
]
},
"TeamMembershipOutput": {
"type": "object",
"properties": {
"id": {
@@ -15430,6 +15753,9 @@
},
"disableImpersonation": {
"type": "boolean"
},
"user": {
"$ref": "#/components/schemas/MembershipUserOutputDto"
}
},
"required": [
@@ -15437,7 +15763,8 @@
"userId",
"teamId",
"accepted",
"role"
"role",
"user"
]
},
"GetAllOrgMemberships": {
@@ -15452,7 +15779,7 @@
]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
@@ -15502,7 +15829,7 @@
]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
@@ -15522,7 +15849,7 @@
]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
@@ -15542,7 +15869,7 @@
]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
@@ -15581,7 +15908,7 @@
]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
@@ -16792,65 +17119,6 @@
"data"
]
},
"MembershipUserOutputDto": {
"type": "object",
"properties": {
"avatarUrl": {
"type": "string"
},
"username": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
}
},
"required": [
"email"
]
},
"OrgTeamMembershipOutputDto": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"userId": {
"type": "number"
},
"teamId": {
"type": "number"
},
"accepted": {
"type": "boolean"
},
"role": {
"type": "string",
"enum": [
"MEMBER",
"OWNER",
"ADMIN"
]
},
"disableImpersonation": {
"type": "boolean"
},
"user": {
"$ref": "#/components/schemas/MembershipUserOutputDto"
}
},
"required": [
"id",
"userId",
"teamId",
"accepted",
"role",
"user"
]
},
"OrgTeamMembershipsOutputResponseDto": {
"type": "object",
"properties": {
@@ -16865,7 +17133,7 @@
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgTeamMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
}
},
@@ -16886,7 +17154,7 @@
]
},
"data": {
"$ref": "#/components/schemas/OrgTeamMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
@@ -19256,9 +19524,24 @@
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
"description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
"example": 123
},
"eventTypeSlug": {
"type": "string",
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
"example": "my-event-type"
},
"username": {
"type": "string",
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
"example": "john-doe"
},
"organizationSlug": {
"type": "string",
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
"example": "acme-corp"
},
"attendee": {
"description": "The attendee's details.",
"allOf": [
@@ -19330,7 +19613,6 @@
},
"required": [
"start",
"eventTypeId",
"attendee"
]
},
@@ -19349,9 +19631,24 @@
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
"description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
"example": 123
},
"eventTypeSlug": {
"type": "string",
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
"example": "my-event-type"
},
"username": {
"type": "string",
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
"example": "john-doe"
},
"organizationSlug": {
"type": "string",
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
"example": "acme-corp"
},
"attendee": {
"description": "The attendee's details.",
"allOf": [
@@ -19428,7 +19725,6 @@
},
"required": [
"start",
"eventTypeId",
"attendee",
"instant"
]
@@ -19448,9 +19744,24 @@
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
"description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
"example": 123
},
"eventTypeSlug": {
"type": "string",
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
"example": "my-event-type"
},
"username": {
"type": "string",
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
"example": "john-doe"
},
"organizationSlug": {
"type": "string",
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
"example": "acme-corp"
},
"attendee": {
"description": "The attendee's details.",
"allOf": [
@@ -19527,7 +19838,6 @@
},
"required": [
"start",
"eventTypeId",
"attendee"
]
},
@@ -21035,41 +21345,6 @@
"userId"
]
},
"TeamMembershipOutput": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"userId": {
"type": "number"
},
"teamId": {
"type": "number"
},
"accepted": {
"type": "boolean"
},
"role": {
"type": "string",
"enum": [
"MEMBER",
"OWNER",
"ADMIN"
]
},
"disableImpersonation": {
"type": "boolean"
}
},
"required": [
"id",
"userId",
"teamId",
"accepted",
"role"
]
},
"CreateTeamMembershipOutput": {
"type": "object",
"properties": {
+374 -96
View File
@@ -3951,14 +3951,6 @@
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
@@ -4122,6 +4114,226 @@
"tags": ["Orgs / Users"]
}
},
"/v2/organizations/{orgId}/users/{userId}/bookings": {
"get": {
"operationId": "OrganizationsUsersBookingsController_getOrganizationUserBookings",
"summary": "Get all bookings of an organization user",
"parameters": [
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "status",
"required": false,
"in": "query",
"description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.",
"example": "?status=upcoming,past",
"schema": {
"type": "array",
"items": {
"type": "string",
"enum": ["upcoming", "recurring", "past", "cancelled", "unconfirmed"]
}
}
},
{
"name": "attendeeEmail",
"required": false,
"in": "query",
"description": "Filter bookings by the attendee's email address.",
"example": "example@domain.com",
"schema": {
"type": "string"
}
},
{
"name": "attendeeName",
"required": false,
"in": "query",
"description": "Filter bookings by the attendee's name.",
"example": "John Doe",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
"in": "query",
"description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.",
"example": "?eventTypeIds=100,200",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeId",
"required": false,
"in": "query",
"description": "Filter bookings by event type id belonging to the user.",
"example": "?eventTypeId=100",
"schema": {
"type": "string"
}
},
{
"name": "teamsIds",
"required": false,
"in": "query",
"description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.",
"example": "?teamIds=50,60",
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": false,
"in": "query",
"description": "Filter bookings by team id that user is part of",
"example": "?teamId=50",
"schema": {
"type": "string"
}
},
{
"name": "afterStart",
"required": false,
"in": "query",
"description": "Filter bookings with start after this date string.",
"example": "?afterStart=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeEnd",
"required": false,
"in": "query",
"description": "Filter bookings with end before this date string.",
"example": "?beforeEnd=2025-03-07T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "afterCreatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been created after this date string.",
"example": "?afterCreatedAt=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeCreatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been created before this date string.",
"example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "afterUpdatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been updated after this date string.",
"example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "beforeUpdatedAt",
"required": false,
"in": "query",
"description": "Filter bookings that have been updated before this date string.",
"example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z",
"schema": {
"type": "string"
}
},
{
"name": "sortStart",
"required": false,
"in": "query",
"description": "Sort results by their start time in ascending or descending order.",
"example": "?sortStart=asc OR ?sortStart=desc",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "sortEnd",
"required": false,
"in": "query",
"description": "Sort results by their end time in ascending or descending order.",
"example": "?sortEnd=asc OR ?sortEnd=desc",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "sortCreated",
"required": false,
"in": "query",
"description": "Sort results by their creation time (when booking was made) in ascending or descending order.",
"example": "?sortCreated=asc OR ?sortCreated=desc",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "sortUpdatedAt",
"required": false,
"in": "query",
"description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.",
"example": "?sortUpdated=asc OR ?sortUpdated=desc",
"schema": {
"enum": ["asc", "desc"],
"type": "string"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": ["Orgs / Users / Bookings"]
}
},
"/v2/organizations/{orgId}/users/{userId}/ooo": {
"get": {
"operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO",
@@ -5162,7 +5374,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 ",
"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 ",
"parameters": [
{
"name": "cal-api-version",
@@ -8081,6 +8293,14 @@
"example": "2050-09-06",
"schema": {}
},
{
"name": "organizationSlug",
"required": false,
"in": "query",
"description": "The slug of the organization to which user with username belongs.",
"example": "org-slug",
"schema": {}
},
{
"name": "username",
"required": false,
@@ -8093,7 +8313,7 @@
"name": "eventTypeSlug",
"required": false,
"in": "query",
"description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.",
"description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too and if relevant organizationSlug too.",
"example": "event-type-slug",
"schema": {}
},
@@ -9510,6 +9730,11 @@
"nullable": true,
"example": "alice"
},
"bio": {
"type": "string",
"nullable": true,
"example": "bio"
},
"timeZone": {
"type": "string",
"example": "America/New_York"
@@ -9585,6 +9810,12 @@
"nullable": true,
"example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
"description": "URL of the user's avatar image"
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
}
},
"required": [
@@ -9592,6 +9823,7 @@
"email",
"username",
"name",
"bio",
"timeZone",
"weekStart",
"createdDate",
@@ -9696,6 +9928,18 @@
"type": "string",
"example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
"description": "URL of the user's avatar image"
},
"bio": {
"type": "string",
"description": "Bio",
"example": "I am a bio"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
"example": {
"key": "value"
}
}
},
"required": ["email", "name"]
@@ -9832,6 +10076,18 @@
"type": "string",
"example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
"description": "URL of the user's avatar image"
},
"bio": {
"type": "string",
"description": "Bio",
"example": "I am a bio"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
"example": {
"key": "value"
}
}
}
},
@@ -14101,6 +14357,12 @@
"description": "The ID of the user who invited this user",
"example": 1
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
},
"profile": {
"description": "organization user profile, contains user data within the organizaton context",
"allOf": [
@@ -14152,6 +14414,18 @@
"description": "Brand color in HEX format",
"example": "#FFFFFF"
},
"bio": {
"type": "string",
"description": "Bio",
"example": "I am a bio"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.",
"example": {
"key": "value"
}
},
"darkBrandColor": {
"type": "string",
"description": "Dark brand color in HEX format",
@@ -14232,7 +14506,34 @@
"type": "object",
"properties": {}
},
"OrgMembershipOutputDto": {
"MembershipUserOutputDto": {
"type": "object",
"properties": {
"avatarUrl": {
"type": "string"
},
"username": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
},
"bio": {
"type": "string"
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
}
},
"required": ["email"]
},
"TeamMembershipOutput": {
"type": "object",
"properties": {
"id": {
@@ -14253,9 +14554,12 @@
},
"disableImpersonation": {
"type": "boolean"
},
"user": {
"$ref": "#/components/schemas/MembershipUserOutputDto"
}
},
"required": ["id", "userId", "teamId", "accepted", "role"]
"required": ["id", "userId", "teamId", "accepted", "role", "user"]
},
"GetAllOrgMemberships": {
"type": "object",
@@ -14266,7 +14570,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": ["status", "data"]
@@ -14303,7 +14607,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": ["status", "data"]
@@ -14317,7 +14621,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": ["status", "data"]
@@ -14331,7 +14635,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": ["status", "data"]
@@ -14360,7 +14664,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/OrgMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": ["status", "data"]
@@ -15477,52 +15781,6 @@
},
"required": ["status", "data"]
},
"MembershipUserOutputDto": {
"type": "object",
"properties": {
"avatarUrl": {
"type": "string"
},
"username": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
}
},
"required": ["email"]
},
"OrgTeamMembershipOutputDto": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"userId": {
"type": "number"
},
"teamId": {
"type": "number"
},
"accepted": {
"type": "boolean"
},
"role": {
"type": "string",
"enum": ["MEMBER", "OWNER", "ADMIN"]
},
"disableImpersonation": {
"type": "boolean"
},
"user": {
"$ref": "#/components/schemas/MembershipUserOutputDto"
}
},
"required": ["id", "userId", "teamId", "accepted", "role", "user"]
},
"OrgTeamMembershipsOutputResponseDto": {
"type": "object",
"properties": {
@@ -15534,7 +15792,7 @@
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OrgTeamMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
}
},
@@ -15549,7 +15807,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/OrgTeamMembershipOutputDto"
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": ["status", "data"]
@@ -17535,9 +17793,24 @@
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
"description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
"example": 123
},
"eventTypeSlug": {
"type": "string",
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
"example": "my-event-type"
},
"username": {
"type": "string",
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
"example": "john-doe"
},
"organizationSlug": {
"type": "string",
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
"example": "acme-corp"
},
"attendee": {
"description": "The attendee's details.",
"allOf": [
@@ -17604,7 +17877,7 @@
}
}
},
"required": ["start", "eventTypeId", "attendee"]
"required": ["start", "attendee"]
},
"CreateInstantBookingInput_2024_08_13": {
"type": "object",
@@ -17621,9 +17894,24 @@
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
"description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
"example": 123
},
"eventTypeSlug": {
"type": "string",
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
"example": "my-event-type"
},
"username": {
"type": "string",
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
"example": "john-doe"
},
"organizationSlug": {
"type": "string",
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
"example": "acme-corp"
},
"attendee": {
"description": "The attendee's details.",
"allOf": [
@@ -17695,7 +17983,7 @@
"example": true
}
},
"required": ["start", "eventTypeId", "attendee", "instant"]
"required": ["start", "attendee", "instant"]
},
"CreateRecurringBookingInput_2024_08_13": {
"type": "object",
@@ -17712,9 +18000,24 @@
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
"description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
"example": 123
},
"eventTypeSlug": {
"type": "string",
"description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
"example": "my-event-type"
},
"username": {
"type": "string",
"description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
"example": "john-doe"
},
"organizationSlug": {
"type": "string",
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
"example": "acme-corp"
},
"attendee": {
"description": "The attendee's details.",
"allOf": [
@@ -17786,7 +18089,7 @@
"example": 5
}
},
"required": ["start", "eventTypeId", "attendee"]
"required": ["start", "attendee"]
},
"BookingHost": {
"type": "object",
@@ -19175,31 +19478,6 @@
},
"required": ["userId"]
},
"TeamMembershipOutput": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"userId": {
"type": "number"
},
"teamId": {
"type": "number"
},
"accepted": {
"type": "boolean"
},
"role": {
"type": "string",
"enum": ["MEMBER", "OWNER", "ADMIN"]
},
"disableImpersonation": {
"type": "boolean"
}
},
"required": ["id", "userId", "teamId", "accepted", "role"]
},
"CreateTeamMembershipOutput": {
"type": "object",
"properties": {
@@ -36,6 +36,34 @@ import {
} from "./location.input";
import { ValidateMetadata } from "./validators/validate-metadata";
function RequireEventTypeIdentification(validationOptions?: ValidationOptions) {
return function (object: any) {
registerDecorator({
name: "requireEventTypeIdentification",
target: object,
propertyName: "eventTypeId or eventTypeSlug + username",
options: validationOptions,
constraints: [],
validator: {
validate(_value: any, args: ValidationArguments) {
const obj = args.object as CreateBookingInput_2024_08_13;
// Check if eventTypeId is provided
const hasEventTypeId = !!obj?.eventTypeId;
// Check if we have both eventTypeSlug and username
const hasSlugAndUsername = !!obj?.eventTypeSlug && !!obj?.username;
return hasEventTypeId || hasSlugAndUsername;
},
defaultMessage(): string {
return "Either eventTypeId OR (eventTypeSlug + username) must be provided";
},
},
});
};
}
function RequireEmailOrPhone(validationOptions?: ValidationOptions) {
return function (target: object, propertyName: string) {
registerDecorator({
@@ -121,6 +149,7 @@ class Attendee {
BookingInputOrganizersDefaultAppLocation_2024_08_13,
ValidateBookingLocation_2024_08_13
)
@RequireEventTypeIdentification()
export class CreateBookingInput_2024_08_13 {
@ApiProperty({
type: String,
@@ -140,13 +169,44 @@ export class CreateBookingInput_2024_08_13 {
})
lengthInMinutes?: number;
@ApiProperty({
@ApiPropertyOptional({
type: Number,
description: "The ID of the event type that is booked.",
description:
"The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.",
example: 123,
})
@IsOptional()
@IsInt()
eventTypeId!: number;
eventTypeId?: number;
@ApiPropertyOptional({
type: String,
description:
"The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.",
example: "my-event-type",
})
@IsOptional()
@IsString()
eventTypeSlug?: string;
@ApiPropertyOptional({
type: String,
description:
"The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.",
example: "john-doe",
})
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({
type: String,
description: "The organization slug. Optional, only used when booking with eventTypeSlug + username.",
example: "acme-corp",
})
@IsOptional()
@IsString()
organizationSlug?: string;
@ApiProperty({
type: Attendee,