Compare commits

...
40 Commits
Author SHA1 Message Date
GitHub Actions 95c48ac805 chore: release v5.2.2
Post release / release (push) Canceled after 0s
2025-04-17 20:49:03 +00:00
Somay Chauhan 59ab38db98 feat: add guards to stripe teams controller (#20540)
* feat: add guards to stripe teams controller

* remove logs and comments

* fix return type status

* refactor: move PlatformSubscription to a dedicated module

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

* fix: type errors

* feat: fixed it for conferencing apps

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

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

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

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

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

* feat: assigned attributes options base e2e

* feat: filter assigned options by other assigned options

* feat: filter assigned options by other assigned options

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

* feat: get org users by assigned attributes

* adding missing input file

* push generated docs

* feat: add filter org users by teamIds

* feat: add filter assigned attribute options by teamIds

* chore; update doc

* chore: remove console log

* chore: minor e2e fix

* chore: minor e2e fix

* chore: improve docs

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

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

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

* fix: buggy skeleton monthly view
2025-04-17 11:10:47 +05:30
Benny Joo 49f0759f31 fix: disable Google Translate from HTML 2025-04-17 00:10:25 +00:00
GitHub Actions cae8470350 chore: release v5.2.1
Post release / release (push) Canceled after 0s
2025-04-17 00:05:25 +00:00
Joe Au-YeungandBenny Joo a82d616b79 feat: Whitelist user's from workflow spam scanning (#20717)
* Add whitelistWorkflows to user

* Do not autolock whitelisted users

* Add endpoint to whitelist a user

* Get users data - include whitelistWorkflows

* Whitelist a user on the user admin page

* Fix typo

* Add test

* Use translations

* Add `updateWhitelistWorkflows` to `UserRepository`

* Use i18 for strings

* Rename to whitelistWorkflows

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-04-16 18:09:29 -04:00
Anik Dhabal Babu 515f3263e0 test: e2e test for the Routing Form to Event Booking flow (#20728)
* test: e2e test Routing Form submission to Booking flow

* update

* tweak
2025-04-16 16:09:15 -03:00
Lauris Skraucis aebbc50ba6 fix: EventTypeSettings atom default schedule title (#20725) 2025-04-16 21:26:30 +03:00
Benny Joo 7cdc78d02d update middleware (#20726) 2025-04-16 17:44:25 +00:00
Keith Williams df55dec553 chore: Rename @calcom/lib/date-fns to @calcom/lib/dayjs (#19870)
* chore: Rename date-fns to dayjs

* Fixed types
2025-04-16 16:26:42 +00:00
Benny Joo 398ad22868 fix wrong translation (#20727) 2025-04-16 15:37:21 +00:00
Lauris Skraucis 7ef1050946 feat: v2 get slots using teamSlug + eventTypeSlug + optional organizationSlug (#20554)
* feat: allow teamSlug + eventTypeSlug + optional organizationSlug as input

* feat: return team event slots

* test: get slots using teamSlug + eventTypeSlug + optional organizationalSlug

* docs: how to get team event slots
2025-04-16 18:29:47 +03:00
Peer Richelsen 7b8bbaaa86 added lindy (#20721) 2025-04-16 14:27:53 +00:00
Omar López ddfd3fda70 refactor: improve error handling in captureErrorsMiddleware (#20718) 2025-04-16 14:01:47 +00:00
Benny Joo 5972a6f77f perf: Fetch data server-side for Webhook Create page (#20675)
* perf: fetch data server-side for create webhooks page

* refactor

* refactor

* refactor

* improve skeleton styling

* fix styling
2025-04-16 10:59:18 -03:00
9bc703a437 feat: Embed - Show skeleton loader when pageType is provided (#20646)
* mvp done

* wip

* fix ts errors and other code improvements

* fix ts errors

* ensure mobile layout support

* Make skeleton responsive on screen resize

* refactor

* Add test for EmbedElement

* make skeleton closer to pixel perfect

* Address PR feedback

* Remove PR_TODO

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
2025-04-16 13:40:51 +00:00
Syed Ali Shahbaz 15aaf7f51b fix: readonly ics error (#20651)
* readonly error fix

* warn instead of throwing err

* .

* .

* fix err

* dont throw error, only warn

* --

* remove skip writing as that's unnecessary

* fix api
2025-04-16 09:02:56 -03:00
Lauris SkraucisandAnik Dhabal Babu c89a1877ae feat: organization booking hooks (#20611)
* docs: request auth headers

* feat: useOrganizationsBookings hook

* feat: useOrganizationUserBookings hook

* rename hooks

* query params

* fix: organizations users bookings extract user from userId not auth

* fix: passing array from hook

* docs

* correct header doc

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-04-16 10:59:02 +03:00
Lauris Skraucis 15d2c0f858 feat: v2 return slots based on routing form response (#20637)
* feat: routing form response slots

* fix

* test routing form slots

* chore: bump libraries

* docs

* docs

* chore: api docs from main to merge main

* chore: regenerate api docs
2025-04-16 10:55:01 +03:00
Morgan 43a6663230 fix: routing form endpoints date filters validation (#20715) 2025-04-16 06:30:29 +00:00
58315e96c9 fix: don't allow seats for recurring events (#19257)
* fix: Don't allow seats for recurring events #19061

* update the commit with English

* Update common.json

* Update common.json

* Update common.json

* Add edge case when people have already enabled seats as well as recurring event

* fix type issue

* Handle case where seats and recurring is already enabled

* minor fix

* minor change

* typo

* Block backend functionality

* blocking new booking and other enhancements

* final-changes

* minor-changes

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
2025-04-15 21:07:57 +00:00
sean-brydon 3e466022b8 fix availability CTA height (#20714) 2025-04-15 20:11:29 +00:00
Keith Williams 68c036cd0a fix: Tasker import and error handling for routing form responses (#20713) 2025-04-15 19:43:54 +00:00
Benny Joo 77b656024a feat: placeholder component for user dropdown to remove layout shift (#20696)
* feat

* refactor
2025-04-15 15:17:14 -04:00
amritandTushar Bhatt 89772a0dac fix: make padding equal on availability toggle (#20708)
* fix: trigger lingo.dev by removing duplicate value

* fix: uneven height in toggle

* address mrge bots comments

* use tailwind

* fix: make padding equal for availability toggle

* Update apps/web/modules/availability/availability-view.tsx

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-15 18:40:04 +00:00
Giridhar 381c462660 feat: email workflow templates translation (#20674)
* Add translations in respective files

* implement multilingual support for email templates

* fix: indentation in common.js & typos in common.js and update.handler.ts
2025-04-15 23:56:34 +05:30
Shawn CazaandTushar Bhatt 417acdaacb fix: Improves dialog window that appears when there is no availability in a month. Adding a description to clarify limits when rolling or range period types are in use. (#20451)
* init

* updated typing, english localization text, used existing calculatePeriodLimits and isTimeViolatingFutureLimit to determine if go to next month button should be disabled

* improving copy

* semantic dialog heading tag, proper EN localization,

* reverting

* force local to origin

* Translations by LLM

* intit tests

* moving NoAvailability to its own file.

* refactor overlay component to encpsulate logic in hooks

* improved tests

* remove unintended change

* fix: period data type import. Providing default periodData values

* fix: type errors on event object

* fix: updates case in type import

* removing translations

* remove unnecessary import

* remove unnecessary import

* improving mocks for booker and logger

* moving tests to __tests__ folder

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-15 23:46:01 +05:30
Kartik SainiandTushar Bhatt 1584dd3179 feat: Redirect to original event link for rescheduling cancelled booking (#18605)
* feat: redirect to original event link for rescheduling cancelled booking

* allow cancelled booking rescheduling when allowRescheduleForCancelledBooking is true

* chore

* fix test

* chore

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-15 14:09:18 -04:00
d66f983f56 feat: dry run behaviour for connect atoms (#20699)
* add dry run prop for mocking actual behaviour without actual api calls

* fix e2e tests

* fixup

* remove await present twice

* fix: adjust success toast for apple connect dry run

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: rajiv@cal.com <rajiv@cal.com>
2025-04-15 17:45:14 +00:00
Rajiv Sahal b1f50fe22d feat: dry run behaviour for AvailabilitySettings atom (#20686) 2025-04-15 22:24:21 +05:30
Rajiv Sahal ce995c39f2 feat: dry run behaviour for event type atoms (#20689) 2025-04-15 22:23:51 +05:30
Rajiv Sahal b99ea16b31 feat: dry run behaviour for CalendarSettings atom (#20690) 2025-04-15 22:23:05 +05:30
Lauris Skraucis 574d8882f4 refactor: required booking fields message (#20688) 2025-04-15 16:29:45 +00:00
284 changed files with 8588 additions and 2218 deletions
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+8
View File
@@ -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"
+1 -1
View File
@@ -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";
+2 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.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 {
+3 -29
View File
@@ -1,22 +1,16 @@
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { forwardRef, Module } from "@nestjs/common";
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
@Module({
@@ -29,28 +23,8 @@ import { PassportModule } from "@nestjs/passport";
TokensModule,
DeploymentsModule,
ProfilesModule,
forwardRef(() => OrganizationsModule),
],
providers: [
NextAuthGuard,
NextAuthStrategy,
ApiAuthGuard,
ApiAuthStrategy,
OAuthFlowService,
IsTeamInOrg,
RolesGuard,
IsOrgGuard,
IsAdminAPIEnabledGuard,
PlatformPlanGuard,
],
exports: [
NextAuthGuard,
ApiAuthGuard,
IsTeamInOrg,
RolesGuard,
IsOrgGuard,
IsAdminAPIEnabledGuard,
PlatformPlanGuard,
],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard],
})
export class AuthModule {}
@@ -6,15 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { forwardRef, Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
imports: [PrismaModule, ConfigModule, HttpModule],
providers: [
ConferencingService,
ConferencingRepository,
@@ -15,9 +15,8 @@ import {
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { HttpService } from "@nestjs/axios";
import { Logger } from "@nestjs/common";
import {
Controller,
@@ -32,10 +31,10 @@ import {
Delete,
Headers,
Redirect,
UnauthorizedException,
Req,
HttpException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { Request } from "express";
@@ -60,9 +59,9 @@ export class ConferencingController {
private readonly logger = new Logger("ConferencingController");
constructor(
private readonly tokensRepository: TokensRepository,
private readonly conferencingService: ConferencingService,
private readonly organizationsConferencingService: OrganizationsConferencingService
private readonly config: ConfigService,
private readonly httpService: HttpService
) {}
@Post("/:app/connect")
@@ -120,6 +119,14 @@ export class ConferencingController {
};
}
/**
* Handles saving conferencing app credentials.
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
* otherwise, credentials are saved at the user level.
*
* Proxying ensures that permission checks—such as whether the user is allowed to install conferencing app for a team or organization—
* are enforced via controller route guards, avoiding duplication of this logic within the service layer.
*/
@Get("/:app/oauth/callback")
@UseGuards()
@Redirect(undefined, 301)
@@ -143,23 +150,28 @@ export class ConferencingController {
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
try {
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
if (error) {
throw new BadRequestException(error_description);
}
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
if (decodedCallbackState.orgId) {
return this.organizationsConferencingService.connectTeamOauthApps({
app,
code,
userId,
decodedCallbackState,
});
} else {
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
if (decodedCallbackState.teamId && decodedCallbackState.orgId) {
const apiUrl = this.config.get("api.url");
const url = `${apiUrl}/organizations/${decodedCallbackState.orgId}/teams/${decodedCallbackState.teamId}/conferencing/${app}/oauth/callback`;
const params: Record<string, string | undefined> = { state, code, error, error_description };
const headers = {
Authorization: `Bearer ${decodedCallbackState.accessToken}`,
};
try {
const response = await this.httpService.axiosRef.get(url, { params, headers });
const redirectUrl = response.data?.url || decodedCallbackState.onErrorReturnTo || "";
return { url: redirectUrl };
} catch (err) {
const fallbackUrl = decodedCallbackState.onErrorReturnTo || "";
return { url: fallbackUrl };
}
}
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState);
} catch (error) {
if (error instanceof HttpException || error instanceof Error) {
this.logger.error(error.message);
@@ -3,9 +3,15 @@ import { ConferencingRepository } from "@/modules/conferencing/repositories/conf
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
import {
BadRequestException,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import {
@@ -25,6 +31,7 @@ export class ConferencingService {
constructor(
private readonly conferencingRepository: ConferencingRepository,
private readonly usersRepository: UsersRepository,
private readonly tokensRepository: TokensRepository,
private readonly googleMeetService: GoogleMeetService,
private readonly zoomVideoService: ZoomVideoService,
private readonly office365VideoService: Office365VideoService
@@ -47,10 +54,13 @@ export class ConferencingService {
async connectOauthApps(
app: string,
code: string,
userId: number,
decodedCallbackState: OAuthCallbackState,
teamId?: number
) {
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
switch (app) {
case ZOOM:
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
@@ -16,8 +16,6 @@ export class DestinationCalendarsService {
userId: number,
delegationCredentialId?: string
) {
// note(Lauris): todo remove the log but leaving this now to confirm delegationCredentialId is received
console.log("debug: delegationCredentialId", delegationCredentialId);
const userCalendars = await this.calendarsService.getCalendars(userId);
const allCalendars: Calendar[] = userCalendars.connectedCalendars
.map((cal: ConnectedCalendar) => cal.calendars ?? [])
@@ -0,0 +1,49 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsOptional, IsArray, ArrayMinSize, IsString, IsNumber } from "class-validator";
export class GetAssignedAttributeOptions {
@ApiPropertyOptional({ type: Number, description: "Number of responses to skip" })
@Transform(({ value }) => value && parseInt(value))
@IsOptional()
skip?: number;
@ApiPropertyOptional({ type: Number, description: "Number of responses to take" })
@Transform(({ value }) => value && parseInt(value))
@IsOptional()
take?: number;
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((optId: string) => optId);
}
return value;
})
@ApiPropertyOptional({
type: [String],
description: "Filter by assigned attribute option ids. ids must be separated by a comma.",
example: "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
})
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1, { message: "assignedOptionIds must contain at least 1 attribute option id" })
assignedOptionIds?: string[];
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((teamId: string) => parseInt(teamId));
}
return value;
})
@ApiPropertyOptional({
type: [Number],
description: "Filter by teamIds. Team ids must be separated by a comma.",
example: "?teamIds=100,200",
})
@IsArray()
@IsNumber({}, { each: true })
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
teamIds?: number[];
}
@@ -7,6 +7,8 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
import { GetOrganizationAttributeAssignedOptionsProp } from "./services/organization-attributes-option.service";
@Injectable()
export class OrganizationAttributeOptionRepository {
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
@@ -90,6 +92,82 @@ export class OrganizationAttributeOptionRepository {
return options;
}
async getOrganizationAttributeAssignedOptions({
attributeId,
attributeSlug,
filters,
organizationId,
skip,
take,
}: GetOrganizationAttributeAssignedOptionsProp) {
const options = await this.dbRead.prisma.attributeOption.findMany({
where: {
attribute: {
...(attributeId && { id: attributeId }),
...(attributeSlug && { slug: attributeSlug }),
teamId: organizationId,
},
assignedUsers: {
some: {}, // empty {} statement checks if option is assigned to at least one user
...(filters?.teamIds && {
some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } },
}),
},
},
include: { assignedUsers: { include: { member: true } } },
skip,
take,
});
// only return options that are assigned to users alongside assignedOptionIds filter
if (filters?.assignedOptionIds) {
const filteredAssignedOptions = await this.dbRead.prisma.attributeOption.findMany({
where: {
attribute: { teamId: organizationId },
assignedUsers: {
every: {
attributeOptionId: { in: filters?.assignedOptionIds },
},
...(filters?.teamIds && {
some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } },
}),
},
},
include: { assignedUsers: { include: { member: true } } },
});
if (!filteredAssignedOptions?.length) {
throw new NotFoundException(
"Options provided in assignedOptionIds are not assigned to anyone, or the users are not part of the teams specified in teamIds filter."
);
}
const matchingUserIds = filteredAssignedOptions.flatMap((opt) =>
opt.assignedUsers.flatMap((assignedUser) => assignedUser.member.userId)
);
// reduce remove options that are not assigned alongside assignedOptionIds filter
return options.reduce((acc, opt) => {
if (opt.assignedUsers.some((assignedUser) => matchingUserIds.includes(assignedUser.member.userId))) {
return [
...acc,
{
...opt,
assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId),
},
];
}
return acc;
}, [] as typeof options);
}
return options.map((opt) => {
return {
...opt,
assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId),
};
});
}
async assignOrganizationAttributeOptionToUser({
organizationId,
membershipId,
@@ -3,6 +3,8 @@ import { AppModule } from "@/app.module";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -13,12 +15,13 @@ import * as request from "supertest";
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, Membership } from "@calcom/prisma/client";
import { User, Team, Membership, Attribute, AttributeOption } from "@calcom/prisma/client";
describe("Organizations Attributes Options Endpoints", () => {
describe("User lacks required role", () => {
@@ -95,19 +98,37 @@ describe("Organizations Attributes Options Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixtures: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let attributeRepositoryFixture: AttributeRepositoryFixture;
const userEmail = `organization-attributes-options-admin-${randomString()}@api.com`;
const userEmail2 = `organization-attributes-options-member-${randomString()}@api.com`;
let user: User;
let user2: User;
let org: Team;
let team: Team;
let membership: Membership;
let membership2: Membership;
let attributeId: string;
let attributeSlug: string;
let createdOption: any;
let createdOption2: any;
let attribute2: Attribute;
let attribute2Option: AttributeOption;
const createOptionInput: CreateOrganizationAttributeOptionInput = {
value: "option1",
slug: "option1",
slug: `option1-${randomString()}`,
};
const createOption2Input: CreateOrganizationAttributeOptionInput = {
value: "option2",
slug: `option2${randomString()}`,
};
beforeAll(async () => {
@@ -120,6 +141,7 @@ describe("Organizations Attributes Options Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
@@ -128,23 +150,50 @@ describe("Organizations Attributes Options Endpoints", () => {
isOrganization: true,
});
team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } });
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
membership2 = await membershipFixtures.addUserToOrg(user2, org, "ADMIN", true);
await membershipFixtures.create({
role: "MEMBER",
accepted: true,
team: { connect: { id: team.id } },
user: { connect: { id: user.id } },
});
// Create an attribute for testing
const attribute = await attributeRepositoryFixture.create({
name: "Test Attribute",
team: { connect: { id: org.id } },
type: "TEXT",
slug: "test-attribute",
slug: `test-attribute-${randomString()}`,
});
attribute2 = await attributeRepositoryFixture.create({
name: "Test Attribute 2",
team: { connect: { id: org.id } },
type: "TEXT",
slug: `test-attribute-2-${randomString()}`,
});
attributeId = attribute.id;
attributeSlug = attribute.slug;
attribute2Option = await attributeRepositoryFixture.createOption({
slug: `optionA-${randomString()}`,
value: "optionA",
attribute: { connect: { id: attribute2.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -164,16 +213,31 @@ describe("Organizations Attributes Options Endpoints", () => {
});
});
it("should create attribute option", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
.send(createOption2Input)
.expect(201)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
createdOption2 = response.body.data;
expect(createdOption2.value).toEqual(createOption2Input.value);
expect(createdOption2.slug).toEqual(createOption2Input.slug);
});
});
it("should get attribute options", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const options = response.body.data;
expect(options.length).toEqual(1);
expect(options[0].value).toEqual(createOptionInput.value);
expect(options[0].slug).toEqual(createOptionInput.slug);
const options = response.body.data as OptionOutput[];
expect(options.length).toEqual(2);
expect(options.find((opt) => opt.value === createOptionInput.value)).toBeDefined();
expect(options.find((opt) => opt.slug === createOptionInput.slug)).toBeDefined();
expect(options.find((opt) => opt.value === createOption2Input.value)).toBeDefined();
expect(options.find((opt) => opt.slug === createOption2Input.slug)).toBeDefined();
});
});
@@ -209,6 +273,38 @@ describe("Organizations Attributes Options Endpoints", () => {
});
});
it("should assign attribute option to user2", async () => {
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
attributeId: attributeId,
attributeOptionId: createdOption2.id,
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
.send(assignInput)
.expect(201)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeTruthy();
});
});
it("should assign attribute option to user2", async () => {
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
attributeId: attribute2.id,
attributeOptionId: attribute2Option.id,
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
.send(assignInput)
.expect(201)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeTruthy();
});
});
it("should get attribute options for user", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
@@ -221,6 +317,133 @@ describe("Organizations Attributes Options Endpoints", () => {
});
});
it("should get attribute options for user2", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/options/${user2.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const userOptions = response.body.data as AttributeOption[];
expect(userOptions.length).toEqual(2);
expect(userOptions.find((opt) => opt.id === createdOption2.id)).toBeDefined();
expect(userOptions.find((opt) => opt.id === attribute2Option.id)).toBeDefined();
});
});
it("should get attribute all assigned options", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const assignedOptions = response.body.data as AssignedOptionOutput[];
expect(assignedOptions?.length).toEqual(2);
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
expect(
assignedOptions
.find((opt) => createdOption.id === opt.id)
?.assignedUserIds.find((id) => id === user.id)
).toBeDefined();
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
expect(
assignedOptions
.find((opt) => createdOption2.id === opt.id)
?.assignedUserIds.find((id) => id === user2.id)
).toBeDefined();
});
});
it("should get attribute all assigned options filtered by team id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?teamIds=${team.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const assignedOptions = response.body.data as AssignedOptionOutput[];
expect(assignedOptions?.length).toEqual(1);
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
expect(
assignedOptions
.find((opt) => createdOption.id === opt.id)
?.assignedUserIds.find((id) => id === user.id)
).toBeDefined();
});
});
it("should get attribute all assigned options by attribute slug", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/slugs/${attributeSlug}/options/assigned`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const assignedOptions = response.body.data as AssignedOptionOutput[];
expect(assignedOptions?.length).toEqual(2);
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
expect(
assignedOptions
.find((opt) => createdOption.id === opt.id)
?.assignedUserIds.find((id) => id === user.id)
).toBeDefined();
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
expect(
assignedOptions
.find((opt) => createdOption2.id === opt.id)
?.assignedUserIds.find((id) => id === user2.id)
).toBeDefined();
});
});
it("should get attribute all assigned options filtered by other assigned options", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}`
)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const assignedOptions = response.body.data as AssignedOptionOutput[];
expect(assignedOptions?.length).toEqual(1);
expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined();
expect(
assignedOptions
.find((opt) => createdOption2.id === opt.id)
?.assignedUserIds.find((id) => id === user2.id)
).toBeDefined();
});
});
it("should not get attribute all assigned options filtered by other assigned options and teamIds in which the user is not part of", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}&teamIds=${team.id}`
)
.expect(404);
});
it("should get attribute all assigned options filtered by other assigned options and teamIds", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${createdOption.id}&teamIds=${team.id}`
)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const assignedOptions = response.body.data as AssignedOptionOutput[];
expect(assignedOptions?.length).toEqual(1);
expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined();
expect(
assignedOptions
.find((opt) => createdOption.id === opt.id)
?.assignedUserIds.find((id) => id === user.id)
).toBeDefined();
});
});
it("should unassign attribute option from user", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
@@ -243,7 +466,9 @@ describe("Organizations Attributes Options Endpoints", () => {
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await membershipFixtures.delete(membership2.id);
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
@@ -8,19 +8,32 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input";
import { GetAssignedAttributeOptions } from "@/modules/organizations/attributes/options/inputs/get-assigned-attribute-options.input";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
import {
AssignOptionUserOutput,
UnassignOptionUserOutput,
} from "@/modules/organizations/attributes/options/outputs/assign-option-user.output";
import { GetAllAttributeAssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
import { CreateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/create-option.output";
import { DeleteAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/delete-option.output";
import { GetOptionUserOutput } from "@/modules/organizations/attributes/options/outputs/get-option-user.output";
import { GetAllAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/get-option.output";
import { UpdateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/update-option.output";
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -118,6 +131,54 @@ export class OrganizationsAttributesOptionsController {
};
}
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/:attributeId/options/assigned")
@ApiOperation({ summary: "Get by attribute id all of the attribute options that are assigned to users" })
async getOrganizationAttributeAssignedOptions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@Query() queryParams: GetAssignedAttributeOptions
): Promise<GetAllAttributeAssignedOptionOutput> {
const { skip, take, ...rest } = queryParams;
const attributeOptions =
await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({
organizationId: orgId,
attributeId,
skip: skip ?? 0,
take: take ?? 250,
filters: rest,
});
return {
status: SUCCESS_STATUS,
data: attributeOptions,
};
}
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/slugs/:attributeSlug/options/assigned")
@ApiOperation({ summary: "Get by attribute slug all of the attribute options that are assigned to users" })
async getOrganizationAttributeAssignedOptionsBySlug(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeSlug") attributeSlug: string,
@Query() queryParams: GetAssignedAttributeOptions
): Promise<GetAllAttributeAssignedOptionOutput> {
const { skip, take, ...rest } = queryParams;
const attributeOptions =
await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({
organizationId: orgId,
attributeSlug,
skip: skip ?? 0,
take: take ?? 250,
filters: rest,
});
return {
status: SUCCESS_STATUS,
data: attributeOptions,
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/attributes/options/:userId")
@@ -0,0 +1,24 @@
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, ValidateNested } from "class-validator";
export class AssignedOptionOutput extends OptionOutput {
@Expose()
@IsArray()
@ApiProperty({
type: Array,
required: true,
description: "Ids of the users assigned to the attribute option.",
example: [124, 224],
})
assignedUserIds!: number[];
}
export class GetAllAttributeAssignedOptionOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => OptionOutput)
data!: AssignedOptionOutput[];
}
@@ -1,8 +1,9 @@
import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
import { Expose } from "class-transformer";
import { IsString } from "class-validator";
export class OptionOutput {
@Expose()
@IsString()
@ApiProperty({
type: String,
@@ -12,10 +13,12 @@ export class OptionOutput {
})
id!: string;
@Expose()
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
attributeId!: string;
@Expose()
@IsString()
@ApiProperty({
type: String,
@@ -25,6 +28,7 @@ export class OptionOutput {
})
value!: string;
@Expose()
@IsString()
@ApiProperty({
type: String,
@@ -3,8 +3,10 @@ import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts";
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { plainToClass } from "class-transformer";
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
@@ -111,4 +113,45 @@ export class OrganizationAttributeOptionService {
userId
);
}
async getOrganizationAttributeAssignedOptions({
organizationId,
attributeId,
attributeSlug,
skip,
take,
filters,
}: GetOrganizationAttributeAssignedOptionsProp) {
const options = await this.organizationAttributeOptionRepository.getOrganizationAttributeAssignedOptions({
organizationId,
...(attributeId ? { attributeId } : { attributeSlug }),
skip,
take,
filters,
});
return options.map((opt) => plainToClass(AssignedOptionOutput, opt, { strategy: "excludeAll" }));
}
}
// Discriminative Union on attributeSlug / attributeId
export type GetOrganizationAttributeAssignedOptionsProp =
| GetOrganizationAttributeAssignedOptionsPropById
| GetOrganizationAttributeAssignedOptionsPropBySlug;
type GetOrganizationAttributeAssignedOptionsPropById = {
organizationId: number;
attributeId: string;
attributeSlug?: undefined;
skip: number;
take: number;
filters?: { assignedOptionIds?: string[]; teamIds?: number[] };
};
type GetOrganizationAttributeAssignedOptionsPropBySlug = {
organizationId: number;
attributeId?: undefined;
attributeSlug?: string;
skip: number;
take: number;
filters?: { assignedOptionIds?: string[]; teamIds?: number[] };
};
@@ -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" })
@@ -22,6 +22,7 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -36,10 +37,13 @@ import {
Headers,
Req,
ParseIntPipe,
BadRequestException,
Redirect,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { Request } from "express";
import { stringify } from "querystring";
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
@@ -60,7 +64,8 @@ export type OAuthCallbackState = {
export class OrganizationsConferencingController {
constructor(
private readonly conferencingService: ConferencingService,
private readonly organizationsConferencingService: OrganizationsConferencingService
private readonly organizationsConferencingService: OrganizationsConferencingService,
private readonly tokensRepository: TokensRepository
) {}
@Roles("TEAM_ADMIN")
@@ -221,4 +226,41 @@ export class OrganizationsConferencingController {
return { status: SUCCESS_STATUS };
}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Get("/teams/:teamId/conferencing/:app/oauth/callback")
@Redirect(undefined, 301)
@ApiOperation({ summary: "Save conferencing app OAuth credentials" })
async saveTeamOauthCredentials(
@Query("state") state: string,
@Query("code") code: string,
@Query("error") error: string | undefined,
@Query("error_description") error_description: string | undefined,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("orgId", ParseIntPipe) orgId: number,
@Param("app") app: string
): Promise<{ url: string }> {
if (!state) {
throw new BadRequestException("Missing `state` query param");
}
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
try {
return await this.organizationsConferencingService.connectTeamOauthApps({
decodedCallbackState,
code,
app,
teamId,
});
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
}
return {
url: decodedCallbackState.onErrorReturnTo ?? "",
};
}
}
}
@@ -0,0 +1,46 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [PrismaModule, ConfigModule, ConferencingModule],
providers: [
ConferencingService,
ConferencingRepository,
GoogleMeetService,
UsersRepository,
TeamsRepository,
OrganizationsConferencingService,
ZoomVideoService,
Office365VideoService,
CredentialsRepository,
TokensRepository,
AppsRepository,
OrganizationsRepository,
StripeService,
MembershipsRepository,
RedisService,
OrganizationsTeamsRepository,
],
exports: [OrganizationsConferencingService],
controllers: [OrganizationsConferencingController],
})
export class OrganizationsConferencingModule {}
@@ -1,10 +1,3 @@
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PlatformPlanType } from "@/modules/billing/types";
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
@@ -13,12 +6,7 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
BadRequestException,
ForbiddenException,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { GOOGLE_MEET } from "@calcom/platform-constants";
@@ -35,129 +23,9 @@ export class OrganizationsConferencingService {
private teamsRepository: TeamsRepository,
private usersRepository: UsersRepository,
private readonly googleMeetService: GoogleMeetService,
private readonly conferencingService: ConferencingService,
private readonly platformPlanGuard: PlatformPlanGuard,
private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard,
private readonly isOrgGuard: IsOrgGuard,
private readonly isTeamInOrg: IsTeamInOrg,
private readonly rolesGuard: RolesGuard
private readonly conferencingService: ConferencingService
) {}
async verifyAccess({
user,
orgId,
teamId,
requiredRole,
minimumPlan,
}: {
user: UserWithProfile;
orgId: string;
teamId?: string;
requiredRole?: string;
minimumPlan?: PlatformPlanType;
}): Promise<{ orgId: number; teamId: number | null }> {
const userWithAdminFlag = {
...user,
isSystemAdmin: user.role === "ADMIN",
};
if (!orgId) {
throw new BadRequestException("Organization ID is required");
}
await this.ensureOrganizationAccess(orgId);
await this.ensureAdminAPIEnabled(orgId);
// Only validate team if a teamId is provided
if (teamId && teamId.trim() !== "") {
await this.ensureTeamBelongsToOrg(orgId, teamId);
}
await this.ensureUserRoleAccess(userWithAdminFlag, orgId, teamId, requiredRole);
await this.ensurePlatformPlanAccess({ teamId, orgId, user: userWithAdminFlag, minimumPlan });
const parsedOrgId = parseInt(orgId, 10);
// For organization-level operations, teamId might be empty or null
const parsedTeamId = teamId && teamId.trim() !== "" ? parseInt(teamId, 10) : null;
return {
orgId: parsedOrgId,
teamId: parsedTeamId,
};
}
private async ensureOrganizationAccess(orgId: string): Promise<void> {
const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId);
if (!canAccess) {
throw new ForbiddenException("Organization validation failed.");
}
}
private async ensureAdminAPIEnabled(orgId: string): Promise<void> {
const { canAccess } = await this.isAdminAPIEnabledGuard.checkAdminAPIEnabled(orgId);
if (!canAccess) {
throw new ForbiddenException("Admin API is not enabled for this organization.");
}
}
private async ensureTeamBelongsToOrg(orgId: string, teamId: string): Promise<void> {
const { canAccess } = await this.isTeamInOrg.checkIfTeamIsInOrg(orgId, teamId);
if (!canAccess) {
throw new ForbiddenException("Team is not part of the organization.");
}
}
private async ensureUserRoleAccess(
user: ApiAuthGuardUser,
orgId: string,
teamId?: string,
requiredRole?: string
): Promise<void> {
if (!requiredRole) return;
// For team-level roles, we need both orgId and teamId
if (requiredRole.startsWith("TEAM_") && (!teamId || teamId.trim() === "")) {
throw new ForbiddenException("Team ID is required for team-level role access");
}
// Use empty string for teamId if it's an org-level operation
const effectiveTeamId = teamId && teamId.trim() !== "" ? teamId : "";
const { canAccess } = await this.rolesGuard.checkUserRoleAccess(
user,
orgId,
effectiveTeamId,
requiredRole
);
if (!canAccess) {
throw new ForbiddenException("User does not have the required role.");
}
}
private async ensurePlatformPlanAccess({
teamId,
orgId,
user,
minimumPlan,
}: {
teamId?: string;
orgId?: string;
user: ApiAuthGuardUser;
minimumPlan?: PlatformPlanType;
}): Promise<void> {
if (!minimumPlan) return;
const { canAccess } = await this.platformPlanGuard.checkPlatformPlanAccess({
teamId,
orgId,
user,
minimumPlan,
});
if (!canAccess) {
throw new ForbiddenException("User's organization does not meet the required subscription plan.");
}
}
async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise<any> {
switch (app) {
case GOOGLE_MEET:
@@ -171,40 +39,14 @@ export class OrganizationsConferencingService {
decodedCallbackState,
app,
code,
userId,
teamId,
}: {
userId: number;
app: string;
decodedCallbackState: OAuthCallbackState;
code: string;
teamId: number;
}) {
const user = await this.usersRepository.findByIdWithProfile(userId);
const { orgId, teamId } = decodedCallbackState;
if (!orgId) {
throw new BadRequestException("orgId required");
}
if (!user) {
throw new BadRequestException("user not found");
}
// Determine if this is a team-level or organization-level operation
const isTeamLevel = !!teamId;
const requiredRole = isTeamLevel ? "TEAM_ADMIN" : "ORG_ADMIN";
// Verify access with appropriate parameters
const { orgId: validatedOrgId, teamId: validatedTeamId } = await this.verifyAccess({
user,
orgId,
teamId,
requiredRole,
minimumPlan: "ESSENTIALS",
});
const entityId = isTeamLevel && validatedTeamId !== null ? validatedTeamId : validatedOrgId;
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState, entityId);
return this.conferencingService.connectOauthApps(app, code, decodedCallbackState, teamId);
}
async getConferencingApps({ teamId }: { teamId: number }) {
@@ -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 {}
@@ -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 {
@@ -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)
@@ -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()
@@ -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;
}
) {
@@ -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;
}
) {
@@ -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: {
@@ -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";
@@ -13,8 +13,8 @@ export class OrganizationsTeamsRoutingFormsResponsesRepository {
take: number,
options?: {
sortCreatedAt?: "asc" | "desc";
afterCreatedAt?: Date;
beforeCreatedAt?: Date;
afterCreatedAt?: string;
beforeCreatedAt?: string;
routedToBookingUid?: string;
}
) {
@@ -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 {
@@ -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;
}
) {
@@ -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);
@@ -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
);
@@ -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 });
}
}
@@ -66,9 +66,11 @@ export class OrganizationsUsersController {
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrganizationsUsersInput
): Promise<GetOrganizationUsersResponseDTO> {
const { emails, assignedOptionIds, attributeQueryOperator, teamIds } = query ?? {};
const users = await this.organizationsUsersService.getUsers(
orgId,
query.emails,
emails,
{ assignedOptionIds, attributeQueryOperator, teamIds },
query.skip ?? 0,
query.take ?? 250
);
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { EmailService } from "@/modules/email/email.service";
import { GetOrganizationsUsersInput } from "@/modules/organizations/users/index/inputs/get-organization-users.input";
import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/users/index/outputs/get-organization-users.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
@@ -10,6 +11,7 @@ import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
@@ -20,7 +22,7 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, EventType } from "@calcom/prisma/client";
import { User, Team, EventType, AttributeOption } from "@calcom/prisma/client";
describe("Organizations Users Endpoints", () => {
const bio = "I am a bio";
@@ -114,8 +116,6 @@ describe("Organizations Users Endpoints", () => {
await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
await app.close();
});
});
describe("Admin role", () => {
@@ -249,11 +249,6 @@ describe("Organizations Users Endpoints", () => {
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(4);
console.log(
"profiles",
{ userData },
userData.map((u) => u.profile)
);
// Find and verify each member's data
const member0 = userData.find((u) => u.profile.username === orgMembersData[0].username);
const member1 = userData.find((u) => u.profile.username === orgMembersData[1].username);
@@ -543,7 +538,250 @@ describe("Organizations Users Endpoints", () => {
await userRepositoryFixture.deleteByEmail(createdUser.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
describe("Org Members with assigned attributes", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamsRepositoryFixtures: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let attributeRepositoryFixture: AttributeRepositoryFixture;
const authEmail = `organizations-users-auth-${randomString()}@api.com`;
const user2Email = `organizations-users2-auth-${randomString()}@api.com`;
let user: User;
let user2: User;
let org: Team;
let team: Team;
let assignedOption1: AttributeOption;
let assignedOption2: AttributeOption;
beforeAll(async () => {
const moduleRef = await withApiAuth(
authEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: `organizations-users-organization-${randomString()}`,
isOrganization: true,
});
team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } });
user = await userRepositoryFixture.create({
email: authEmail,
username: authEmail,
organization: { connect: { id: org.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: authEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
const membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
await membershipFixtures.create({
role: "MEMBER",
accepted: true,
team: { connect: { id: team.id } },
user: { connect: { id: user.id } },
});
user2 = await userRepositoryFixture.create({
email: user2Email,
username: user2Email,
organization: { connect: { id: org.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user2.id}`,
username: user2Email,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user2.id,
},
},
});
const membership2 = await membershipFixtures.addUserToOrg(user2, org, "ADMIN", true);
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
const attribute = await attributeRepositoryFixture.create({
name: "Test Attribute",
team: { connect: { id: org.id } },
type: "TEXT",
slug: `test-attribute-${randomString()}`,
});
const attribute2 = await attributeRepositoryFixture.create({
name: "Test Attribute 2",
team: { connect: { id: org.id } },
type: "TEXT",
slug: `test-attribute-2-${randomString()}`,
});
const attributeId = attribute.id;
assignedOption1 = await attributeRepositoryFixture.createOption({
slug: "option1",
value: "option1",
attribute: { connect: { id: attribute.id } },
assignedUsers: {
create: {
memberId: membership.id,
},
},
});
assignedOption2 = await attributeRepositoryFixture.createOption({
slug: "optionA",
value: "optionA",
attribute: { connect: { id: attribute2.id } },
assignedUsers: { createMany: { data: [{ memberId: membership.id }, { memberId: membership2.id }] } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should get users with all specified assigned attribute options", async () => {
const { body } = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users`)
.query({
assignedOptionIds: [assignedOption1.id],
attributeQueryOperator: "AND",
} as GetOrganizationsUsersInput)
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(1);
const userWithAssignedOptions = userData.find(
(u: GetOrgUsersWithProfileOutput) => u.email === user.email
);
expect(userWithAssignedOptions).toBeDefined();
expect(userWithAssignedOptions?.email).toBe(user.email);
expect(userWithAssignedOptions?.profile.username).toBe(user.username);
});
it("should get users with at least one of the specified assigned attribute options", async () => {
const { body } = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users`)
.query({
assignedOptionIds: [assignedOption1.id, assignedOption2.id],
attributeQueryOperator: "OR",
} as GetOrganizationsUsersInput)
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(2);
const userWithAssignedOptions = userData.find(
(u: GetOrgUsersWithProfileOutput) => u.email === user.email
);
expect(userWithAssignedOptions).toBeDefined();
expect(userWithAssignedOptions?.email).toBe(user.email);
expect(userWithAssignedOptions?.profile.username).toBe(user.username);
const userWithAssignedOptions2 = userData.find(
(u: GetOrgUsersWithProfileOutput) => u.email === user2.email
);
expect(userWithAssignedOptions2).toBeDefined();
expect(userWithAssignedOptions2?.email).toBe(user2.email);
expect(userWithAssignedOptions2?.profile.username).toBe(user2.username);
});
it("should get users with at least one of the specified assigned attribute options filtered by teams", async () => {
const { body } = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users`)
.query({
assignedOptionIds: [assignedOption1.id, assignedOption2.id],
attributeQueryOperator: "OR",
teamIds: [team.id],
} as GetOrganizationsUsersInput)
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(1);
const userWithAssignedOptions = userData.find(
(u: GetOrgUsersWithProfileOutput) => u.email === user.email
);
expect(userWithAssignedOptions).toBeDefined();
expect(userWithAssignedOptions?.email).toBe(user.email);
expect(userWithAssignedOptions?.profile.username).toBe(user.username);
});
it("should get users with none of the specified assigned attribute options", async () => {
const { body } = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users`)
.query({
assignedOptionIds: [assignedOption1.id, assignedOption2.id],
attributeQueryOperator: "NONE",
} as GetOrganizationsUsersInput)
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(0);
});
afterAll(async () => {
// await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
@@ -1,3 +1,52 @@
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { Expose } from "class-transformer";
import { IsOptional, IsArray, ArrayMinSize, IsString, IsIn, IsNumber } from "class-validator";
export class GetOrganizationsUsersInput extends GetUsersInput {}
export class GetOrganizationsUsersInput extends GetUsersInput {
@Expose()
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((optId: string) => optId);
}
return value;
})
@ApiPropertyOptional({
type: [String],
description: "Filter by assigned attribute option ids. ids must be separated by a comma.",
example: "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee",
})
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1, { message: "assignedOptionIds must contain at least 1 attribute option id" })
assignedOptionIds?: string[];
@ApiPropertyOptional({
type: String,
description: "Query operator used to filter assigned options, AND by default.",
example: "NONE",
})
@IsOptional()
@IsString()
@IsIn(["OR", "AND", "NONE"])
attributeQueryOperator: "AND" | "OR" | "NONE" = "AND"; // Default value
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((teamId: string) => parseInt(teamId));
}
return value;
})
@ApiPropertyOptional({
type: [Number],
description: "Filter by teamIds. Team ids must be separated by a comma.",
example: "?teamIds=100,200",
})
@IsArray()
@IsNumber({}, { each: true })
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
teamIds?: number[];
}
@@ -17,11 +17,64 @@ export class OrganizationsUsersRepository {
};
}
async getOrganizationUsersByEmails(orgId: number, emailArray?: string[], skip?: number, take?: number) {
async getOrganizationUsersByEmailsAndAttributeFilters(
orgId: number,
filters: {
teamIds?: number[];
assignedOptionIds: string[];
attributeQueryOperator?: "AND" | "OR" | "NONE";
},
emailArray?: string[],
skip?: number,
take?: number
) {
const { teamIds, assignedOptionIds, attributeQueryOperator } = filters ?? {};
const attributeToUsersWithProfile = await this.dbRead.prisma.attributeToUser.findMany({
include: {
member: { include: { user: { include: { profiles: { where: { organizationId: orgId } } } } } },
},
distinct: ["memberId"],
where: {
member: {
teamId: orgId,
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
// Filter to only get users which have ALL of the assigned attribue options
...(attributeQueryOperator === "AND" && {
AND: assignedOptionIds.map((optionId) => ({
AttributeToUser: { some: { attributeOptionId: optionId } },
})),
}),
},
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
// Filter to get users which have AT LEAST ONE of the assigned attribue options
...(attributeQueryOperator === "OR" && {
attributeOption: { id: { in: assignedOptionIds } },
}),
// Filter to get users that have NONE the assigned attribue options
...(attributeQueryOperator === "NONE" && {
NOT: {
attributeOption: { id: { in: assignedOptionIds } },
},
}),
},
skip,
take,
});
return attributeToUsersWithProfile.map((attributeToUser) => attributeToUser.member.user);
}
async getOrganizationUsersByEmails(
orgId: number,
emailArray?: string[],
teamIds?: number[],
skip?: number,
take?: number
) {
return await this.dbRead.prisma.user.findMany({
where: {
...this.filterOnOrgMembership(orgId),
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
...(teamIds && { teams: { some: { teamId: { in: teamIds } } } }),
},
include: {
profiles: {
@@ -16,12 +16,37 @@ export class OrganizationsUsersService {
private readonly emailService: EmailService
) {}
async getUsers(orgId: number, emailInput?: string[], skip?: number, take?: number) {
async getUsers(
orgId: number,
emailInput?: string[],
filters?: {
teamIds?: number[];
assignedOptionIds?: string[];
attributeQueryOperator?: "AND" | "OR" | "NONE";
},
skip?: number,
take?: number
) {
const emailArray = !emailInput ? [] : emailInput;
if (filters?.assignedOptionIds && filters?.assignedOptionIds?.length) {
return await this.organizationsUsersRepository.getOrganizationUsersByEmailsAndAttributeFilters(
orgId,
{
assignedOptionIds: filters.assignedOptionIds,
attributeQueryOperator: filters?.attributeQueryOperator ?? "AND",
teamIds: filters?.teamIds,
},
emailArray,
skip,
take
);
}
const users = await this.organizationsUsersRepository.getOrganizationUsersByEmails(
orgId,
emailArray,
filters?.teamIds,
skip,
take
);
@@ -11,7 +11,6 @@ export class OrgUsersOOORepository {
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" },
filters?: { email?: string }
) {
console.log({ sort, filters });
return this.dbRead.prisma.outOfOfficeEntry.findMany({
where: {
user: {
@@ -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];
}
}
@@ -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`)
@@ -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,
},
});
}
}
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>
</>
);
}
@@ -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 />;
}
@@ -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;
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "@calcom/features/webhooks/pages/webhook-new-skeleton";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -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} />;
</>
);
}
+2
View File
@@ -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;
-2
View File
@@ -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>&nbsp;/&nbsp;</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";
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.2.0",
"version": "5.2.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+39 -2
View File
@@ -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."
);
});
+33
View File
@@ -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(
+13 -1
View File
@@ -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": "تم تشغيل هذا الاستبيان بواسطة سير العمل في كال."
}
+12 -1
View File
@@ -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ı."
}
+13 -1
View File
@@ -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