Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95c48ac805 | ||
|
|
59ab38db98 | ||
|
|
11a41e0e39 | ||
|
|
880fe151c0 | ||
|
|
603f106389 | ||
|
|
5c7f1aebb2 | ||
|
|
a8fe3a6466 | ||
|
|
8974f8f9de | ||
|
|
a11c2cc52c | ||
|
|
14d319f992 | ||
|
|
49f0759f31 | ||
|
|
cae8470350 | ||
|
|
a82d616b79 | ||
|
|
515f3263e0 | ||
|
|
aebbc50ba6 | ||
|
|
7cdc78d02d | ||
|
|
df55dec553 | ||
|
|
398ad22868 | ||
|
|
7ef1050946 | ||
|
|
7b8bbaaa86 | ||
|
|
ddfd3fda70 | ||
|
|
5972a6f77f | ||
|
|
9bc703a437 | ||
|
|
15aaf7f51b | ||
|
|
c89a1877ae | ||
|
|
15d2c0f858 | ||
|
|
43a6663230 | ||
|
|
58315e96c9 | ||
|
|
3e466022b8 | ||
|
|
68c036cd0a | ||
|
|
77b656024a | ||
|
|
89772a0dac | ||
|
|
381c462660 | ||
|
|
417acdaacb | ||
|
|
1584dd3179 | ||
|
|
d66f983f56 | ||
|
|
b1f50fe22d | ||
|
|
ce995c39f2 | ||
|
|
b99ea16b31 | ||
|
|
574d8882f4 |
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
@@ -3,7 +3,7 @@ import utc from "dayjs/plugin/utc";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.172",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.173",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
@@ -46,6 +46,7 @@
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||
"@nestjs/axios": "^4.0.0",
|
||||
"@nestjs/bull": "^10.1.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
|
||||
@@ -19,9 +19,14 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -38,6 +43,9 @@ import { Module } from "@nestjs/common";
|
||||
SchedulesModule_2024_04_15,
|
||||
EventTypesModule_2024_04_15,
|
||||
EventTypesModule_2024_06_14,
|
||||
StripeModule,
|
||||
TeamsModule,
|
||||
TeamsEventTypesModule,
|
||||
],
|
||||
providers: [
|
||||
TokensRepository,
|
||||
@@ -57,6 +65,8 @@ import { Module } from "@nestjs/common";
|
||||
AppsRepository,
|
||||
CalendarsRepository,
|
||||
SelectedCalendarsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsRepository,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
|
||||
@@ -100,9 +100,13 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
||||
|
||||
Finally, there are 2 ways to book an event type:
|
||||
Finally, there are 2 ways to book an event type belonging to an individual user:
|
||||
1. Provide \`eventTypeId\` in the request body.
|
||||
2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization.
|
||||
|
||||
And 2 ways to book and event type belonging to a team:
|
||||
1. Provide \`eventTypeId\` in the request body.
|
||||
2. Provide \`eventTypeSlug\` and \`teamSlug\` and optionally \`organizationSlug\` if the team with the teamSlug is within an organization.
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
|
||||
@@ -39,7 +39,10 @@ import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Team bookings", () => {
|
||||
let app: INestApplication;
|
||||
const organizationSlug = `team-bookings-organization-${randomString()}`;
|
||||
let organization: Team;
|
||||
const team1Slug = `team-bookings-team1-${randomString()}`;
|
||||
const team2Slug = `team-bookings-team2-${randomString()}`;
|
||||
let team1: Team;
|
||||
let team2: Team;
|
||||
|
||||
@@ -95,12 +98,15 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `team-bookings-organization-${randomString()}`,
|
||||
name: organizationSlug,
|
||||
slug: organizationSlug,
|
||||
isOrganization: true,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
team1 = await teamRepositoryFixture.create({
|
||||
name: `team-bookings-team1-${randomString()}`,
|
||||
name: team1Slug,
|
||||
slug: team1Slug,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
createdByOAuthClient: {
|
||||
@@ -111,7 +117,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
|
||||
team2 = await teamRepositoryFixture.create({
|
||||
name: `team-bookings-team2-${randomString()}`,
|
||||
name: team2Slug,
|
||||
slug: team2Slug,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
createdByOAuthClient: {
|
||||
@@ -668,6 +675,84 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("book using teamSlug, eventTypeSlug and organizationSlug", () => {
|
||||
it("should not be able to book if missing organizationSlug", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
|
||||
teamSlug: team1Slug,
|
||||
eventTypeSlug: team1EventTypeSlug,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Madrid",
|
||||
language: "es",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should book using teamSlug and eventTypeSlug and organizationSlug", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
|
||||
teamSlug: team1Slug,
|
||||
eventTypeSlug: team1EventTypeSlug,
|
||||
organizationSlug: organizationSlug,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Madrid",
|
||||
language: "es",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(teamUser.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(team1EventTypeId);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "@calcom/platform-constants";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
@@ -1692,11 +1693,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(
|
||||
response.body.error.message.includes(
|
||||
"Either eventTypeId OR (eventTypeSlug + username) must be provided"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(response.body.error.message.includes(FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should create a booking by username and event type slug", async () => {
|
||||
|
||||
@@ -8,7 +8,11 @@ import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
@@ -77,7 +81,11 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput) {
|
||||
@@ -94,6 +102,16 @@ export class BookingsService_2024_08_13 {
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||
}
|
||||
|
||||
@@ -141,13 +159,37 @@ export class BookingsService_2024_08_13 {
|
||||
user.id,
|
||||
body.eventTypeSlug
|
||||
);
|
||||
} else if (body.teamSlug && body.eventTypeSlug) {
|
||||
const team = await this.getBookedEventTypeTeam(body.teamSlug, body.organizationSlug);
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team with slug ${body.teamSlug} not found`);
|
||||
}
|
||||
return await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlugWithOwnerAndTeam(
|
||||
team.id,
|
||||
body.eventTypeSlug
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getBookedEventTypeTeam(teamSlug: string, organizationSlug: string | undefined) {
|
||||
if (!organizationSlug) {
|
||||
return await this.teamsRepository.findTeamBySlug(teamSlug);
|
||||
}
|
||||
|
||||
const organization = await this.organizationsRepository.findOrgBySlug(organizationSlug);
|
||||
if (!organization) {
|
||||
throw new NotFoundException(
|
||||
`slots-input.service.ts: Organization with slug ${organizationSlug} not found`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.organizationsTeamsRepository.findOrgTeamBySlug(organization.id, teamSlug);
|
||||
}
|
||||
|
||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||
const bookingFields = body.bookingFieldsResponses;
|
||||
if (!bookingFields || !eventType || !eventType.bookingFields) {
|
||||
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
|
||||
if (!eventType?.bookingFields) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -156,11 +198,20 @@ export class BookingsService_2024_08_13 {
|
||||
.parse(eventType.bookingFields)
|
||||
.filter((field) => !field.editable.startsWith("system"));
|
||||
|
||||
if (!eventTypeBookingFields.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const field of eventTypeBookingFields) {
|
||||
if (field.required && !(field.name in bookingFields)) {
|
||||
throw new BadRequestException(`
|
||||
Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses.
|
||||
You can fetch the event type with ID ${eventType.id} to see the required fields.`);
|
||||
if (field.name === "attendeePhoneNumber") {
|
||||
throw new BadRequestException(
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" in the request.`
|
||||
);
|
||||
}
|
||||
throw new BadRequestException(
|
||||
`Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +236,11 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
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,
|
||||
@@ -202,7 +257,11 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
}
|
||||
|
||||
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
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,
|
||||
@@ -219,7 +278,11 @@ export class BookingsService_2024_08_13 {
|
||||
);
|
||||
}
|
||||
|
||||
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
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,
|
||||
@@ -244,7 +307,11 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
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,
|
||||
@@ -579,13 +646,24 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||
|
||||
await roundRobinReassignment({
|
||||
bookingId: booking.id,
|
||||
orgId: profile?.organizationId || null,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
reassignedById: requestUser.id,
|
||||
});
|
||||
try {
|
||||
await roundRobinReassignment({
|
||||
bookingId: booking.id,
|
||||
orgId: profile?.organizationId || null,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
reassignedById: requestUser.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
throw new BadRequestException(
|
||||
"Can't reassign the booking because no other host is available at that time."
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||
if (!reassigned) {
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("Platform Calendars Endpoints", () => {
|
||||
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect?redir=https://cal.com&isDryRun=false`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
BadRequestException,
|
||||
Post,
|
||||
Body,
|
||||
ParseBoolPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
@@ -148,13 +149,14 @@ export class CalendarsController {
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("calendar") calendar: string,
|
||||
@Query("redir") redir?: string | null
|
||||
@Query("redir") redir?: string | null,
|
||||
@Query("isDryRun", ParseBoolPipe) isDryRun?: boolean
|
||||
): Promise<ApiResponse<{ authUrl: string }>> {
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.connect(authorization, req, redir ?? "");
|
||||
return await this.outlookService.connect(authorization, req, redir ?? "", isDryRun);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
|
||||
return await this.googleCalendarService.connect(authorization, req, redir ?? "", isDryRun);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
@@ -179,18 +181,30 @@ export class CalendarsController {
|
||||
): Promise<{ url: string }> {
|
||||
// state params contains our user access token
|
||||
const stateParams = new URLSearchParams(state);
|
||||
const { accessToken, origin, redir } = z
|
||||
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
|
||||
const { accessToken, origin, redir, isDryRun } = z
|
||||
.object({
|
||||
accessToken: z.string(),
|
||||
origin: z.string(),
|
||||
redir: z.string().nullish().optional(),
|
||||
isDryRun: z.string().nullish().optional(),
|
||||
})
|
||||
.parse({
|
||||
accessToken: stateParams.get("accessToken"),
|
||||
origin: stateParams.get("origin"),
|
||||
redir: stateParams.get("redir"),
|
||||
isDryRun: stateParams.get("isDryRun"),
|
||||
});
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
|
||||
return await this.googleCalendarService.save(
|
||||
code,
|
||||
accessToken,
|
||||
origin,
|
||||
redir ?? "",
|
||||
isDryRun === "true"
|
||||
);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
|
||||
@@ -39,31 +39,38 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request,
|
||||
redir?: string
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||
async save(
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: CALENDAR_SCOPES,
|
||||
prompt: "consent",
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
});
|
||||
|
||||
return authUrl;
|
||||
@@ -115,7 +122,8 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
) {
|
||||
// User chose not to authorize your app or didn't authorize your app
|
||||
// redirect directly without oauth code
|
||||
@@ -123,6 +131,11 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
// if isDryRun is true we know its a dry run so we just redirect straight away
|
||||
if (isDryRun) {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
@@ -33,24 +33,31 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
async connect(
|
||||
authorization: string,
|
||||
req: Request,
|
||||
redir?: string
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
const origin = req.get("origin") ?? req.get("host");
|
||||
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
|
||||
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
|
||||
}
|
||||
|
||||
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
|
||||
async save(
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
): Promise<{ url: string }> {
|
||||
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
return await this.checkIfCalendarConnected(userId);
|
||||
}
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||
@@ -60,7 +67,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
client_id,
|
||||
prompt: "select_account",
|
||||
redirect_uri: this.redirectUri,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
@@ -148,13 +155,19 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
code: string,
|
||||
accessToken: string,
|
||||
origin: string,
|
||||
redir?: string
|
||||
redir?: string,
|
||||
isDryRun?: boolean
|
||||
) {
|
||||
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
|
||||
if (!code || code === "undefined") {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
// if isDryRun is true we know its a dry run so we just redirect straight away
|
||||
if (isDryRun) {
|
||||
return { url: redir || origin };
|
||||
}
|
||||
|
||||
const parsedCode = z.string().parse(code);
|
||||
|
||||
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MeModule } from "@/ee/me/me.module";
|
||||
import { ProviderModule } from "@/ee/provider/provider.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
@@ -33,6 +34,7 @@ import { Module } from "@nestjs/common";
|
||||
SlotsModule_2024_04_15,
|
||||
SlotsModule_2024_09_04,
|
||||
TeamsModule,
|
||||
RoutingFormsModule,
|
||||
],
|
||||
})
|
||||
export class PlatformEndpointsModule implements NestModule {
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
@Module({
|
||||
@@ -29,28 +23,8 @@ import { PassportModule } from "@nestjs/passport";
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
forwardRef(() => OrganizationsModule),
|
||||
],
|
||||
providers: [
|
||||
NextAuthGuard,
|
||||
NextAuthStrategy,
|
||||
ApiAuthGuard,
|
||||
ApiAuthStrategy,
|
||||
OAuthFlowService,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
exports: [
|
||||
NextAuthGuard,
|
||||
ApiAuthGuard,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -6,15 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
|
||||
imports: [PrismaModule, ConfigModule, HttpModule],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
@@ -32,10 +31,10 @@ import {
|
||||
Delete,
|
||||
Headers,
|
||||
Redirect,
|
||||
UnauthorizedException,
|
||||
Req,
|
||||
HttpException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
@@ -60,9 +59,9 @@ export class ConferencingController {
|
||||
private readonly logger = new Logger("ConferencingController");
|
||||
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
private readonly config: ConfigService,
|
||||
private readonly httpService: HttpService
|
||||
) {}
|
||||
|
||||
@Post("/:app/connect")
|
||||
@@ -120,6 +119,14 @@ export class ConferencingController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles saving conferencing app credentials.
|
||||
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
|
||||
* otherwise, credentials are saved at the user level.
|
||||
*
|
||||
* Proxying ensures that permission checks—such as whether the user is allowed to install conferencing app for a team or organization—
|
||||
* are enforced via controller route guards, avoiding duplication of this logic within the service layer.
|
||||
*/
|
||||
@Get("/:app/oauth/callback")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@@ -143,23 +150,28 @@ export class ConferencingController {
|
||||
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
if (error) {
|
||||
throw new BadRequestException(error_description);
|
||||
}
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
if (decodedCallbackState.orgId) {
|
||||
return this.organizationsConferencingService.connectTeamOauthApps({
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
decodedCallbackState,
|
||||
});
|
||||
} else {
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
|
||||
|
||||
if (decodedCallbackState.teamId && decodedCallbackState.orgId) {
|
||||
const apiUrl = this.config.get("api.url");
|
||||
const url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/conferencing/${app}/oauth/callback`;
|
||||
const params: Record<string, string | undefined> = { state, code, error, error_description };
|
||||
const headers = {
|
||||
Authorization: `Bearer ${decodedCallbackState.accessToken}`,
|
||||
};
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.get(url, { params, headers });
|
||||
const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: redirectUrl };
|
||||
} catch (err) {
|
||||
const fallbackUrl = decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: fallbackUrl };
|
||||
}
|
||||
}
|
||||
|
||||
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException || error instanceof Error) {
|
||||
this.logger.error(error.message);
|
||||
|
||||
@@ -3,9 +3,15 @@ import { ConferencingRepository } from "@/modules/conferencing/repositories/conf
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
@@ -25,6 +31,7 @@ export class ConferencingService {
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
@@ -47,10 +54,13 @@ export class ConferencingService {
|
||||
async connectOauthApps(
|
||||
app: string,
|
||||
code: string,
|
||||
userId: number,
|
||||
decodedCallbackState: OAuthCallbackState,
|
||||
teamId?: number
|
||||
) {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
|
||||
|
||||
-2
@@ -16,8 +16,6 @@ export class DestinationCalendarsService {
|
||||
userId: number,
|
||||
delegationCredentialId?: string
|
||||
) {
|
||||
// note(Lauris): todo remove the log but leaving this now to confirm delegationCredentialId is received
|
||||
console.log("debug: delegationCredentialId", delegationCredentialId);
|
||||
const userCalendars = await this.calendarsService.getCalendars(userId);
|
||||
const allCalendars: Calendar[] = userCalendars.connectedCalendars
|
||||
.map((cal: ConnectedCalendar) => cal.calendars ?? [])
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsOptional, IsArray, ArrayMinSize, IsString, IsNumber } from "class-validator";
|
||||
|
||||
export class GetAssignedAttributeOptions {
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of responses to skip" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
skip?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, description: "Number of responses to take" })
|
||||
@Transform(({ value }) => value && parseInt(value))
|
||||
@IsOptional()
|
||||
take?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((optId: string) => optId);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: "Filter by assigned attribute option ids. ids must be separated by a comma.",
|
||||
example: "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMinSize(1, { message: "assignedOptionIds must contain at least 1 attribute option id" })
|
||||
assignedOptionIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((teamId: string) => parseInt(teamId));
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@ApiPropertyOptional({
|
||||
type: [Number],
|
||||
description: "Filter by teamIds. Team ids must be separated by a comma.",
|
||||
example: "?teamIds=100,200",
|
||||
})
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
|
||||
teamIds?: number[];
|
||||
}
|
||||
+78
@@ -7,6 +7,8 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
import { GetOrganizationAttributeAssignedOptionsProp } from "./services/organization-attributes-option.service";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAttributeOptionRepository {
|
||||
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
|
||||
@@ -90,6 +92,82 @@ export class OrganizationAttributeOptionRepository {
|
||||
return options;
|
||||
}
|
||||
|
||||
async getOrganizationAttributeAssignedOptions({
|
||||
attributeId,
|
||||
attributeSlug,
|
||||
filters,
|
||||
organizationId,
|
||||
skip,
|
||||
take,
|
||||
}: GetOrganizationAttributeAssignedOptionsProp) {
|
||||
const options = await this.dbRead.prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: {
|
||||
...(attributeId && { id: attributeId }),
|
||||
...(attributeSlug && { slug: attributeSlug }),
|
||||
teamId: organizationId,
|
||||
},
|
||||
assignedUsers: {
|
||||
some: {}, // empty {} statement checks if option is assigned to at least one user
|
||||
...(filters?.teamIds && {
|
||||
some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: { assignedUsers: { include: { member: true } } },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
// only return options that are assigned to users alongside assignedOptionIds filter
|
||||
if (filters?.assignedOptionIds) {
|
||||
const filteredAssignedOptions = await this.dbRead.prisma.attributeOption.findMany({
|
||||
where: {
|
||||
attribute: { teamId: organizationId },
|
||||
assignedUsers: {
|
||||
every: {
|
||||
attributeOptionId: { in: filters?.assignedOptionIds },
|
||||
},
|
||||
...(filters?.teamIds && {
|
||||
some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: { assignedUsers: { include: { member: true } } },
|
||||
});
|
||||
|
||||
if (!filteredAssignedOptions?.length) {
|
||||
throw new NotFoundException(
|
||||
"Options provided in assignedOptionIds are not assigned to anyone, or the users are not part of the teams specified in teamIds filter."
|
||||
);
|
||||
}
|
||||
|
||||
const matchingUserIds = filteredAssignedOptions.flatMap((opt) =>
|
||||
opt.assignedUsers.flatMap((assignedUser) => assignedUser.member.userId)
|
||||
);
|
||||
// reduce remove options that are not assigned alongside assignedOptionIds filter
|
||||
return options.reduce((acc, opt) => {
|
||||
if (opt.assignedUsers.some((assignedUser) => matchingUserIds.includes(assignedUser.member.userId))) {
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
...opt,
|
||||
assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId),
|
||||
},
|
||||
];
|
||||
}
|
||||
return acc;
|
||||
}, [] as typeof options);
|
||||
}
|
||||
|
||||
return options.map((opt) => {
|
||||
return {
|
||||
...opt,
|
||||
assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async assignOrganizationAttributeOptionToUser({
|
||||
organizationId,
|
||||
membershipId,
|
||||
|
||||
+233
-8
@@ -3,6 +3,8 @@ import { AppModule } from "@/app.module";
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
||||
import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
|
||||
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -13,12 +15,13 @@ import * as request from "supertest";
|
||||
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, Membership } from "@calcom/prisma/client";
|
||||
import { User, Team, Membership, Attribute, AttributeOption } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Attributes Options Endpoints", () => {
|
||||
describe("User lacks required role", () => {
|
||||
@@ -95,19 +98,37 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixtures: TeamRepositoryFixture;
|
||||
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let attributeRepositoryFixture: AttributeRepositoryFixture;
|
||||
|
||||
const userEmail = `organization-attributes-options-admin-${randomString()}@api.com`;
|
||||
const userEmail2 = `organization-attributes-options-member-${randomString()}@api.com`;
|
||||
|
||||
let user: User;
|
||||
let user2: User;
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let membership: Membership;
|
||||
let membership2: Membership;
|
||||
|
||||
let attributeId: string;
|
||||
let attributeSlug: string;
|
||||
|
||||
let createdOption: any;
|
||||
let createdOption2: any;
|
||||
let attribute2: Attribute;
|
||||
let attribute2Option: AttributeOption;
|
||||
|
||||
const createOptionInput: CreateOrganizationAttributeOptionInput = {
|
||||
value: "option1",
|
||||
slug: "option1",
|
||||
slug: `option1-${randomString()}`,
|
||||
};
|
||||
|
||||
const createOption2Input: CreateOrganizationAttributeOptionInput = {
|
||||
value: "option2",
|
||||
slug: `option2${randomString()}`,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -120,6 +141,7 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef);
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||
|
||||
@@ -128,23 +150,50 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } });
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
user2 = await userRepositoryFixture.create({
|
||||
email: userEmail2,
|
||||
username: userEmail2,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
membership2 = await membershipFixtures.addUserToOrg(user2, org, "ADMIN", true);
|
||||
await membershipFixtures.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
team: { connect: { id: team.id } },
|
||||
user: { connect: { id: user.id } },
|
||||
});
|
||||
|
||||
// Create an attribute for testing
|
||||
const attribute = await attributeRepositoryFixture.create({
|
||||
name: "Test Attribute",
|
||||
team: { connect: { id: org.id } },
|
||||
type: "TEXT",
|
||||
slug: "test-attribute",
|
||||
slug: `test-attribute-${randomString()}`,
|
||||
});
|
||||
|
||||
attribute2 = await attributeRepositoryFixture.create({
|
||||
name: "Test Attribute 2",
|
||||
team: { connect: { id: org.id } },
|
||||
type: "TEXT",
|
||||
slug: `test-attribute-2-${randomString()}`,
|
||||
});
|
||||
attributeId = attribute.id;
|
||||
|
||||
attributeSlug = attribute.slug;
|
||||
attribute2Option = await attributeRepositoryFixture.createOption({
|
||||
slug: `optionA-${randomString()}`,
|
||||
value: "optionA",
|
||||
attribute: { connect: { id: attribute2.id } },
|
||||
});
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -164,16 +213,31 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create attribute option", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.send(createOption2Input)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
createdOption2 = response.body.data;
|
||||
expect(createdOption2.value).toEqual(createOption2Input.value);
|
||||
expect(createdOption2.slug).toEqual(createOption2Input.slug);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const options = response.body.data;
|
||||
expect(options.length).toEqual(1);
|
||||
expect(options[0].value).toEqual(createOptionInput.value);
|
||||
expect(options[0].slug).toEqual(createOptionInput.slug);
|
||||
const options = response.body.data as OptionOutput[];
|
||||
expect(options.length).toEqual(2);
|
||||
expect(options.find((opt) => opt.value === createOptionInput.value)).toBeDefined();
|
||||
expect(options.find((opt) => opt.slug === createOptionInput.slug)).toBeDefined();
|
||||
expect(options.find((opt) => opt.value === createOption2Input.value)).toBeDefined();
|
||||
expect(options.find((opt) => opt.slug === createOption2Input.slug)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +273,38 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign attribute option to user2", async () => {
|
||||
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
|
||||
attributeId: attributeId,
|
||||
attributeOptionId: createdOption2.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
|
||||
.send(assignInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign attribute option to user2", async () => {
|
||||
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
|
||||
attributeId: attribute2.id,
|
||||
attributeOptionId: attribute2Option.id,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
|
||||
.send(assignInput)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options for user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
|
||||
@@ -221,6 +317,133 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute options for user2", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const userOptions = response.body.data as AttributeOption[];
|
||||
expect(userOptions.length).toEqual(2);
|
||||
expect(userOptions.find((opt) => opt.id === createdOption2.id)).toBeDefined();
|
||||
expect(userOptions.find((opt) => opt.id === attribute2Option.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(2);
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user.id)
|
||||
).toBeDefined();
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption2.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user2.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options filtered by team id", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?teamIds=${team.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(1);
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options by attribute slug", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/attributes/slugs/${attributeSlug}/options/assigned`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(2);
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user.id)
|
||||
).toBeDefined();
|
||||
|
||||
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption2.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user2.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options filtered by other assigned options", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}`
|
||||
)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(1);
|
||||
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption2.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user2.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not get attribute all assigned options filtered by other assigned options and teamIds in which the user is not part of", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}&teamIds=${team.id}`
|
||||
)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should get attribute all assigned options filtered by other assigned options and teamIds", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${createdOption.id}&teamIds=${team.id}`
|
||||
)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
const assignedOptions = response.body.data as AssignedOptionOutput[];
|
||||
expect(assignedOptions?.length).toEqual(1);
|
||||
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
|
||||
expect(
|
||||
assignedOptions
|
||||
.find((opt) => createdOption.id === opt.id)
|
||||
?.assignedUserIds.find((id) => id === user.id)
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should unassign attribute option from user", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
|
||||
@@ -243,7 +466,9 @@ describe("Organizations Attributes Options Endpoints", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await membershipFixtures.delete(membership.id);
|
||||
await membershipFixtures.delete(membership2.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+62
-1
@@ -8,19 +8,32 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
|
||||
import { GetAssignedAttributeOptions } from "@/modules/organizations/attributes/options/inputs/get-assigned-attribute-options.input";
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
||||
import {
|
||||
AssignOptionUserOutput,
|
||||
UnassignOptionUserOutput,
|
||||
} from "@/modules/organizations/attributes/options/outputs/assign-option-user.output";
|
||||
import { GetAllAttributeAssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
|
||||
import { CreateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/create-option.output";
|
||||
import { DeleteAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/delete-option.output";
|
||||
import { GetOptionUserOutput } from "@/modules/organizations/attributes/options/outputs/get-option-user.output";
|
||||
import { GetAllAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/get-option.output";
|
||||
import { UpdateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/update-option.output";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -118,6 +131,54 @@ export class OrganizationsAttributesOptionsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/:attributeId/options/assigned")
|
||||
@ApiOperation({ summary: "Get by attribute id all of the attribute options that are assigned to users" })
|
||||
async getOrganizationAttributeAssignedOptions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@Query() queryParams: GetAssignedAttributeOptions
|
||||
): Promise<GetAllAttributeAssignedOptionOutput> {
|
||||
const { skip, take, ...rest } = queryParams;
|
||||
const attributeOptions =
|
||||
await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({
|
||||
organizationId: orgId,
|
||||
attributeId,
|
||||
skip: skip ?? 0,
|
||||
take: take ?? 250,
|
||||
filters: rest,
|
||||
});
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/slugs/:attributeSlug/options/assigned")
|
||||
@ApiOperation({ summary: "Get by attribute slug all of the attribute options that are assigned to users" })
|
||||
async getOrganizationAttributeAssignedOptionsBySlug(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeSlug") attributeSlug: string,
|
||||
@Query() queryParams: GetAssignedAttributeOptions
|
||||
): Promise<GetAllAttributeAssignedOptionOutput> {
|
||||
const { skip, take, ...rest } = queryParams;
|
||||
const attributeOptions =
|
||||
await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({
|
||||
organizationId: orgId,
|
||||
attributeSlug,
|
||||
skip: skip ?? 0,
|
||||
take: take ?? 250,
|
||||
filters: rest,
|
||||
});
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: attributeOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Post("/attributes/options/:userId")
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
|
||||
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsArray, ValidateNested } from "class-validator";
|
||||
|
||||
export class AssignedOptionOutput extends OptionOutput {
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ApiProperty({
|
||||
type: Array,
|
||||
required: true,
|
||||
description: "Ids of the users assigned to the attribute option.",
|
||||
example: [124, 224],
|
||||
})
|
||||
assignedUserIds!: number[];
|
||||
}
|
||||
|
||||
export class GetAllAttributeAssignedOptionOutput extends BaseOutputDTO {
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => OptionOutput)
|
||||
data!: AssignedOptionOutput[];
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class OptionOutput {
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
@@ -12,10 +13,12 @@ export class OptionOutput {
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
|
||||
attributeId!: string;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
@@ -25,6 +28,7 @@ export class OptionOutput {
|
||||
})
|
||||
value!: string;
|
||||
|
||||
@Expose()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
|
||||
+43
@@ -3,8 +3,10 @@ import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/
|
||||
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
|
||||
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
|
||||
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
|
||||
import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
|
||||
|
||||
@@ -111,4 +113,45 @@ export class OrganizationAttributeOptionService {
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async getOrganizationAttributeAssignedOptions({
|
||||
organizationId,
|
||||
attributeId,
|
||||
attributeSlug,
|
||||
skip,
|
||||
take,
|
||||
filters,
|
||||
}: GetOrganizationAttributeAssignedOptionsProp) {
|
||||
const options = await this.organizationAttributeOptionRepository.getOrganizationAttributeAssignedOptions({
|
||||
organizationId,
|
||||
...(attributeId ? { attributeId } : { attributeSlug }),
|
||||
skip,
|
||||
take,
|
||||
filters,
|
||||
});
|
||||
return options.map((opt) => plainToClass(AssignedOptionOutput, opt, { strategy: "excludeAll" }));
|
||||
}
|
||||
}
|
||||
|
||||
// Discriminative Union on attributeSlug / attributeId
|
||||
export type GetOrganizationAttributeAssignedOptionsProp =
|
||||
| GetOrganizationAttributeAssignedOptionsPropById
|
||||
| GetOrganizationAttributeAssignedOptionsPropBySlug;
|
||||
|
||||
type GetOrganizationAttributeAssignedOptionsPropById = {
|
||||
organizationId: number;
|
||||
attributeId: string;
|
||||
attributeSlug?: undefined;
|
||||
skip: number;
|
||||
take: number;
|
||||
filters?: { assignedOptionIds?: string[]; teamIds?: number[] };
|
||||
};
|
||||
|
||||
type GetOrganizationAttributeAssignedOptionsPropBySlug = {
|
||||
organizationId: number;
|
||||
attributeId?: undefined;
|
||||
attributeSlug?: string;
|
||||
skip: number;
|
||||
take: number;
|
||||
filters?: { assignedOptionIds?: string[]; teamIds?: number[] };
|
||||
};
|
||||
|
||||
+12
-9
@@ -1,5 +1,11 @@
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
OPTIONAL_API_KEY_HEADER,
|
||||
OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
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";
|
||||
@@ -8,15 +14,12 @@ import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.g
|
||||
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 { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
import { GetOrganizationsBookingsInput } from "./inputs/get-org-bookings.input";
|
||||
import { GetBookingsOutput_2024_08_13, GetOrganizationsBookingsInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/bookings",
|
||||
@@ -24,11 +27,11 @@ import { GetOrganizationsBookingsInput } from "./inputs/get-org-bookings.input";
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Bookings")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
export class OrganizationsBookingsController {
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly orgUsersService: OrganizationsUsersService
|
||||
) {}
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get Organization Bookings" })
|
||||
|
||||
+43
-1
@@ -22,6 +22,7 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -36,10 +37,13 @@ import {
|
||||
Headers,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
BadRequestException,
|
||||
Redirect,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
|
||||
@@ -60,7 +64,8 @@ export type OAuthCallbackState = {
|
||||
export class OrganizationsConferencingController {
|
||||
constructor(
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -221,4 +226,41 @@ export class OrganizationsConferencingController {
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/:app/oauth/callback")
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save conferencing app OAuth credentials" })
|
||||
async saveTeamOauthCredentials(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<{ url: string }> {
|
||||
if (!state) {
|
||||
throw new BadRequestException("Missing `state` query param");
|
||||
}
|
||||
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
return await this.organizationsConferencingService.connectTeamOauthApps({
|
||||
decodedCallbackState,
|
||||
code,
|
||||
app,
|
||||
teamId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return {
|
||||
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule, ConferencingModule],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
GoogleMeetService,
|
||||
UsersRepository,
|
||||
TeamsRepository,
|
||||
OrganizationsConferencingService,
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
CredentialsRepository,
|
||||
TokensRepository,
|
||||
AppsRepository,
|
||||
OrganizationsRepository,
|
||||
StripeService,
|
||||
MembershipsRepository,
|
||||
RedisService,
|
||||
OrganizationsTeamsRepository,
|
||||
],
|
||||
exports: [OrganizationsConferencingService],
|
||||
controllers: [OrganizationsConferencingController],
|
||||
})
|
||||
export class OrganizationsConferencingModule {}
|
||||
+5
-163
@@ -1,10 +1,3 @@
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PlatformPlanType } from "@/modules/billing/types";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
@@ -13,12 +6,7 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
@@ -35,129 +23,9 @@ export class OrganizationsConferencingService {
|
||||
private teamsRepository: TeamsRepository,
|
||||
private usersRepository: UsersRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly platformPlanGuard: PlatformPlanGuard,
|
||||
private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard,
|
||||
private readonly isOrgGuard: IsOrgGuard,
|
||||
private readonly isTeamInOrg: IsTeamInOrg,
|
||||
private readonly rolesGuard: RolesGuard
|
||||
private readonly conferencingService: ConferencingService
|
||||
) {}
|
||||
|
||||
async verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan,
|
||||
}: {
|
||||
user: UserWithProfile;
|
||||
orgId: string;
|
||||
teamId?: string;
|
||||
requiredRole?: string;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<{ orgId: number; teamId: number | null }> {
|
||||
const userWithAdminFlag = {
|
||||
...user,
|
||||
isSystemAdmin: user.role === "ADMIN",
|
||||
};
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("Organization ID is required");
|
||||
}
|
||||
|
||||
await this.ensureOrganizationAccess(orgId);
|
||||
await this.ensureAdminAPIEnabled(orgId);
|
||||
|
||||
// Only validate team if a teamId is provided
|
||||
if (teamId && teamId.trim() !== "") {
|
||||
await this.ensureTeamBelongsToOrg(orgId, teamId);
|
||||
}
|
||||
|
||||
await this.ensureUserRoleAccess(userWithAdminFlag, orgId, teamId, requiredRole);
|
||||
await this.ensurePlatformPlanAccess({ teamId, orgId, user: userWithAdminFlag, minimumPlan });
|
||||
|
||||
const parsedOrgId = parseInt(orgId, 10);
|
||||
// For organization-level operations, teamId might be empty or null
|
||||
const parsedTeamId = teamId && teamId.trim() !== "" ? parseInt(teamId, 10) : null;
|
||||
|
||||
return {
|
||||
orgId: parsedOrgId,
|
||||
teamId: parsedTeamId,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureOrganizationAccess(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Organization validation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminAPIEnabled(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isAdminAPIEnabledGuard.checkAdminAPIEnabled(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Admin API is not enabled for this organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTeamBelongsToOrg(orgId: string, teamId: string): Promise<void> {
|
||||
const { canAccess } = await this.isTeamInOrg.checkIfTeamIsInOrg(orgId, teamId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Team is not part of the organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
teamId?: string,
|
||||
requiredRole?: string
|
||||
): Promise<void> {
|
||||
if (!requiredRole) return;
|
||||
|
||||
// For team-level roles, we need both orgId and teamId
|
||||
if (requiredRole.startsWith("TEAM_") && (!teamId || teamId.trim() === "")) {
|
||||
throw new ForbiddenException("Team ID is required for team-level role access");
|
||||
}
|
||||
|
||||
// Use empty string for teamId if it's an org-level operation
|
||||
const effectiveTeamId = teamId && teamId.trim() !== "" ? teamId : "";
|
||||
|
||||
const { canAccess } = await this.rolesGuard.checkUserRoleAccess(
|
||||
user,
|
||||
orgId,
|
||||
effectiveTeamId,
|
||||
requiredRole
|
||||
);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User does not have the required role.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
}: {
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
user: ApiAuthGuardUser;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<void> {
|
||||
if (!minimumPlan) return;
|
||||
|
||||
const { canAccess } = await this.platformPlanGuard.checkPlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
});
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User's organization does not meet the required subscription plan.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise<any> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
@@ -171,40 +39,14 @@ export class OrganizationsConferencingService {
|
||||
decodedCallbackState,
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
teamId,
|
||||
}: {
|
||||
userId: number;
|
||||
app: string;
|
||||
decodedCallbackState: OAuthCallbackState;
|
||||
code: string;
|
||||
teamId: number;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByIdWithProfile(userId);
|
||||
const { orgId, teamId } = decodedCallbackState;
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("orgId required");
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException("user not found");
|
||||
}
|
||||
|
||||
// Determine if this is a team-level or organization-level operation
|
||||
const isTeamLevel = !!teamId;
|
||||
const requiredRole = isTeamLevel ? "TEAM_ADMIN" : "ORG_ADMIN";
|
||||
|
||||
// Verify access with appropriate parameters
|
||||
const { orgId: validatedOrgId, teamId: validatedTeamId } = await this.verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan: "ESSENTIALS",
|
||||
});
|
||||
|
||||
const entityId = isTeamLevel && validatedTeamId !== null ? validatedTeamId : validatedOrgId;
|
||||
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState, entityId);
|
||||
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState, teamId);
|
||||
}
|
||||
|
||||
async getConferencingApps({ teamId }: { teamId: number }) {
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ export class OrganizationsEventTypesController {
|
||||
|
||||
@UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/event-types")
|
||||
@ApiOperation({ summary: "Get a team event type" })
|
||||
@ApiOperation({ summary: "Get team event types" })
|
||||
async getTeamEventTypes(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
@@ -14,6 +19,7 @@ import { OrganizationAttributeOptionRepository } from "@/modules/organizations/a
|
||||
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
|
||||
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
|
||||
@@ -31,6 +37,8 @@ import { OrganizationsOrganizationsModule } from "@/modules/organizations/organi
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
|
||||
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
||||
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
|
||||
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
||||
@@ -50,13 +58,15 @@ import { OrganizationsWebhooksRepository } from "@/modules/organizations/webhook
|
||||
import { OrganizationsWebhooksService } from "@/modules/organizations/webhooks/services/organizations-webhooks.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -69,11 +79,11 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_06_14,
|
||||
TeamsEventTypesModule,
|
||||
TeamsModule,
|
||||
forwardRef(() => ConferencingModule),
|
||||
forwardRef(() => AuthModule),
|
||||
OrganizationsDelegationCredentialModule,
|
||||
OrganizationsOrganizationsModule,
|
||||
OrganizationsStripeModule,
|
||||
OrganizationsTeamsRoutingFormsModule,
|
||||
OrganizationsConferencingModule,
|
||||
],
|
||||
providers: [
|
||||
OrganizationsRepository,
|
||||
@@ -108,6 +118,16 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
OrgUsersOOOService,
|
||||
OrgUsersOOORepository,
|
||||
OrganizationsConferencingService,
|
||||
OrganizationsStripeService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
RedisService,
|
||||
ConferencingRepository,
|
||||
GoogleMeetService,
|
||||
ConferencingService,
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
TokensRepository,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
@@ -129,6 +149,7 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
WebhooksService,
|
||||
OrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
OrganizationsStripeService,
|
||||
],
|
||||
controllers: [
|
||||
OrganizationsTeamsController,
|
||||
@@ -142,7 +163,6 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
OrganizationsWebhooksController,
|
||||
OrganizationsTeamsSchedulesController,
|
||||
OrganizationsUsersOOOController,
|
||||
OrganizationsConferencingController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ const SCALE = "SCALE";
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Orgs")
|
||||
@DocsTags("Managed Orgs")
|
||||
@ApiHeader(X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(X_CAL_SECRET_KEY_HEADER)
|
||||
export class OrganizationsOrganizationsController {
|
||||
|
||||
+4
-1
@@ -178,9 +178,12 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
});
|
||||
|
||||
it("should get routing form responses", async () => {
|
||||
const createdAt = new Date(routingFormResponse.createdAt);
|
||||
createdAt.setHours(createdAt.getHours() - 1);
|
||||
const isoStringCreatedAt = createdAt.toISOString();
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc`
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc&afterCreatedAt=${isoStringCreatedAt}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
|
||||
+5
-22
@@ -1,15 +1,6 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsDate,
|
||||
IsNumber,
|
||||
IsArray,
|
||||
ArrayMinSize,
|
||||
} from "class-validator";
|
||||
import { IsOptional, IsString, IsEnum, IsISO8601, IsNumber, IsArray, ArrayMinSize } from "class-validator";
|
||||
|
||||
enum SortOrder {
|
||||
ASC = "asc",
|
||||
@@ -44,9 +35,7 @@ export class GetRoutingFormResponsesParams {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
afterCreatedAt?: Date;
|
||||
afterCreatedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
@@ -55,9 +44,7 @@ export class GetRoutingFormResponsesParams {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
beforeCreatedAt?: Date;
|
||||
beforeCreatedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
@@ -66,9 +53,7 @@ export class GetRoutingFormResponsesParams {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
afterUpdatedAt?: Date;
|
||||
afterUpdatedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
@@ -77,9 +62,7 @@ export class GetRoutingFormResponsesParams {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@Transform(({ value }) => value && new Date(value))
|
||||
@IsDate()
|
||||
beforeUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: String, description: "Filter by responses routed to a specific booking" })
|
||||
@IsOptional()
|
||||
|
||||
+8
-8
@@ -15,10 +15,10 @@ export class OrganizationsRoutingFormsRepository {
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
teamIds?: number[];
|
||||
}
|
||||
) {
|
||||
@@ -61,10 +61,10 @@ export class OrganizationsRoutingFormsRepository {
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@ export class OrganizationsRoutingFormsResponsesService {
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
|
||||
+4
-4
@@ -14,10 +14,10 @@ export class OrganizationsRoutingFormsService {
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
teamIds?: number[];
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
import {
|
||||
StripConnectOutputDto,
|
||||
StripConnectOutputResponseDto,
|
||||
StripCredentialsCheckOutputResponseDto,
|
||||
StripCredentialsSaveOutputResponseDto,
|
||||
} from "@/modules/stripe/outputs/stripe.output";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Param,
|
||||
Headers,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
Redirect,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/stripe",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Organizations/Teams Stripe")
|
||||
export class OrganizationsStripeController {
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly organizationsStripeService: OrganizationsStripeService,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get stripe connect URL for a team" })
|
||||
async getTeamStripeConnectUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId") teamId: string,
|
||||
@Param("orgId") orgId: string,
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<StripConnectOutputResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state: OAuthCallbackState = {
|
||||
onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin,
|
||||
fromApp: false,
|
||||
returnTo: !!returnTo ? returnTo : origin,
|
||||
accessToken,
|
||||
teamId,
|
||||
orgId,
|
||||
};
|
||||
|
||||
const stripeRedirectUrl = await this.organizationsStripeService.getStripeTeamRedirectUrl({
|
||||
state,
|
||||
userEmail: user.email,
|
||||
userName: user.name,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Check team stripe connection" })
|
||||
async checkTeamStripeConnection(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.organizationsStripeService.checkIfTeamStripeAccountConnected(teamId);
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/save")
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<StripCredentialsSaveOutputResponseDto> {
|
||||
if (!state) {
|
||||
throw new BadRequestException("Missing `state` query param");
|
||||
}
|
||||
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
|
||||
// user cancels flow
|
||||
if (error === "access_denied") {
|
||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(stringify({ error, error_description }));
|
||||
}
|
||||
|
||||
return await this.organizationsStripeService.saveStripeAccount(
|
||||
decodedCallbackState,
|
||||
code,
|
||||
teamId,
|
||||
userId
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return {
|
||||
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsStripeController } from "@/modules/organizations/stripe/organizations-stripe.controller";
|
||||
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [StripeModule, PrismaModule],
|
||||
exports: [OrganizationsStripeService],
|
||||
providers: [
|
||||
OrganizationsStripeService,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
RedisService,
|
||||
OrganizationsRepository,
|
||||
MembershipsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
TokensRepository,
|
||||
],
|
||||
controllers: [OrganizationsStripeController],
|
||||
})
|
||||
export class OrganizationsStripeModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OAuthCallbackState, StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||
import { Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { ApiResponseWithoutData } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsStripeService {
|
||||
private logger = new Logger("OrganizationsStripeService");
|
||||
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly appsRepository: AppsRepository
|
||||
) {}
|
||||
|
||||
async getStripeTeamRedirectUrl({
|
||||
state,
|
||||
userEmail,
|
||||
userName,
|
||||
}: {
|
||||
state: OAuthCallbackState;
|
||||
userEmail?: string;
|
||||
userName?: string | null;
|
||||
}): Promise<string> {
|
||||
return await this.stripeService.getStripeRedirectUrl(state, userEmail, userName);
|
||||
}
|
||||
|
||||
async saveStripeAccount(
|
||||
state: OAuthCallbackState,
|
||||
code: string,
|
||||
teamId: number,
|
||||
userId?: number
|
||||
): Promise<{ url: string }> {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const response = await stripeInstance.oauth.token({
|
||||
grant_type: "authorization_code",
|
||||
code: code?.toString(),
|
||||
});
|
||||
|
||||
const data: StripeData = { ...response, default_currency: "" };
|
||||
if (response["stripe_user_id"]) {
|
||||
const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]);
|
||||
data["default_currency"] = account.default_currency;
|
||||
}
|
||||
|
||||
const existingCredentials = await this.credentialRepository.findAllCredentialsByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
const credentialIdsToDelete = existingCredentials.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createTeamAppCredential(
|
||||
"stripe_payment",
|
||||
data as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
async checkIfTeamStripeAccountConnected(teamId: number): Promise<ApiResponseWithoutData> {
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
return await this.stripeService.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,18 @@ export class OrganizationsTeamsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgTeamBySlug(organizationId: number, teamSlug: string) {
|
||||
return this.dbRead.prisma.team.findUnique({
|
||||
where: {
|
||||
slug_parentId: {
|
||||
slug: teamSlug,
|
||||
parentId: organizationId,
|
||||
},
|
||||
isOrganization: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgTeams(organizationId: number) {
|
||||
return this.dbRead.prisma.team.findMany({
|
||||
where: {
|
||||
|
||||
-1
@@ -5,7 +5,6 @@ import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizatio
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ export class OrganizationsTeamsRoutingFormsResponsesRepository {
|
||||
take: number,
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
|
||||
+4
-4
@@ -15,10 +15,10 @@ export class OrganizationsTeamsRoutingFormsRepository {
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
|
||||
+4
-4
@@ -18,10 +18,10 @@ export class OrganizationsTeamsRoutingFormsResponsesService {
|
||||
options?: {
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
routedToBookingUid?: string;
|
||||
}
|
||||
) {
|
||||
|
||||
+4
-4
@@ -15,10 +15,10 @@ export class OrganizationsTeamsRoutingFormsService {
|
||||
name?: string;
|
||||
sortCreatedAt?: "asc" | "desc";
|
||||
sortUpdatedAt?: "asc" | "desc";
|
||||
afterCreatedAt?: Date;
|
||||
beforeCreatedAt?: Date;
|
||||
afterUpdatedAt?: Date;
|
||||
beforeUpdatedAt?: Date;
|
||||
afterCreatedAt?: string;
|
||||
beforeCreatedAt?: string;
|
||||
afterUpdatedAt?: string;
|
||||
beforeUpdatedAt?: string;
|
||||
}
|
||||
) {
|
||||
return this.routingFormsRepository.getTeamRoutingForms(teamId, skip, take, options);
|
||||
|
||||
+11
-5
@@ -1,6 +1,10 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import {
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
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";
|
||||
@@ -10,11 +14,10 @@ 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 { ApiHeader, 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",
|
||||
@@ -22,6 +25,9 @@ import { User } from "@calcom/prisma/client";
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsUserInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Users / Bookings")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
export class OrganizationsUsersBookingsController {
|
||||
constructor(private readonly organizationUsersBookingsService: OrganizationUsersBookingsService) {}
|
||||
|
||||
@@ -30,13 +36,13 @@ export class OrganizationsUsersBookingsController {
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiOperation({ summary: "Get all bookings of an organization user" })
|
||||
async getOrganizationUserBookings(
|
||||
@GetUser() user: User,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Query() query: GetBookingsInput_2024_08_13
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings(
|
||||
orgId,
|
||||
user,
|
||||
userId,
|
||||
query
|
||||
);
|
||||
|
||||
|
||||
+11
-8
@@ -1,18 +1,21 @@
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException } 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) {}
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async getOrganizationUserBookings(
|
||||
orgId: number,
|
||||
user: Pick<User, "id" | "email">,
|
||||
queryParams: GetBookingsInput_2024_08_13
|
||||
) {
|
||||
async getOrganizationUserBookings(orgId: number, userId: number, queryParams: GetBookingsInput_2024_08_13) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`getOrganizationUserBookings - User ${userId} not found`);
|
||||
}
|
||||
return await this.bookingsService.getBookings(queryParams, { orgId, id: user.id, email: user.email });
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -66,9 +66,11 @@ export class OrganizationsUsersController {
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: GetOrganizationsUsersInput
|
||||
): Promise<GetOrganizationUsersResponseDTO> {
|
||||
const { emails, assignedOptionIds, attributeQueryOperator, teamIds } = query ?? {};
|
||||
const users = await this.organizationsUsersService.getUsers(
|
||||
orgId,
|
||||
query.emails,
|
||||
emails,
|
||||
{ assignedOptionIds, attributeQueryOperator, teamIds },
|
||||
query.skip ?? 0,
|
||||
query.take ?? 250
|
||||
);
|
||||
|
||||
+246
-8
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { GetOrganizationsUsersInput } from "@/modules/organizations/users/index/inputs/get-organization-users.input";
|
||||
import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/users/index/outputs/get-organization-users.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
@@ -10,6 +11,7 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
@@ -20,7 +22,7 @@ import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { User, Team, EventType } from "@calcom/prisma/client";
|
||||
import { User, Team, EventType, AttributeOption } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Users Endpoints", () => {
|
||||
const bio = "I am a bio";
|
||||
@@ -114,8 +116,6 @@ describe("Organizations Users Endpoints", () => {
|
||||
await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
describe("Admin role", () => {
|
||||
@@ -249,11 +249,6 @@ describe("Organizations Users Endpoints", () => {
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(4);
|
||||
console.log(
|
||||
"profiles",
|
||||
{ userData },
|
||||
userData.map((u) => u.profile)
|
||||
);
|
||||
// Find and verify each member's data
|
||||
const member0 = userData.find((u) => u.profile.username === orgMembersData[0].username);
|
||||
const member1 = userData.find((u) => u.profile.username === orgMembersData[1].username);
|
||||
@@ -543,7 +538,250 @@ describe("Organizations Users Endpoints", () => {
|
||||
await userRepositoryFixture.deleteByEmail(createdUser.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Org Members with assigned attributes", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let membershipFixtures: MembershipRepositoryFixture;
|
||||
let teamsRepositoryFixtures: TeamRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let attributeRepositoryFixture: AttributeRepositoryFixture;
|
||||
|
||||
const authEmail = `organizations-users-auth-${randomString()}@api.com`;
|
||||
const user2Email = `organizations-users2-auth-${randomString()}@api.com`;
|
||||
|
||||
let user: User;
|
||||
let user2: User;
|
||||
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let assignedOption1: AttributeOption;
|
||||
let assignedOption2: AttributeOption;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
authEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef);
|
||||
|
||||
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `organizations-users-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } });
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: authEmail,
|
||||
username: authEmail,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: authEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
|
||||
|
||||
await membershipFixtures.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
team: { connect: { id: team.id } },
|
||||
user: { connect: { id: user.id } },
|
||||
});
|
||||
|
||||
user2 = await userRepositoryFixture.create({
|
||||
email: user2Email,
|
||||
username: user2Email,
|
||||
organization: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user2.id}`,
|
||||
username: user2Email,
|
||||
organization: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user2.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const membership2 = await membershipFixtures.addUserToOrg(user2, org, "ADMIN", true);
|
||||
|
||||
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
|
||||
const attribute = await attributeRepositoryFixture.create({
|
||||
name: "Test Attribute",
|
||||
team: { connect: { id: org.id } },
|
||||
type: "TEXT",
|
||||
slug: `test-attribute-${randomString()}`,
|
||||
});
|
||||
|
||||
const attribute2 = await attributeRepositoryFixture.create({
|
||||
name: "Test Attribute 2",
|
||||
team: { connect: { id: org.id } },
|
||||
type: "TEXT",
|
||||
slug: `test-attribute-2-${randomString()}`,
|
||||
});
|
||||
const attributeId = attribute.id;
|
||||
|
||||
assignedOption1 = await attributeRepositoryFixture.createOption({
|
||||
slug: "option1",
|
||||
value: "option1",
|
||||
attribute: { connect: { id: attribute.id } },
|
||||
assignedUsers: {
|
||||
create: {
|
||||
memberId: membership.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assignedOption2 = await attributeRepositoryFixture.createOption({
|
||||
slug: "optionA",
|
||||
value: "optionA",
|
||||
attribute: { connect: { id: attribute2.id } },
|
||||
assignedUsers: { createMany: { data: [{ memberId: membership.id }, { memberId: membership2.id }] } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(userRepositoryFixture).toBeDefined();
|
||||
expect(organizationsRepositoryFixture).toBeDefined();
|
||||
expect(user).toBeDefined();
|
||||
expect(org).toBeDefined();
|
||||
});
|
||||
|
||||
it("should get users with all specified assigned attribute options", async () => {
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/users`)
|
||||
.query({
|
||||
assignedOptionIds: [assignedOption1.id],
|
||||
attributeQueryOperator: "AND",
|
||||
} as GetOrganizationsUsersInput)
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(1);
|
||||
|
||||
const userWithAssignedOptions = userData.find(
|
||||
(u: GetOrgUsersWithProfileOutput) => u.email === user.email
|
||||
);
|
||||
expect(userWithAssignedOptions).toBeDefined();
|
||||
expect(userWithAssignedOptions?.email).toBe(user.email);
|
||||
expect(userWithAssignedOptions?.profile.username).toBe(user.username);
|
||||
});
|
||||
|
||||
it("should get users with at least one of the specified assigned attribute options", async () => {
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/users`)
|
||||
.query({
|
||||
assignedOptionIds: [assignedOption1.id, assignedOption2.id],
|
||||
attributeQueryOperator: "OR",
|
||||
} as GetOrganizationsUsersInput)
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(2);
|
||||
|
||||
const userWithAssignedOptions = userData.find(
|
||||
(u: GetOrgUsersWithProfileOutput) => u.email === user.email
|
||||
);
|
||||
expect(userWithAssignedOptions).toBeDefined();
|
||||
expect(userWithAssignedOptions?.email).toBe(user.email);
|
||||
expect(userWithAssignedOptions?.profile.username).toBe(user.username);
|
||||
|
||||
const userWithAssignedOptions2 = userData.find(
|
||||
(u: GetOrgUsersWithProfileOutput) => u.email === user2.email
|
||||
);
|
||||
expect(userWithAssignedOptions2).toBeDefined();
|
||||
expect(userWithAssignedOptions2?.email).toBe(user2.email);
|
||||
expect(userWithAssignedOptions2?.profile.username).toBe(user2.username);
|
||||
});
|
||||
|
||||
it("should get users with at least one of the specified assigned attribute options filtered by teams", async () => {
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/users`)
|
||||
.query({
|
||||
assignedOptionIds: [assignedOption1.id, assignedOption2.id],
|
||||
attributeQueryOperator: "OR",
|
||||
teamIds: [team.id],
|
||||
} as GetOrganizationsUsersInput)
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(1);
|
||||
|
||||
const userWithAssignedOptions = userData.find(
|
||||
(u: GetOrgUsersWithProfileOutput) => u.email === user.email
|
||||
);
|
||||
expect(userWithAssignedOptions).toBeDefined();
|
||||
expect(userWithAssignedOptions?.email).toBe(user.email);
|
||||
expect(userWithAssignedOptions?.profile.username).toBe(user.username);
|
||||
});
|
||||
|
||||
it("should get users with none of the specified assigned attribute options", async () => {
|
||||
const { body } = await request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/users`)
|
||||
.query({
|
||||
assignedOptionIds: [assignedOption1.id, assignedOption2.id],
|
||||
attributeQueryOperator: "NONE",
|
||||
} as GetOrganizationsUsersInput)
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(0);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// await membershipFixtures.delete(membership.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
+50
-1
@@ -1,3 +1,52 @@
|
||||
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsOptional, IsArray, ArrayMinSize, IsString, IsIn, IsNumber } from "class-validator";
|
||||
|
||||
export class GetOrganizationsUsersInput extends GetUsersInput {}
|
||||
export class GetOrganizationsUsersInput extends GetUsersInput {
|
||||
@Expose()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((optId: string) => optId);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: "Filter by assigned attribute option ids. ids must be separated by a comma.",
|
||||
example: "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMinSize(1, { message: "assignedOptionIds must contain at least 1 attribute option id" })
|
||||
assignedOptionIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Query operator used to filter assigned options, AND by default.",
|
||||
example: "NONE",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(["OR", "AND", "NONE"])
|
||||
attributeQueryOperator: "AND" | "OR" | "NONE" = "AND"; // Default value
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((teamId: string) => parseInt(teamId));
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@ApiPropertyOptional({
|
||||
type: [Number],
|
||||
description: "Filter by teamIds. Team ids must be separated by a comma.",
|
||||
example: "?teamIds=100,200",
|
||||
})
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
|
||||
teamIds?: number[];
|
||||
}
|
||||
|
||||
+54
-1
@@ -17,11 +17,64 @@ export class OrganizationsUsersRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getOrganizationUsersByEmails(orgId: number, emailArray?: string[], skip?: number, take?: number) {
|
||||
async getOrganizationUsersByEmailsAndAttributeFilters(
|
||||
orgId: number,
|
||||
filters: {
|
||||
teamIds?: number[];
|
||||
assignedOptionIds: string[];
|
||||
attributeQueryOperator?: "AND" | "OR" | "NONE";
|
||||
},
|
||||
emailArray?: string[],
|
||||
skip?: number,
|
||||
take?: number
|
||||
) {
|
||||
const { teamIds, assignedOptionIds, attributeQueryOperator } = filters ?? {};
|
||||
const attributeToUsersWithProfile = await this.dbRead.prisma.attributeToUser.findMany({
|
||||
include: {
|
||||
member: { include: { user: { include: { profiles: { where: { organizationId: orgId } } } } } },
|
||||
},
|
||||
distinct: ["memberId"],
|
||||
where: {
|
||||
member: {
|
||||
teamId: orgId,
|
||||
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
|
||||
// Filter to only get users which have ALL of the assigned attribue options
|
||||
...(attributeQueryOperator === "AND" && {
|
||||
AND: assignedOptionIds.map((optionId) => ({
|
||||
AttributeToUser: { some: { attributeOptionId: optionId } },
|
||||
})),
|
||||
}),
|
||||
},
|
||||
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
||||
// Filter to get users which have AT LEAST ONE of the assigned attribue options
|
||||
...(attributeQueryOperator === "OR" && {
|
||||
attributeOption: { id: { in: assignedOptionIds } },
|
||||
}),
|
||||
// Filter to get users that have NONE the assigned attribue options
|
||||
...(attributeQueryOperator === "NONE" && {
|
||||
NOT: {
|
||||
attributeOption: { id: { in: assignedOptionIds } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
return attributeToUsersWithProfile.map((attributeToUser) => attributeToUser.member.user);
|
||||
}
|
||||
|
||||
async getOrganizationUsersByEmails(
|
||||
orgId: number,
|
||||
emailArray?: string[],
|
||||
teamIds?: number[],
|
||||
skip?: number,
|
||||
take?: number
|
||||
) {
|
||||
return await this.dbRead.prisma.user.findMany({
|
||||
where: {
|
||||
...this.filterOnOrgMembership(orgId),
|
||||
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
||||
...(teamIds && { teams: { some: { teamId: { in: teamIds } } } }),
|
||||
},
|
||||
include: {
|
||||
profiles: {
|
||||
|
||||
+26
-1
@@ -16,12 +16,37 @@ export class OrganizationsUsersService {
|
||||
private readonly emailService: EmailService
|
||||
) {}
|
||||
|
||||
async getUsers(orgId: number, emailInput?: string[], skip?: number, take?: number) {
|
||||
async getUsers(
|
||||
orgId: number,
|
||||
emailInput?: string[],
|
||||
filters?: {
|
||||
teamIds?: number[];
|
||||
assignedOptionIds?: string[];
|
||||
attributeQueryOperator?: "AND" | "OR" | "NONE";
|
||||
},
|
||||
skip?: number,
|
||||
take?: number
|
||||
) {
|
||||
const emailArray = !emailInput ? [] : emailInput;
|
||||
|
||||
if (filters?.assignedOptionIds && filters?.assignedOptionIds?.length) {
|
||||
return await this.organizationsUsersRepository.getOrganizationUsersByEmailsAndAttributeFilters(
|
||||
orgId,
|
||||
{
|
||||
assignedOptionIds: filters.assignedOptionIds,
|
||||
attributeQueryOperator: filters?.attributeQueryOperator ?? "AND",
|
||||
teamIds: filters?.teamIds,
|
||||
},
|
||||
emailArray,
|
||||
skip,
|
||||
take
|
||||
);
|
||||
}
|
||||
|
||||
const users = await this.organizationsUsersRepository.getOrganizationUsersByEmails(
|
||||
orgId,
|
||||
emailArray,
|
||||
filters?.teamIds,
|
||||
skip,
|
||||
take
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ export class OrgUsersOOORepository {
|
||||
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" },
|
||||
filters?: { email?: string }
|
||||
) {
|
||||
console.log({ sort, filters });
|
||||
return this.dbRead.prisma.outOfOfficeEntry.findMany({
|
||||
where: {
|
||||
user: {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { ResponseSlotsOutput } from "@/modules/routing-forms/outputs/response-slots.output";
|
||||
import { RoutingFormsService } from "@/modules/routing-forms/services/routing-forms.service";
|
||||
import { Controller, HttpCode, HttpStatus, Param, Post, Query, Req } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/routing-forms/:routingFormId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@ApiTags("Routing forms")
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class RoutingFormsController {
|
||||
constructor(private readonly routingFormsService: RoutingFormsService) {}
|
||||
|
||||
@Post("/calculate-slots")
|
||||
@ApiOperation({
|
||||
summary: "Calculate slots based on routing form response",
|
||||
description:
|
||||
"It will not actually save the response just return the routed event type and slots when it can be booked.",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async calculateSlotsBasedOnRoutingFormResponse(
|
||||
@Req() request: Request,
|
||||
@Query() query: GetAvailableSlotsInput_2024_09_04,
|
||||
@Param("routingFormId") routingFormId: string
|
||||
): Promise<ResponseSlotsOutput> {
|
||||
const responseSlots = await this.routingFormsService.calculateSlotsBasedOnRoutingFormResponse(
|
||||
request,
|
||||
routingFormId,
|
||||
query
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: responseSlots,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import {
|
||||
ResponseSlotsOutput,
|
||||
ResponseSlotsOutputData,
|
||||
} from "@/modules/routing-forms/outputs/response-slots.output";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { App_RoutingForms_Form, EventType, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { RoutingFormsRepositoryFixture } from "test/fixtures/repository/routing-forms.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Routing forms endpoints", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let routingFormsRepositoryFixture: RoutingFormsRepositoryFixture;
|
||||
|
||||
const teammateEmailTwo = `routing-forms-teammate-1-${randomString()}`;
|
||||
const teammateEmailOne = `routing-forms-teammate-2-${randomString()}`;
|
||||
|
||||
let team: Team;
|
||||
let teammateOne: User;
|
||||
let teammateTwo: User;
|
||||
let collectiveEventTypeFootball: EventType;
|
||||
let collectiveEventTypeBasketball: EventType;
|
||||
|
||||
let routingForm: App_RoutingForms_Form;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
AppModule,
|
||||
PrismaModule,
|
||||
UsersModule,
|
||||
TokensModule,
|
||||
SchedulesModule_2024_06_11,
|
||||
SlotsModule_2024_09_04,
|
||||
],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
routingFormsRepositoryFixture = new RoutingFormsRepositoryFixture(moduleRef);
|
||||
|
||||
teammateOne = await userRepositoryFixture.create({
|
||||
email: teammateEmailOne,
|
||||
name: teammateEmailOne,
|
||||
username: teammateEmailOne,
|
||||
});
|
||||
|
||||
teammateTwo = await userRepositoryFixture.create({
|
||||
email: teammateEmailTwo,
|
||||
name: teammateEmailTwo,
|
||||
username: teammateEmailTwo,
|
||||
});
|
||||
|
||||
const teamSlug = `routing-forms-team-${randomString()}`;
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: teamSlug,
|
||||
slug: teamSlug,
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teammateOne.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teammateTwo.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const collectiveEventTypeFootballSlug = `routing-forms-collective-event-type-football-${randomString()}`;
|
||||
collectiveEventTypeFootball = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "Collective Event Type Football",
|
||||
slug: collectiveEventTypeFootballSlug,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
schedule: {
|
||||
create: {
|
||||
name: "football",
|
||||
timeZone: "Europe/London",
|
||||
user: { connect: { id: teammateOne.id } },
|
||||
availability: {
|
||||
create: {
|
||||
days: [1],
|
||||
startTime: new Date("1970-01-01T09:00:00Z"),
|
||||
endTime: new Date("1970-01-01T11:00:00Z"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
|
||||
},
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teammateOne.id,
|
||||
isFixed: true,
|
||||
},
|
||||
{
|
||||
userId: teammateTwo.id,
|
||||
isFixed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const collectiveEventTypeBasketballSlug = `routing-forms-collective-event-type-basketball-${randomString()}`;
|
||||
collectiveEventTypeBasketball = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "Collective Event Type Basketball",
|
||||
slug: collectiveEventTypeBasketballSlug,
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
schedule: {
|
||||
create: {
|
||||
name: "basketball",
|
||||
timeZone: "Europe/London",
|
||||
user: { connect: { id: teammateTwo.id } },
|
||||
availability: {
|
||||
create: {
|
||||
days: [2],
|
||||
startTime: new Date("1970-01-01T11:00:00Z"),
|
||||
endTime: new Date("1970-01-01T13:00:00Z"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
|
||||
},
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: teammateOne.id,
|
||||
isFixed: true,
|
||||
},
|
||||
{
|
||||
userId: teammateTwo.id,
|
||||
isFixed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
routingForm = await routingFormsRepositoryFixture.create({
|
||||
name: "football or basketball",
|
||||
description: "Do you want to play football or basketball?",
|
||||
routes: [
|
||||
{
|
||||
id: "baba89aa-4567-489a-bcde-f1961b1ae10c",
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
value: `team/${team.slug}/${collectiveEventTypeFootballSlug}`,
|
||||
eventTypeId: collectiveEventTypeFootball.id,
|
||||
},
|
||||
queryValue: {
|
||||
id: "a8a8b9aa-0123-4456-b89a-b1961b1ae10c",
|
||||
type: "group",
|
||||
children1: {
|
||||
"99ab8aaa-4567-489a-bcde-f1961b1ae5cd": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "dcd8b978-fa11-47be-801a-5ca2cd4ec16e",
|
||||
value: ["755c11d1-fa46-4d03-8e15-d2c4ab3407d7"],
|
||||
operator: "select_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["select"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributesQueryValue: { id: "b8998aab-cdef-4012-b456-71961b1ae10c", type: "group" },
|
||||
attributeRoutingConfig: {},
|
||||
fallbackAttributesQueryValue: { id: "ab98888a-89ab-4cde-b012-31961b1ae10c", type: "group" },
|
||||
},
|
||||
{
|
||||
id: "88a98b98-0123-4456-b89a-b1961b1b1a04",
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
value: `team/${team.slug}/${collectiveEventTypeBasketballSlug}`,
|
||||
eventTypeId: collectiveEventTypeBasketball.id,
|
||||
},
|
||||
queryValue: {
|
||||
id: "bab8b9a8-cdef-4012-b456-71961b1b1a04",
|
||||
type: "group",
|
||||
children1: {
|
||||
"9988a98a-0123-4456-b89a-b1961b1b1f8b": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "dcd8b978-fa11-47be-801a-5ca2cd4ec16e",
|
||||
value: ["4cbb1537-0ab9-4286-b4bf-08b08ff23372"],
|
||||
operator: "select_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["select"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributesQueryValue: { id: "99a89a9b-89ab-4cde-b012-31961b1b1a04", type: "group" },
|
||||
attributeRoutingConfig: {},
|
||||
fallbackAttributesQueryValue: { id: "88989a98-4567-489a-bcde-f1961b1b1a04", type: "group" },
|
||||
},
|
||||
{
|
||||
id: "9b9ab9bb-cdef-4012-b456-71961b1a8b78",
|
||||
action: {
|
||||
type: "customPageMessage",
|
||||
value: "Thank you for your interest! We will be in touch soon.",
|
||||
},
|
||||
isFallback: true,
|
||||
queryValue: { id: "9b9ab9bb-cdef-4012-b456-71961b1a8b78", type: "group" },
|
||||
attributesQueryValue: { id: "88b988ab-cdef-4012-b456-71961b1ad81a", type: "group" },
|
||||
fallbackAttributesQueryValue: { id: "a8b89a9a-89ab-4cde-b012-31961b1ad81a", type: "group" },
|
||||
},
|
||||
],
|
||||
createdAt: new Date("2025-04-09T15:10:46.651Z"),
|
||||
updatedAt: new Date("2025-04-09T15:11:37.696Z"),
|
||||
fields: [
|
||||
{
|
||||
id: "dcd8b978-fa11-47be-801a-5ca2cd4ec16e",
|
||||
type: "select",
|
||||
label: "sport",
|
||||
options: [
|
||||
{ id: "755c11d1-fa46-4d03-8e15-d2c4ab3407d7", label: "football" },
|
||||
{ id: "4cbb1537-0ab9-4286-b4bf-08b08ff23372", label: "basketball" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
connect: { id: teammateOne.id },
|
||||
},
|
||||
disabled: false,
|
||||
settings: { emailOwnerOnSubmission: true },
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
position: 0,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
describe("Calculate slots", () => {
|
||||
const slotsQuery = "start=2060-01-12&end=2060-01-16";
|
||||
|
||||
it("should return correct slots for option 1", async () => {
|
||||
const body = {
|
||||
sport: "football",
|
||||
};
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/routing-forms/${routingForm.id}/calculate-slots?${slotsQuery}`)
|
||||
.send(body)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ResponseSlotsOutput = reserveResponse.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseBodyData: ResponseSlotsOutputData = responseBody.data;
|
||||
expect(responseBodyData.eventTypeId).toEqual(collectiveEventTypeFootball.id);
|
||||
expect(responseBodyData.slots).toEqual({
|
||||
"2060-01-12": [
|
||||
{
|
||||
start: "2060-01-12T09:00:00.000Z",
|
||||
},
|
||||
{
|
||||
start: "2060-01-12T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return correct slots for option 2", async () => {
|
||||
const body = {
|
||||
sport: "basketball",
|
||||
};
|
||||
|
||||
const reserveResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/routing-forms/${routingForm.id}/calculate-slots?${slotsQuery}`)
|
||||
.send(body)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ResponseSlotsOutput = reserveResponse.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseBodyData: ResponseSlotsOutputData = responseBody.data;
|
||||
expect(responseBodyData.eventTypeId).toEqual(collectiveEventTypeBasketball.id);
|
||||
expect(responseBodyData.slots).toEqual({
|
||||
"2060-01-13": [
|
||||
{
|
||||
start: "2060-01-13T11:00:00.000Z",
|
||||
},
|
||||
{
|
||||
start: "2060-01-13T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(teammateOne.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammateTwo.email);
|
||||
await teamRepositoryFixture.delete(team.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNumber, ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, SlotsOutput_2024_09_04 } from "@calcom/platform-types";
|
||||
import { RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04)
|
||||
export class ResponseSlotsOutputData {
|
||||
@IsNumber()
|
||||
@ApiProperty()
|
||||
eventTypeId!: number;
|
||||
|
||||
@ValidateNested()
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(SlotsOutput_2024_09_04) },
|
||||
{ $ref: getSchemaPath(RangeSlotsOutput_2024_09_04) },
|
||||
],
|
||||
})
|
||||
@Type(() => Object)
|
||||
slots!: SlotsOutput_2024_09_04 | RangeSlotsOutput_2024_09_04;
|
||||
}
|
||||
|
||||
export class ResponseSlotsOutput extends ApiResponseWithoutData {
|
||||
@ValidateNested()
|
||||
@ApiProperty({
|
||||
type: ResponseSlotsOutputData,
|
||||
})
|
||||
@Type(() => ResponseSlotsOutputData)
|
||||
data!: ResponseSlotsOutputData;
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RoutingFormsController } from "@/modules/routing-forms/controllers/routing-forms.controller";
|
||||
import { RoutingFormsRepository } from "@/modules/routing-forms/routing-forms.repository";
|
||||
import { RoutingFormsService } from "@/modules/routing-forms/services/routing-forms.service";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [RoutingFormsRepository],
|
||||
imports: [PrismaModule, TeamsEventTypesModule, SlotsModule_2024_09_04, StripeModule],
|
||||
providers: [RoutingFormsRepository, RoutingFormsService, OrganizationsRepository],
|
||||
controllers: [RoutingFormsController],
|
||||
exports: [RoutingFormsRepository],
|
||||
})
|
||||
export class RoutingFormsModule {}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { getRoutedUrl } from "@calcom/platform-libraries";
|
||||
import { ById_2024_09_04_type, GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class RoutingFormsService {
|
||||
constructor(
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly slotsService: SlotsService_2024_09_04
|
||||
) {}
|
||||
|
||||
async calculateSlotsBasedOnRoutingFormResponse(
|
||||
request: Request,
|
||||
formId: string,
|
||||
slotsQuery: GetAvailableSlotsInput_2024_09_04
|
||||
) {
|
||||
const eventTypeId = await this.getRoutedEventTypeId(request, formId);
|
||||
|
||||
if (!eventTypeId) {
|
||||
throw new NotFoundException("Event type not found.");
|
||||
}
|
||||
|
||||
const slots = await this.slotsService.getAvailableSlots({
|
||||
type: ById_2024_09_04_type,
|
||||
eventTypeId,
|
||||
...slotsQuery,
|
||||
});
|
||||
|
||||
return {
|
||||
eventTypeId,
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
private async getRoutedEventTypeId(request: Request, formId: string) {
|
||||
const routingUrl = await this.getRoutingUrl(request, formId);
|
||||
if (!this.isEventTypeRedirectUrl(routingUrl)) {
|
||||
throw new NotFoundException("Routed to a non cal.com event type URL.");
|
||||
}
|
||||
|
||||
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
|
||||
|
||||
const eventType = await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug);
|
||||
return eventType?.id;
|
||||
}
|
||||
|
||||
private async getRoutingUrl(request: Request, formId: string) {
|
||||
const params = Object.fromEntries(new URLSearchParams(request.body));
|
||||
const routedUrlData = await getRoutedUrl({
|
||||
req: request,
|
||||
query: { ...params, "cal.isBookingDryRun": "true", form: formId },
|
||||
});
|
||||
|
||||
const destination = routedUrlData?.redirect?.destination;
|
||||
|
||||
if (!destination) {
|
||||
throw new NotFoundException("Route to which the form response should be redirected not found.");
|
||||
}
|
||||
|
||||
return new URL(destination);
|
||||
}
|
||||
|
||||
private extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl: URL) {
|
||||
const eventTypeSlug = this.extractEventTypeFromRoutedUrl(routingUrl);
|
||||
const teamId = this.extractTeamIdFromRoutedUrl(routingUrl);
|
||||
|
||||
if (!teamId) {
|
||||
throw new NotFoundException("Team ID not found in the routed URL.");
|
||||
}
|
||||
|
||||
if (!eventTypeSlug) {
|
||||
throw new NotFoundException("Event type slug not found in the routed URL.");
|
||||
}
|
||||
|
||||
return { teamId, eventTypeSlug };
|
||||
}
|
||||
|
||||
private isEventTypeRedirectUrl(routingUrl: URL) {
|
||||
const routingSearchParams = routingUrl.searchParams;
|
||||
return routingSearchParams.get("cal.action") === "eventTypeRedirectUrl";
|
||||
}
|
||||
|
||||
private extractTeamIdFromRoutedUrl(routingUrl: URL) {
|
||||
const routingSearchParams = routingUrl.searchParams;
|
||||
return Number(routingSearchParams.get("cal.teamId"));
|
||||
}
|
||||
|
||||
private extractEventTypeFromRoutedUrl(routingUrl: URL) {
|
||||
const pathNameParams = routingUrl.pathname.split("/");
|
||||
return pathNameParams[pathNameParams.length - 1];
|
||||
}
|
||||
}
|
||||
+34
-2
@@ -49,11 +49,14 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
|
||||
const nonOrgUserEmailOne = `slots-2024-09-04-non-org-user-one-${randomString()}@api.com`;
|
||||
|
||||
const orgSlug = `slots-2024-09-04-organization-${randomString()}`;
|
||||
let organization: Team;
|
||||
const teamSlug = `slots-2024-09-04-organization-team-${randomString()}`;
|
||||
let team: Team;
|
||||
let orgUserOne: User;
|
||||
let orgUserTwo: User;
|
||||
let collectiveEventTypeId: number;
|
||||
let collectiveEventTypeSlug: string;
|
||||
let roundRobinEventTypeId: number;
|
||||
let collectiveBookingId: number;
|
||||
let roundRobinBookingId: number;
|
||||
@@ -91,7 +94,6 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
|
||||
const orgSlug = `slots-2024-09-04-organization-${randomString()}`;
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: orgSlug,
|
||||
isOrganization: true,
|
||||
@@ -143,7 +145,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `slots-2024-09-04-team-${randomString()}`,
|
||||
name: teamSlug,
|
||||
slug: teamSlug,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
});
|
||||
@@ -178,6 +181,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
},
|
||||
});
|
||||
collectiveEventTypeId = collectiveEventType.id;
|
||||
collectiveEventTypeSlug = collectiveEventType.slug;
|
||||
|
||||
const roundRobinEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
@@ -282,6 +286,34 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get collective team event slots in UTC using teamSlug, eventTypeSlug and organizationSlug", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?organizationSlug=${orgSlug}&teamSlug=${teamSlug}&eventTypeSlug=${collectiveEventTypeSlug}&start=2050-09-05&end=2050-09-09`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not get collective team event slots in UTC using teamSlug, eventTypeSlug if organizationSlug is missing", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?teamSlug=${teamSlug}&eventTypeSlug=${collectiveEventTypeSlug}&start=2050-09-05&end=2050-09-09`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should get round robin team event slots in UTC", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
|
||||
+24
-1
@@ -54,10 +54,12 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
let outsider: User;
|
||||
let outsiderApiKeyString: string;
|
||||
|
||||
const teamSlug = `slots-2024-09-04-team-${randomString()}`;
|
||||
let team: Team;
|
||||
let teammateOne: User;
|
||||
let teammateTwo: User;
|
||||
let collectiveEventTypeId: number;
|
||||
let collectiveEventTypeSlug: string;
|
||||
let collectiveEventTypeWithoutHostsId: number;
|
||||
let roundRobinEventTypeId: number;
|
||||
let collectiveBookingId: number;
|
||||
@@ -121,7 +123,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
outsiderApiKeyString = unrelatedUserKeyString;
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
name: `slots-2024-09-04-team-${randomString()}`,
|
||||
name: teamSlug,
|
||||
slug: teamSlug,
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
@@ -167,6 +170,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
},
|
||||
});
|
||||
collectiveEventTypeId = collectiveEventType.id;
|
||||
collectiveEventTypeSlug = collectiveEventType.slug;
|
||||
|
||||
const collectiveEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
@@ -246,6 +250,25 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get collective team event slots in UTC using teamSlug and eventTypeSlug", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/slots?teamSlug=${teamSlug}&eventTypeSlug=${collectiveEventTypeSlug}&start=2050-09-05&end=2050-09-09`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const slots = responseBody.data;
|
||||
|
||||
expect(slots).toBeDefined();
|
||||
const days = Object.keys(slots);
|
||||
expect(days.length).toEqual(5);
|
||||
expect(slots).toEqual(expectedSlotsUTC);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get round robin team event slots in UTC", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
|
||||
|
||||
@@ -60,7 +60,7 @@ export class SlotsController_2024_09_04 {
|
||||
@ApiOperation({
|
||||
summary: "Find out when is an event type ready to be booked.",
|
||||
description: `
|
||||
There are 4 ways to get available slots:
|
||||
There are 4 ways to get available slots for event type of an individual user:
|
||||
|
||||
1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
@@ -70,6 +70,14 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.
|
||||
|
||||
And 3 ways to get available slots for team event type:
|
||||
|
||||
1. By team event type id. Example '/v2/slots?teamEventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
All of them require "start" and "end" query parameters which define the time range for which available slots should be checked.
|
||||
Optional parameters are:
|
||||
- timeZone: Time zone in which the available slots should be returned. Defaults to UTC.
|
||||
@@ -117,19 +125,28 @@ 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 and if relevant organizationSlug too.",
|
||||
"The slug of the event type for which available slots should be checked. If slug is provided then username or teamSlug must be provided too and if relevant organizationSlug too.",
|
||||
example: "event-type-slug",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "username",
|
||||
required: false,
|
||||
description: "The username of the user to get event types for.",
|
||||
description:
|
||||
"The username of the user who owns event type with eventTypeSlug - used when slots are checked for individual user event type.",
|
||||
example: "bob",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "teamSlug",
|
||||
required: false,
|
||||
description:
|
||||
"The slug of the team who owns event type with eventTypeSlug - used when slots are checked for team event type.",
|
||||
example: "team-slug",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "organizationSlug",
|
||||
required: false,
|
||||
description: "The slug of the organization to which user with username belongs.",
|
||||
description:
|
||||
"The slug of the organization to which user with username belongs or team with teamSlug belongs.",
|
||||
example: "org-slug",
|
||||
})
|
||||
@ApiQuery({
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { dynamicEvent } from "@calcom/platform-libraries";
|
||||
import { GetSlotsInput_2024_09_04 } from "@calcom/platform-types";
|
||||
import {
|
||||
ById_2024_09_04,
|
||||
ByUsernameAndEventTypeSlug_2024_09_04,
|
||||
ByTeamSlugAndEventTypeSlug_2024_09_04,
|
||||
GetSlotsInput_2024_09_04,
|
||||
ById_2024_09_04_type,
|
||||
ByUsernameAndEventTypeSlug_2024_09_04_type,
|
||||
ByTeamSlugAndEventTypeSlug_2024_09_04_type,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class SlotsInputService_2024_09_04 {
|
||||
@@ -14,7 +25,10 @@ export class SlotsInputService_2024_09_04 {
|
||||
private readonly eventTypeRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly organizationsUsersRepository: OrganizationsUsersRepository,
|
||||
private readonly organizationsRepository: OrganizationsRepository
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
) {}
|
||||
|
||||
async transformGetSlotsQuery(query: GetSlotsInput_2024_09_04) {
|
||||
@@ -47,11 +61,11 @@ export class SlotsInputService_2024_09_04 {
|
||||
}
|
||||
|
||||
private async getEventType(input: GetSlotsInput_2024_09_04) {
|
||||
if ("eventTypeId" in input) {
|
||||
if (input.type === ById_2024_09_04_type) {
|
||||
return this.eventTypeRepository.getEventTypeById(input.eventTypeId);
|
||||
}
|
||||
|
||||
if ("eventTypeSlug" in input) {
|
||||
if (input.type === ByUsernameAndEventTypeSlug_2024_09_04_type) {
|
||||
const user = await this.getEventTypeUser(input);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with username ${input.username} not found`);
|
||||
@@ -59,27 +73,48 @@ export class SlotsInputService_2024_09_04 {
|
||||
return this.eventTypeRepository.getUserEventTypeBySlug(user.id, input.eventTypeSlug);
|
||||
}
|
||||
|
||||
if (input.type === ByTeamSlugAndEventTypeSlug_2024_09_04_type) {
|
||||
const team = await this.getEventTypeTeam(input);
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team with slug ${input.teamSlug} not found`);
|
||||
}
|
||||
return this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(team.id, input.eventTypeSlug);
|
||||
}
|
||||
|
||||
return input.duration ? { ...dynamicEvent, length: input.duration } : dynamicEvent;
|
||||
}
|
||||
|
||||
private async getEventTypeUser(input: GetSlotsInput_2024_09_04) {
|
||||
if ("eventTypeSlug" in input) {
|
||||
if ("organizationSlug" in input && input.organizationSlug) {
|
||||
const organization = await this.organizationsRepository.findOrgBySlug(input.organizationSlug);
|
||||
if (!organization) {
|
||||
throw new NotFoundException(
|
||||
`slots-input.service.ts: Organization with slug ${input.organizationSlug} not found`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.organizationsUsersRepository.getOrganizationUserByUsername(
|
||||
organization.id,
|
||||
input.username
|
||||
);
|
||||
}
|
||||
|
||||
private async getEventTypeUser(input: ByUsernameAndEventTypeSlug_2024_09_04) {
|
||||
if (!input.organizationSlug) {
|
||||
return await this.usersRepository.findByUsername(input.username);
|
||||
}
|
||||
|
||||
const organization = await this.organizationsRepository.findOrgBySlug(input.organizationSlug);
|
||||
if (!organization) {
|
||||
throw new NotFoundException(
|
||||
`slots-input.service.ts: Organization with slug ${input.organizationSlug} not found`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.organizationsUsersRepository.getOrganizationUserByUsername(
|
||||
organization.id,
|
||||
input.username
|
||||
);
|
||||
}
|
||||
|
||||
private async getEventTypeTeam(input: ByTeamSlugAndEventTypeSlug_2024_09_04) {
|
||||
if (!input.organizationSlug) {
|
||||
return await this.teamsRepository.findTeamBySlug(input.teamSlug);
|
||||
}
|
||||
|
||||
const organization = await this.organizationsRepository.findOrgBySlug(input.organizationSlug);
|
||||
if (!organization) {
|
||||
throw new NotFoundException(
|
||||
`slots-input.service.ts: Organization with slug ${input.organizationSlug} not found`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.organizationsTeamsRepository.findOrgTeamBySlug(organization.id, input.teamSlug);
|
||||
}
|
||||
|
||||
private adjustStartTime(startTime: string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SlotsController_2024_09_04 } from "@/modules/slots/slots-2024-09-04/controllers/slots.controller";
|
||||
@@ -9,12 +10,20 @@ import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/
|
||||
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
|
||||
import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_06_14, StripeModule, TeamsModule, MembershipsModule],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
EventTypesModule_2024_06_14,
|
||||
StripeModule,
|
||||
TeamsModule,
|
||||
MembershipsModule,
|
||||
TeamsEventTypesModule,
|
||||
],
|
||||
providers: [
|
||||
SlotsRepository_2024_09_04,
|
||||
SlotsService_2024_09_04,
|
||||
@@ -23,6 +32,7 @@ import { Module } from "@nestjs/common";
|
||||
SlotsOutputService_2024_09_04,
|
||||
OrganizationsUsersRepository,
|
||||
OrganizationsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
],
|
||||
controllers: [SlotsController_2024_09_04],
|
||||
exports: [SlotsService_2024_09_04],
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
StripCredentialsCheckOutputResponseDto,
|
||||
StripCredentialsSaveOutputResponseDto,
|
||||
} from "@/modules/stripe/outputs/stripe.output";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { OAuthCallbackState, StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import {
|
||||
Controller,
|
||||
Query,
|
||||
@@ -22,8 +24,8 @@ import {
|
||||
Req,
|
||||
BadRequestException,
|
||||
Headers,
|
||||
Param,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiTags as DocsTags, ApiOperation, ApiHeader } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
@@ -37,7 +39,12 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
})
|
||||
@DocsTags("Stripe")
|
||||
export class StripeController {
|
||||
constructor(private readonly stripeService: StripeService) {}
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly config: ConfigService
|
||||
) {}
|
||||
|
||||
@Get("/connect")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -48,26 +55,20 @@ export class StripeController {
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Query("redir") redir?: string | null,
|
||||
@Query("errorRedir") errorRedir?: string | null,
|
||||
@Query("teamId") teamId?: string | null
|
||||
@Query("returnTo") returnTo?: string | null,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string | null
|
||||
): Promise<StripConnectOutputResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state = {
|
||||
onErrorReturnTo: !!errorRedir ? errorRedir : origin,
|
||||
const state: OAuthCallbackState = {
|
||||
onErrorReturnTo: !!onErrorReturnTo ? onErrorReturnTo : origin,
|
||||
fromApp: false,
|
||||
returnTo: !!redir ? redir : origin,
|
||||
returnTo: !!returnTo ? returnTo : origin,
|
||||
accessToken,
|
||||
teamId: Number(teamId) ?? null,
|
||||
};
|
||||
|
||||
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
|
||||
JSON.stringify(state),
|
||||
user.email,
|
||||
user.name
|
||||
);
|
||||
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(state, user.email, user.name);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -79,24 +80,71 @@ export class StripeController {
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
/**
|
||||
* Handles saving Stripe credentials.
|
||||
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
|
||||
* otherwise, credentials are saved at the user level.
|
||||
*
|
||||
* Proxying ensures that permission checks—such as whether the user is allowed to install Stripe for a team or organization—
|
||||
* are enforced via controller route guards, avoiding duplication of this logic within the service layer.
|
||||
*/
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined
|
||||
): Promise<StripCredentialsSaveOutputResponseDto> {
|
||||
const accessToken = JSON.parse(state).accessToken;
|
||||
|
||||
// user cancels flow
|
||||
if (error === "access_denied") {
|
||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||
if (!state) {
|
||||
throw new BadRequestException("Missing `state` query param");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(stringify({ error, error_description }));
|
||||
}
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
// If teamId is present, proxy to team endpoint
|
||||
if (decodedCallbackState.teamId && decodedCallbackState.orgId) {
|
||||
let url = "";
|
||||
const apiUrl = this.config.get("api.url");
|
||||
url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/stripe/save`;
|
||||
|
||||
return await this.stripeService.saveStripeAccount(state, code, accessToken);
|
||||
const params: Record<string, string | undefined> = { state, code, error, error_description };
|
||||
const headers = {
|
||||
Authorization: `Bearer ${decodedCallbackState.accessToken}`,
|
||||
};
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.get(url, { params, headers });
|
||||
const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: redirectUrl };
|
||||
} catch (err) {
|
||||
const fallbackUrl = decodedCallbackState.onErrorReturnTo || "";
|
||||
return { url: fallbackUrl };
|
||||
}
|
||||
}
|
||||
|
||||
// user-level fallback
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
|
||||
// user cancels flow
|
||||
if (error === "access_denied") {
|
||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(stringify({ error, error_description }));
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("Invalid Access token.");
|
||||
}
|
||||
|
||||
return await this.stripeService.saveStripeAccount(decodedCallbackState, code, userId);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
return {
|
||||
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/check")
|
||||
@@ -107,15 +155,4 @@ export class StripeController {
|
||||
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
|
||||
}
|
||||
|
||||
@Get("/check/:teamId")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Check team stripe connection" })
|
||||
async checkTeamStripeConnection(
|
||||
@Param("teamId") teamId: string
|
||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export class StripConnectOutputResponseDto {
|
||||
|
||||
export class StripCredentialsCheckOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS })
|
||||
status!: typeof SUCCESS_STATUS;
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
|
||||
export class StripCredentialsSaveOutputResponseDto {
|
||||
|
||||
@@ -6,11 +6,12 @@ import { StripeController } from "@/modules/stripe/controllers/stripe.controller
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, PrismaModule, UsersModule],
|
||||
imports: [ConfigModule, PrismaModule, UsersModule, HttpModule],
|
||||
exports: [StripeService],
|
||||
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
|
||||
controllers: [StripeController],
|
||||
|
||||
@@ -2,10 +2,8 @@ import { AppConfig } from "@/config/type";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Injectable,
|
||||
@@ -25,12 +23,13 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
|
||||
|
||||
import stringify = require("qs-stringify");
|
||||
|
||||
type IntegrationOAuthCallbackState = {
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
returnTo: string;
|
||||
onErrorReturnTo: string;
|
||||
fromApp: boolean;
|
||||
teamId?: number | null;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -45,8 +44,7 @@ export class StripeService {
|
||||
configService: ConfigService<AppConfig>,
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly membershipRepository: MembershipsRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {
|
||||
@@ -59,7 +57,7 @@ export class StripeService {
|
||||
return this.stripe;
|
||||
}
|
||||
|
||||
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
|
||||
async getStripeRedirectUrl(state: OAuthCallbackState, userEmail?: string, userName?: string | null) {
|
||||
const { client_id } = await this.getStripeAppKeys();
|
||||
|
||||
const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = {
|
||||
@@ -73,7 +71,7 @@ export class StripeService {
|
||||
country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined,
|
||||
},
|
||||
redirect_uri: this.redirectUri,
|
||||
state: state,
|
||||
state: JSON.stringify(state),
|
||||
};
|
||||
|
||||
const params = z.record(z.any()).parse(stripeConnectParams);
|
||||
@@ -99,10 +97,7 @@ export class StripeService {
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
|
||||
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
|
||||
|
||||
async saveStripeAccount(state: OAuthCallbackState, code: string, userId: number): Promise<{ url: string }> {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
@@ -118,17 +113,14 @@ export class StripeService {
|
||||
data["default_currency"] = account.default_currency;
|
||||
}
|
||||
|
||||
if (oAuthCallbackState.teamId) {
|
||||
await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
|
||||
const existingCredentials = await this.credentialsRepository.findAllCredentialsByTypeAndUserId(
|
||||
"stripe_payment",
|
||||
userId
|
||||
);
|
||||
|
||||
await this.appsRepository.createTeamAppCredential(
|
||||
"stripe_payment",
|
||||
data as unknown as Prisma.InputJsonObject,
|
||||
oAuthCallbackState.teamId,
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: getReturnToValueFromQueryState(state) };
|
||||
const credentialIdsToDelete = existingCredentials.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
@@ -138,11 +130,11 @@ export class StripeService {
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: getReturnToValueFromQueryState(state) };
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
const stripeCredentials = await this.credentialsRepository.findCredentialByTypeAndUserId(
|
||||
"stripe_payment",
|
||||
userId
|
||||
);
|
||||
@@ -150,24 +142,6 @@ export class StripeService {
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) {
|
||||
const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId);
|
||||
const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER";
|
||||
|
||||
if (!hasAdminAccessToTeam) {
|
||||
throw new BadRequestException("You must be team owner or admin to do this");
|
||||
}
|
||||
}
|
||||
|
||||
async validateStripeCredentials(
|
||||
credentials?: Credential | null
|
||||
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
|
||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
|
||||
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
|
||||
@@ -14,7 +12,7 @@ import { TeamsEventTypesService } from "@/modules/teams/event-types/services/tea
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,8 +22,7 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_06_14,
|
||||
UsersModule,
|
||||
TeamsModule,
|
||||
forwardRef(() => ConferencingModule),
|
||||
forwardRef(() => AuthModule),
|
||||
OrganizationsConferencingModule,
|
||||
],
|
||||
providers: [
|
||||
TeamsEventTypesRepository,
|
||||
@@ -34,7 +31,6 @@ import { forwardRef, Module } from "@nestjs/common";
|
||||
OrganizationsTeamsRepository,
|
||||
OutputTeamEventTypesResponsePipe,
|
||||
OutputOrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
],
|
||||
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
|
||||
controllers: [TeamsEventTypesController],
|
||||
|
||||
@@ -60,6 +60,18 @@ export class TeamsEventTypesRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeByTeamIdAndSlugWithOwnerAndTeam(teamId: number, eventTypeSlug: string) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: {
|
||||
teamId_slug: {
|
||||
teamId,
|
||||
slug: eventTypeSlug,
|
||||
},
|
||||
},
|
||||
include: { owner: true, team: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number) {
|
||||
return this.dbRead.prisma.eventType.findMany({
|
||||
where: {
|
||||
|
||||
@@ -110,4 +110,14 @@ export class TeamsRepository {
|
||||
where: { id: teamId },
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamBySlug(slug: string) {
|
||||
return this.dbRead.prisma.team.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
isOrganization: false,
|
||||
parentId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1136
-449
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,11 @@ const Layout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (session?.user?.id) SentrySetUser({ id: session.user.id });
|
||||
|
||||
return <Shell withoutMain={true}>{children}</Shell>;
|
||||
return (
|
||||
<>
|
||||
<Shell withoutMain={true}>{children}</Shell>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
|
||||
@@ -8,5 +8,5 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
export default function TroubleshooterLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,16 @@ import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
||||
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<div>
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading={t("insights")}
|
||||
subtitle={t("insights_subtitle")}
|
||||
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
|
||||
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
|
||||
</Shell>
|
||||
</div>
|
||||
<>
|
||||
<div>
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading={t("insights")}
|
||||
subtitle={t("insights_subtitle")}
|
||||
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
|
||||
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
|
||||
</Shell>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-11
@@ -1,14 +1,5 @@
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { SkeletonLoader } from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
import { SkeletonLoader } from "@calcom/features/webhooks/pages/webhooks-skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<SettingsHeader
|
||||
title="Webhooks"
|
||||
description="Receive meeting data in real-time when something happens in Cal.com"
|
||||
borderInShellHeader={true}
|
||||
CTA={null}>
|
||||
<SkeletonLoader />
|
||||
</SettingsHeader>
|
||||
);
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
|
||||
+2
-13
@@ -1,11 +1,9 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { CreateNewWebhookButton } from "@calcom/features/webhooks/components";
|
||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
@@ -25,20 +23,11 @@ const WebhooksViewServerWrapper = async () => {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
const caller = await createRouterCaller(webhookRouter);
|
||||
const data = await caller.getByViewer();
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("webhooks")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
CTA={data && data.webhookGroups.length > 0 ? <CreateNewWebhookButton isAdmin={isAdmin} /> : null}
|
||||
borderInShellHeader={(data && data.profiles.length === 1) || !data?.webhookGroups?.length}>
|
||||
<WebhooksView data={data} isAdmin={isAdmin} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
||||
};
|
||||
|
||||
export default WebhooksViewServerWrapper;
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { SkeletonLoader } from "@calcom/features/webhooks/pages/webhook-new-skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
+15
-16
@@ -1,9 +1,10 @@
|
||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import { Suspense } from "react";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { NewWebhookView, SkeletonLoader } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -12,19 +13,17 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const [appsCaller, webhookCaller] = await Promise.all([
|
||||
createRouterCaller(appsRouter),
|
||||
createRouterCaller(webhookRouter),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("add_webhook")}
|
||||
description={t("add_webhook_description", { appName: APP_NAME })}
|
||||
borderInShellHeader={true}
|
||||
backButton>
|
||||
<Suspense fallback={<SkeletonLoader />}>
|
||||
<NewWebhookView />
|
||||
</Suspense>
|
||||
</SettingsHeader>
|
||||
);
|
||||
const [installedApps, webhooks] = await Promise.all([
|
||||
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
||||
webhookCaller.list(),
|
||||
]);
|
||||
|
||||
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -30,5 +30,9 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutAppDirPr
|
||||
});
|
||||
} catch (err) {}
|
||||
|
||||
return <SettingsLayoutAppDirClient {...props} currentOrg={currentOrg} otherTeams={otherTeams} />;
|
||||
return (
|
||||
<>
|
||||
<SettingsLayoutAppDirClient {...props} currentOrg={currentOrg} otherTeams={otherTeams} />;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,6 +119,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
|
||||
return (
|
||||
<html
|
||||
className="notranslate"
|
||||
translate="no"
|
||||
lang={locale}
|
||||
dir={direction}
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}
|
||||
|
||||
@@ -10,7 +10,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import "@calcom/dayjs/locales";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import { formatTime } from "@calcom/lib/dayjs";
|
||||
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
@@ -97,7 +97,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
// If booking is already CANCELLED or REJECTED, we can't reschedule this booking. Take the user to the booking page which would show it's correct status and other details.
|
||||
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
||||
|
||||
const enrichedBookingUser = booking.user
|
||||
? await UserRepository.enrichUserWithItsProfile({ user: booking.user })
|
||||
: null;
|
||||
|
||||
const eventUrl = await buildEventUrlFromBooking({
|
||||
eventType,
|
||||
dynamicGroupSlugRef: booking.dynamicGroupSlugRef ?? null,
|
||||
profileEnrichedBookingUser: enrichedBookingUser,
|
||||
});
|
||||
|
||||
// If booking is already REJECTED, we can't reschedule this booking. Take the user to the booking page which would show it's correct status and other details.
|
||||
// If the booking is CANCELLED and allowRescheduleForCancelledBooking is false, we redirect the user to the original event link.
|
||||
// A booking that has been rescheduled to a new booking will also have a status of CANCELLED
|
||||
const isDisabledRescheduling = booking.eventType?.disableRescheduling;
|
||||
if (
|
||||
@@ -107,7 +120,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/booking/${uid}`,
|
||||
destination: booking.status === BookingStatus.CANCELLED ? eventUrl : `/booking/${uid}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
@@ -122,18 +135,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
||||
|
||||
const enrichedBookingUser = booking.user
|
||||
? await UserRepository.enrichUserWithItsProfile({ user: booking.user })
|
||||
: null;
|
||||
|
||||
const eventUrl = await buildEventUrlFromBooking({
|
||||
eventType,
|
||||
dynamicGroupSlugRef: booking.dynamicGroupSlugRef ?? null,
|
||||
profileEnrichedBookingUser: enrichedBookingUser,
|
||||
});
|
||||
|
||||
const isBookingInPast = booking.endTime && new Date(booking.endTime) < new Date();
|
||||
if (isBookingInPast && !eventType.allowReschedulingPastBookings) {
|
||||
const destinationUrlSearchParams = new URLSearchParams();
|
||||
@@ -184,6 +185,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
destinationUrlSearchParams.set("rescheduleUid", seatReferenceUid || bookingUid);
|
||||
|
||||
if (allowRescheduleForCancelledBooking) {
|
||||
destinationUrlSearchParams.set("allowRescheduleForCancelledBooking", "true");
|
||||
}
|
||||
|
||||
// TODO: I think we should just forward all the query params here including coep flag
|
||||
if (coepFlag) {
|
||||
destinationUrlSearchParams.set("flag.coep", coepFlag as string);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
@@ -9,7 +10,7 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/client";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
@@ -24,6 +25,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const session = await getServerSession({ req });
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
|
||||
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
@@ -61,7 +63,18 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
|
||||
const name = team.parent?.name ?? team.name ?? null;
|
||||
|
||||
const booking = rescheduleUid ? await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : null;
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
if (booking?.status === BookingStatus.CANCELLED && !allowRescheduleForCancelledBooking) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/team/${teamSlug}/${meetingSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
|
||||
|
||||
@@ -217,8 +217,6 @@ export const config = {
|
||||
"/org/:orgSlug",
|
||||
"/team/:slug/:type",
|
||||
"/team/:slug",
|
||||
"/:user/:type",
|
||||
"/:user",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Apps({ apps, category }: CategoryDataProps) {
|
||||
</Link>
|
||||
{category && (
|
||||
<span className="text-default gap-1">
|
||||
<span>{t("slash_separator")}</span>
|
||||
<span> / </span>
|
||||
{t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -216,9 +216,9 @@ export const AvailabilityCTA = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToggleGroup
|
||||
className="min-h-10 hidden md:flex md:items-center"
|
||||
className="hidden h-fit md:block"
|
||||
defaultValue={searchParams?.get("type") ?? "mine"}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
|
||||
@@ -28,11 +28,7 @@ import {
|
||||
} from "@calcom/features/bookings/lib/SystemField";
|
||||
import { getCalendarLinks, CalendarLinkType } from "@calcom/lib/bookings/getCalendarLinks";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import {
|
||||
formatToLocalizedDate,
|
||||
formatToLocalizedTime,
|
||||
formatToLocalizedTimezone,
|
||||
} from "@calcom/lib/date-fns";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime, formatToLocalizedTimezone } from "@calcom/lib/dayjs";
|
||||
import type { nameObjectSchema } from "@calcom/lib/event";
|
||||
import { getEventName } from "@calcom/lib/event";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { formatLocalizedDateTime } from "@calcom/lib/date-fns";
|
||||
import { formatLocalizedDateTime } from "@calcom/lib/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localeOptions } from "@calcom/lib/i18n";
|
||||
import { nameOfDay } from "@calcom/lib/weekday";
|
||||
|
||||
@@ -72,7 +72,7 @@ vi.mock("@calcom/lib/constants", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/date-fns", () => {
|
||||
vi.mock("@calcom/lib/dayjs", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { TRANSCRIPTION_STOPPED_ICON, RECORDING_DEFAULT_ICON } from "@calcom/lib/constants";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.2.0",
|
||||
"version": "5.2.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -246,9 +246,13 @@ test.describe("pro user", () => {
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
const bookingCancelledId = new URL(page.url()).pathname.split("/booking/")[1];
|
||||
|
||||
const { slug: eventSlug } = await pro.getFirstEventAsOwner();
|
||||
|
||||
await page.goto(`/reschedule/${bookingCancelledId}`);
|
||||
// Should be redirected to the booking details page which shows the cancelled headline
|
||||
await expect(page.locator('[data-testid="cancelled-headline"]')).toBeVisible();
|
||||
|
||||
// Should be redirected to the original event link
|
||||
await expect(page).toHaveURL(new RegExp(`/${pro.username}/${eventSlug}`));
|
||||
});
|
||||
|
||||
test("can book an event that requires confirmation and then that booking can be accepted by organizer", async ({
|
||||
@@ -654,3 +658,36 @@ test.describe("Event type with disabled cancellation and rescheduling", () => {
|
||||
expect(responseBody.message).toBe("This event type does not allow cancellations");
|
||||
});
|
||||
});
|
||||
test("Should throw error when both seatsPerTimeSlot and recurringEvent are set", async ({ page, users }) => {
|
||||
const user = await users.create({
|
||||
name: `Test-user-${randomString(4)}`,
|
||||
eventTypes: [
|
||||
{
|
||||
title: "Seats With Recurrence",
|
||||
slug: "seats-with-recurrence",
|
||||
length: 30,
|
||||
seatsPerTimeSlot: 3,
|
||||
recurringEvent: {
|
||||
freq: 1,
|
||||
count: 4,
|
||||
interval: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Way to book the event
|
||||
await page.goto(`/${user.username}/seats-with-recurrence`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await page.locator('[name="name"]').fill("Test name");
|
||||
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
|
||||
|
||||
page.locator("[data-testid=confirm-book-button]").click();
|
||||
|
||||
// Expect an error message to be displayed
|
||||
const alertError = page.locator("[data-testid=booking-fail]");
|
||||
await expect(alertError).toBeVisible();
|
||||
await expect(alertError).toContainText(
|
||||
"Could not book the meeting. Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring."
|
||||
);
|
||||
});
|
||||
|
||||
@@ -387,6 +387,39 @@ test.describe("Event Types tests", () => {
|
||||
await unCheckDisplayLocation(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("Should not allow enabling both recurring event and offer seats at the same time", async ({
|
||||
page,
|
||||
}) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `Conflict event ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
await page.click(`text=${eventTitle}`);
|
||||
|
||||
// Go to Advanced tab and enable offerSeats
|
||||
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||
const offerSeatsToggle = page.locator("[data-testid=offer-seats-toggle]");
|
||||
await offerSeatsToggle.click();
|
||||
|
||||
// Try enabling recurring - should be disabled
|
||||
await page.click("[data-testid=vertical-tab-recurring]");
|
||||
const recurringEventToggle = page.locator("[data-testid=recurring-event-check]");
|
||||
await expect(recurringEventToggle).toBeDisabled();
|
||||
|
||||
// Go back and disable offerSeats
|
||||
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||
await offerSeatsToggle.click(); // turn it off
|
||||
|
||||
// Enable recurring now
|
||||
await page.click("[data-testid=vertical-tab-recurring]");
|
||||
await recurringEventToggle.click();
|
||||
await expect(page.locator("[data-testid=recurring-event-collapsible]")).toBeVisible();
|
||||
|
||||
// After enabling recurring, offerSeats should now be disabled
|
||||
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||
await expect(offerSeatsToggle).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ async function verifyRobotsMetaTag({ page, orgSlug, urls, expectedContent }: Ver
|
||||
await doOnOrgDomain({ orgSlug, page }, async ({ page, goToUrlWithErrorHandling }) => {
|
||||
for (const relativeUrl of urls) {
|
||||
const { url } = await goToUrlWithErrorHandling(relativeUrl);
|
||||
const metaTag = await page.locator('head > meta[name="robots"]');
|
||||
const metaTag = page.locator('head > meta[name="robots"]');
|
||||
await expect(metaTag).toBeAttached();
|
||||
const metaTagValue = await metaTag.getAttribute("content");
|
||||
expect(metaTagValue).not.toBeNull();
|
||||
expect(
|
||||
|
||||
@@ -3053,5 +3053,17 @@
|
||||
"most_cancelled_bookings": "الحجوزات الأكثر إلغاءً",
|
||||
"name_or_email": "الاسم أو البريد الإلكتروني",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "إذا لم يتم العثور على جهة اتصال، يتم الرجوع إلى مالك العميل المحتمل إذا كان موجودًا",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑",
|
||||
"date_and_time" : "التاريخ والوقت",
|
||||
"email_reminder_upcoming_event_notice" : "هذا تذكير بالحدث القادم.",
|
||||
"email_reminder_triggered_by_workflow" : "تم تشغيل هذا التذكير بواسطة سير العمل في Cal.",
|
||||
"event_upper_case": "حدث",
|
||||
"scheduling_by": "جدولة بواسطة",
|
||||
"experience_review_prompt": "كيف كانت تجربتك الأخيرة؟",
|
||||
"improve_customer_experience_message": "نحن نعمل دائمًا على تحسين تجربة عملائنا.",
|
||||
"meeting_satisfaction_question": "ما مدى رضاك عن اجتماعك الأخير؟",
|
||||
"meeting_not_joined_question": "لم تحضر الاجتماع؟",
|
||||
"reschedule_cta_short": "أعد الجدولة من هنا",
|
||||
"you_and_conjunction": "& أنت",
|
||||
"email_survey_triggered_by_workflow": "تم تشغيل هذا الاستبيان بواسطة سير العمل في كال."
|
||||
}
|
||||
@@ -3053,5 +3053,16 @@
|
||||
"most_cancelled_bookings": "Ən çox ləğv edilmiş rezervasiyalar",
|
||||
"name_or_email": "Ad və ya E-poçt",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Əgər əlaqə tapılmazsa, mövcud olduğu təqdirdə potensial müştəri sahibinə qayıt",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑",
|
||||
"date_and_time" : "Tarix & Saat",
|
||||
"email_reminder_upcoming_event_notice" : "Bu, qarşıdan gələn tədbiriniz haqqında xatırlatmadır.",
|
||||
"email_reminder_triggered_by_workflow" : "Bu xatırladıcı Calda iş axını ilə işə salınıb.",
|
||||
"event_upper_case": "Hadisə",
|
||||
"scheduling_by": "Təqvimləşdirmə edən",
|
||||
"experience_review_prompt": "Son təcrübəniz necə oldu?",
|
||||
"improve_customer_experience_message": "Həmişə müştəri təcrübəsini yaxşılaşdırmağa çalışırıq.",
|
||||
"meeting_not_joined_question": "görüşə qoşulmadınız?",
|
||||
"reschedule_cta_short": "Buradan yenidən təyin edin.",
|
||||
"you_and_conjunction": "Siz &",
|
||||
"email_survey_triggered_by_workflow": "Bu sorğu Cal-da Workflow tərəfindən başladıldı."
|
||||
}
|
||||
@@ -3053,5 +3053,17 @@
|
||||
"most_cancelled_bookings": "Най-много отменени резервации",
|
||||
"name_or_email": "Име или имейл",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "Ако не е намерен контакт, връщане към собственика на лийда, ако съществува",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Добавете новите си низове над този ред ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Добавете новите си низове над този ред ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑",
|
||||
"date_and_time" : "Дата и час",
|
||||
"email_reminder_upcoming_event_notice" : "Това е напомняне за предстоящото ви събитие.",
|
||||
"email_reminder_triggered_by_workflow" : "Това напомняне е задействано от работен процес в Cal.",
|
||||
"event_upper_case": "Събитие",
|
||||
"scheduling_by": "График от",
|
||||
"experience_review_prompt": "Как бе вашето последно преживяване?",
|
||||
"improve_customer_experience_message": "Винаги се стремим да подобрим клиентското изживяване.",
|
||||
"meeting_satisfaction_question": "Колко сте доволни от последната си среща?",
|
||||
"meeting_not_joined_question": "не присъствахте на срещата?",
|
||||
"reschedule_cta_short": "Пренасрочете тук.",
|
||||
"you_and_conjunction": "Вие &",
|
||||
"email_survey_triggered_by_workflow": "Тази анкета беше стартирана от Workflow в Cal."
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user