Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6410687fb6 | ||
|
|
4e4c8f5f49 | ||
|
|
416b17cf69 | ||
|
|
27cd7c8496 | ||
|
|
02321c33b2 | ||
|
|
c3dab3e4f8 | ||
|
|
0c182e2813 | ||
|
|
51a076212b | ||
|
|
27dce7374b | ||
|
|
52f2c18800 | ||
|
|
35a8918ea9 | ||
|
|
b39eab72e7 | ||
|
|
928b2477be | ||
|
|
dc13251bcc | ||
|
|
4843658dfb | ||
|
|
076afdc00e | ||
|
|
4404699fa6 | ||
|
|
52940ce254 | ||
|
|
cd6293d220 | ||
|
|
108f95723b | ||
|
|
f611c3d767 | ||
|
|
6091208f8a | ||
|
|
585a12fa48 | ||
|
|
bfc4f7b66c | ||
|
|
9dca13305f | ||
|
|
0ab9823c9a | ||
|
|
b3fd4f307f | ||
|
|
06ed0de456 | ||
|
|
79b6278883 | ||
|
|
673823cb97 | ||
|
|
15b26dff73 | ||
|
|
2303f2e9e1 | ||
|
|
056b821070 | ||
|
|
c90fded6b7 | ||
|
|
aa6f1a7f93 | ||
|
|
0cdd7c9605 | ||
|
|
f40a1c92ef | ||
|
|
739ef153f6 | ||
|
|
850ac8092f | ||
|
|
5b1eb48e6b | ||
|
|
da6fc9f2d5 | ||
|
|
20434856e9 | ||
|
|
7f891adff2 | ||
|
|
eb66fbe054 | ||
|
|
29122934a9 | ||
|
|
0825cce82c | ||
|
|
fd964131d1 | ||
|
|
77d9c15fed | ||
|
|
b21295f268 | ||
|
|
5769553659 | ||
|
|
d23d68bf2d | ||
|
|
d04c7f58a1 | ||
|
|
93e2426760 | ||
|
|
9f6c05f389 | ||
|
|
f8a1bd7b94 | ||
|
|
02967961f3 | ||
|
|
053b8b5c0a | ||
|
|
d59efd3ba0 | ||
|
|
ce56cd1ab0 | ||
|
|
54b6deee19 | ||
|
|
8315641724 | ||
|
|
aa3086cfae | ||
|
|
b599ce4919 | ||
|
|
52f16604c1 | ||
|
|
c98c06062b | ||
|
|
4963d247dc | ||
|
|
9405bc9553 | ||
|
|
8a080b91b9 | ||
|
|
4aa05fe523 | ||
|
|
d74d99dde7 | ||
|
|
0ee137efbb | ||
|
|
90f55670dc | ||
|
|
9c53f392ca | ||
|
|
cc19a81875 | ||
|
|
67032ddaf1 | ||
|
|
2fe71c3695 | ||
|
|
21422fe027 | ||
|
|
95575291ff | ||
|
|
3e61a0633d | ||
|
|
85c9a790f2 | ||
|
|
e17d55457f | ||
|
|
d8d8bc4d00 | ||
|
|
55cc1aea59 | ||
|
|
ae383164bc | ||
|
|
0d8863fe8e | ||
|
|
51f6654635 | ||
|
|
fa11e10bcb | ||
|
|
a8f5bd6fe9 | ||
|
|
a1c725bb35 | ||
|
|
e4eeb11a61 | ||
|
|
3b0bb25251 | ||
|
|
d27490e6f2 | ||
|
|
b3c191b553 | ||
|
|
0b977e615d | ||
|
|
979e1fd28b | ||
|
|
3437c4331d |
@@ -164,6 +164,7 @@ SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# Used for capturing exceptions and logging messages
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN_CLIENT=
|
||||
SENTRY_DEBUG=
|
||||
SENTRY_MAX_SPANS=
|
||||
SENTRY_SAMPLE_RATE=
|
||||
|
||||
@@ -27,8 +27,24 @@ jobs:
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --local user.email "github-actions@github.com"
|
||||
git config --local user.name "GitHub Actions"
|
||||
# Define authorized users and their emails using organization secret
|
||||
# Expected format: JSON object with username -> email mapping
|
||||
# Example: {"zomars": "zomars@cal.com", "peer": "peer@cal.com", "username3": "email3@cal.com"}
|
||||
USER_EMAILS='${{ secrets.RELEASE_USER_EMAILS }}'
|
||||
|
||||
# Extract email for the triggering user
|
||||
USER_EMAIL=$(echo "$USER_EMAILS" | jq -r --arg user "${{ github.actor }}" '.[$user] // empty')
|
||||
|
||||
# Fail if user is not authorized or not in mapping
|
||||
if [ -z "$USER_EMAIL" ] || [ "$USER_EMAIL" = "null" ]; then
|
||||
echo "Error: User '${{ github.actor }}' is not authorized to run the release workflow."
|
||||
echo "Only authorized team members can trigger releases."
|
||||
echo "Contact your administrator to be added to the authorized users list."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config --local user.email "$USER_EMAIL"
|
||||
git config --local user.name "${{ github.actor }}"
|
||||
|
||||
- uses: ./.github/actions/yarn-install
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
|
||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || github.event.label.name == 'ready-for-e2e') }}
|
||||
steps:
|
||||
- name: Check if PR exists with ready-for-e2e label for this SHA
|
||||
id: check-if-pr-has-label
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
@@ -43,6 +43,7 @@ enum locales {
|
||||
SR = "sr",
|
||||
SV = "sv",
|
||||
VI = "vi",
|
||||
BN = "bn",
|
||||
}
|
||||
enum theme {
|
||||
DARK = "dark",
|
||||
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -5,9 +5,6 @@ const plugins = [withAxiom];
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
},
|
||||
transpilePackages: [
|
||||
"@calcom/app-store",
|
||||
"@calcom/dayjs",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^13.5.9",
|
||||
"next": "^15.3.0",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -93,6 +93,8 @@ describe("POST /api/selected-calendars", () => {
|
||||
watchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
@@ -136,6 +138,8 @@ describe("POST /api/selected-calendars", () => {
|
||||
watchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
@@ -38,8 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.241",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -232,6 +232,15 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
|
||||
if (bookingUid) {
|
||||
const { bookingInfo } = await getBookingInfo(bookingUid);
|
||||
if (!bookingInfo) {
|
||||
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||
}
|
||||
if (bookingInfo.status === "CANCELLED") {
|
||||
throw new BadRequestException(
|
||||
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
}
|
||||
try {
|
||||
req.body.uid = bookingUid;
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
|
||||
@@ -443,7 +443,7 @@ export class BookingsController_2024_08_13 {
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get 'Booking References' for a booking",
|
||||
summary: "Get booking references",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getBookingReferences(
|
||||
|
||||
@@ -2553,6 +2553,59 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cant't cancel already cancelled booking", () => {
|
||||
it("should not be able to cancel alraedy cancelled booking", async () => {
|
||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancellationReason: "Going on a vacation",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${cancelledBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't cancel booking with uid=${cancelledBooking.uid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar events", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
@@ -186,6 +186,20 @@ export class InputBookingsService_2024_08_13 {
|
||||
guests,
|
||||
location,
|
||||
},
|
||||
...this.getRoutingFormData(inputBooking.routing),
|
||||
};
|
||||
}
|
||||
|
||||
private getRoutingFormData(routing: { teamMemberIds?: number[]; responseId?: number; teamMemberEmail?: string; skipContactOwner?: boolean; crmAppSlug?: string; crmOwnerRecordType?: string } | undefined) {
|
||||
if (!routing) return null;
|
||||
|
||||
return {
|
||||
routedTeamMemberIds: routing.teamMemberIds,
|
||||
routingFormResponseId: routing.responseId,
|
||||
teamMemberEmail: routing.teamMemberEmail,
|
||||
skipContactOwner: routing.skipContactOwner,
|
||||
crmAppSlug: routing.crmAppSlug,
|
||||
crmOwnerRecordType: routing.crmOwnerRecordType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,6 +467,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
location,
|
||||
},
|
||||
schedulingType: eventType.schedulingType,
|
||||
...this.getRoutingFormData(inputBooking.routing),
|
||||
});
|
||||
|
||||
switch (timeBetween) {
|
||||
@@ -711,6 +726,12 @@ export class InputBookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
}
|
||||
|
||||
if (booking.status === "CANCELLED") {
|
||||
throw new BadRequestException(
|
||||
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
}
|
||||
|
||||
const oAuthClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
const credentialForCalendarRepositorySelect = {
|
||||
id: true,
|
||||
appId: true,
|
||||
type: true,
|
||||
@@ -16,7 +16,7 @@ const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.Credential
|
||||
teamId: true,
|
||||
key: true,
|
||||
invalid: true,
|
||||
});
|
||||
} satisfies Prisma.CredentialSelect;
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsRepository {
|
||||
|
||||
@@ -157,7 +157,7 @@ export class CalendarsController {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get oAuth connect URL" })
|
||||
@ApiOperation({ summary: "Get OAuth connect URL" })
|
||||
@ApiQuery({
|
||||
name: "redir",
|
||||
required: false,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
+24
-5
@@ -18,6 +18,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
@@ -33,13 +34,19 @@ import {
|
||||
HttpStatus,
|
||||
Delete,
|
||||
Query,
|
||||
Headers,
|
||||
InternalServerErrorException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
EVENT_TYPE_READ,
|
||||
EVENT_TYPE_WRITE,
|
||||
SUCCESS_STATUS,
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries/event-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Controller({
|
||||
@@ -51,7 +58,8 @@ import { PrismaClient } from "@calcom/prisma";
|
||||
export class EventTypesController_2024_04_15 {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||
private readonly prismaReadService: PrismaReadService
|
||||
private readonly prismaReadService: PrismaReadService,
|
||||
private readonly organizationsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@@ -109,19 +117,30 @@ export class EventTypesController_2024_04_15 {
|
||||
async getPublicEventType(
|
||||
@Param("username") username: string,
|
||||
@Param("eventSlug") eventSlug: string,
|
||||
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15
|
||||
@Query() queryParams: GetPublicEventTypeQueryParams_2024_04_15,
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<GetEventTypePublicOutput> {
|
||||
try {
|
||||
let orgSlug = queryParams.org;
|
||||
|
||||
if (clientId && !orgSlug && username.includes(`-${clientId}`)) {
|
||||
const org = await this.organizationsRepository.findTeamIdAndSlugFromClientId(clientId).catch(() => null);
|
||||
if (org) {
|
||||
orgSlug = org.slug;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await getPublicEvent(
|
||||
username.toLowerCase(),
|
||||
eventSlug,
|
||||
queryParams.isTeamEvent,
|
||||
queryParams.org || null,
|
||||
orgSlug ?? null,
|
||||
this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
|
||||
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
data: event,
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventTypesController_2024_04_15 } from "@/ee/event-types/event-types_20
|
||||
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
@@ -9,7 +10,14 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
UsersModule,
|
||||
SelectedCalendarsModule,
|
||||
OrganizationsModule,
|
||||
],
|
||||
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
|
||||
controllers: [EventTypesController_2024_04_15],
|
||||
exports: [EventTypesService_2024_04_15, EventTypesRepository_2024_04_15],
|
||||
|
||||
+9
-1
@@ -1721,7 +1721,7 @@ describe("Event types Endpoints", () => {
|
||||
description: "undefined booking fields",
|
||||
length: 40,
|
||||
hidden: false,
|
||||
slug: "undefined-booking-fields",
|
||||
slug: `undefined-booking-fields-${randomString()}`,
|
||||
locations: [],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
};
|
||||
@@ -1751,6 +1751,14 @@ describe("Event types Endpoints", () => {
|
||||
disableOnPrefill: false,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
disableOnPrefill: false,
|
||||
hidden: true,
|
||||
isDefault: true,
|
||||
required: false,
|
||||
slug: "attendeePhoneNumber",
|
||||
type: "phone",
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "radioInput",
|
||||
|
||||
+9
-11
@@ -46,11 +46,10 @@ export class SchedulesController_2024_04_15 {
|
||||
@Body() bodySchedule: CreateScheduleInput_2024_04_15
|
||||
): Promise<CreateScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,13 +59,10 @@ export class SchedulesController_2024_04_15 {
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||
const scheduleFormatted = schedule
|
||||
? await this.schedulesService.formatScheduleForAtom(user, schedule)
|
||||
: null;
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,23 +73,25 @@ export class SchedulesController_2024_04_15 {
|
||||
@Param("scheduleId") scheduleId: number
|
||||
): Promise<GetScheduleOutput_2024_04_15> {
|
||||
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: scheduleFormatted,
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
||||
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
|
||||
const schedules = await this.schedulesService.getUserSchedules(
|
||||
user.id,
|
||||
user.timeZone,
|
||||
user.defaultScheduleId
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedulesFormatted,
|
||||
data: schedules,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesRepository_2024_04_15 {
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
|
||||
import { SchedulesRepository_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.repository";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Schedule } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
|
||||
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
|
||||
import {
|
||||
transformWorkingHoursForClient,
|
||||
transformAvailabilityForClient,
|
||||
transformDateOverridesForClient,
|
||||
} from "@calcom/platform-libraries-0.0.2";
|
||||
import { updateSchedule } from "@calcom/platform-libraries/schedules";
|
||||
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService_2024_04_15 {
|
||||
constructor(
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly dbWrite: PrismaWriteService
|
||||
) {}
|
||||
|
||||
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||
@@ -48,7 +44,9 @@ export class SchedulesService_2024_04_15 {
|
||||
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
|
||||
}
|
||||
|
||||
return createdSchedule;
|
||||
const formattedSchedule = await this.getUserSchedule(userId, createdSchedule.id);
|
||||
|
||||
return formattedSchedule;
|
||||
}
|
||||
|
||||
async getUserScheduleDefault(userId: number) {
|
||||
@@ -56,11 +54,29 @@ export class SchedulesService_2024_04_15 {
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
|
||||
return this.schedulesRepository.getScheduleById(user.defaultScheduleId);
|
||||
return await ScheduleRepository.findDetailedScheduleById({
|
||||
scheduleId: user.defaultScheduleId,
|
||||
isManagedEventType: undefined,
|
||||
userId,
|
||||
timeZone: user.timeZone,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserSchedule(userId: number, scheduleId: number) {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with ID=${userId} does not exist.`);
|
||||
}
|
||||
|
||||
const existingSchedule = await ScheduleRepository.findDetailedScheduleById({
|
||||
scheduleId: scheduleId,
|
||||
isManagedEventType: undefined,
|
||||
userId,
|
||||
timeZone: user.timeZone,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
});
|
||||
|
||||
if (!existingSchedule) {
|
||||
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||
@@ -71,8 +87,8 @@ export class SchedulesService_2024_04_15 {
|
||||
return existingSchedule;
|
||||
}
|
||||
|
||||
async getUserSchedules(userId: number) {
|
||||
return this.schedulesRepository.getSchedulesByUserId(userId);
|
||||
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
|
||||
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
|
||||
}
|
||||
|
||||
async updateUserSchedule(
|
||||
@@ -89,7 +105,6 @@ export class SchedulesService_2024_04_15 {
|
||||
this.checkUserOwnsSchedule(user.id, existingSchedule);
|
||||
|
||||
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
|
||||
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
|
||||
|
||||
if (!bodySchedule.schedule) {
|
||||
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
|
||||
@@ -97,12 +112,16 @@ export class SchedulesService_2024_04_15 {
|
||||
// and they have same shape, so to match shapes I attach "scheduleFormatted.availability" to reflect
|
||||
// schedule that would be passed by the web app. If we don't, then updating schedule name will erase
|
||||
// schedule.
|
||||
bodySchedule.schedule = scheduleFormatted.availability;
|
||||
bodySchedule.schedule = schedule.availability;
|
||||
}
|
||||
|
||||
return updateScheduleHandler({
|
||||
input: { scheduleId: Number(scheduleId), ...bodySchedule },
|
||||
ctx: { user },
|
||||
return updateSchedule({
|
||||
input: {
|
||||
scheduleId: Number(scheduleId),
|
||||
...bodySchedule,
|
||||
},
|
||||
user,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,44 +137,6 @@ export class SchedulesService_2024_04_15 {
|
||||
return this.schedulesRepository.deleteScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async formatScheduleForAtom(user: User, schedule: ScheduleWithAvailabilities): Promise<ScheduleOutput> {
|
||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
||||
return this.transformScheduleForAtom(schedule, usersSchedulesCount, user);
|
||||
}
|
||||
|
||||
async formatSchedulesForAtom(
|
||||
user: User,
|
||||
schedules: ScheduleWithAvailabilities[]
|
||||
): Promise<ScheduleOutput[]> {
|
||||
const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id);
|
||||
return Promise.all(
|
||||
schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user))
|
||||
);
|
||||
}
|
||||
|
||||
async transformScheduleForAtom(
|
||||
schedule: ScheduleWithAvailabilities,
|
||||
userSchedulesCount: number,
|
||||
user: Pick<User, "id" | "defaultScheduleId" | "timeZone">
|
||||
): Promise<ScheduleOutput> {
|
||||
const timeZone = schedule.timeZone || user.timeZone;
|
||||
const defaultSchedule = await this.getUserScheduleDefault(user.id);
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
name: schedule.name,
|
||||
isManaged: schedule.userId !== user.id,
|
||||
workingHours: transformWorkingHoursForClient(schedule),
|
||||
schedule: schedule.availability,
|
||||
availability: transformAvailabilityForClient(schedule),
|
||||
timeZone,
|
||||
dateOverrides: transformDateOverridesForClient(schedule, timeZone),
|
||||
isDefault: defaultSchedule?.id === schedule.id,
|
||||
isLastSchedule: userSchedulesCount <= 1,
|
||||
readOnly: schedule.userId !== user.id,
|
||||
};
|
||||
}
|
||||
|
||||
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
|
||||
if (userId !== schedule.userId) {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -39,6 +40,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
@@ -46,6 +48,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(400).json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -16,6 +17,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
const statusCode = exception.getStatus();
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
@@ -23,6 +25,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
||||
@@ -43,6 +44,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`PrismaError: ${error.message}`, {
|
||||
error,
|
||||
body: request.body,
|
||||
@@ -50,6 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
let message = "There was an error, please try again later.";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
@@ -120,6 +121,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
@@ -127,6 +129,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||
import { Catch, HttpStatus, Logger } from "@nestjs/common";
|
||||
@@ -18,6 +19,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
const userContext = extractUserContext(request);
|
||||
this.logger.error(`ZodError: ${error.message}`, {
|
||||
error,
|
||||
body: request.body,
|
||||
@@ -25,6 +27,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
...userContext,
|
||||
});
|
||||
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
|
||||
@@ -41,4 +41,5 @@ export enum Locales {
|
||||
TA = "ta",
|
||||
UK = "uk",
|
||||
ZH_TW = "zh-TW",
|
||||
BN = "bn",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
import { ApiAuthGuardUser } from "../modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
export interface UserContext {
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
organizationId?: string;
|
||||
teamId?: string;
|
||||
userOrgId?: string;
|
||||
}
|
||||
|
||||
export function extractUserContext(request: Request): UserContext {
|
||||
const context: UserContext = {};
|
||||
|
||||
const user = (request as any).user as ApiAuthGuardUser | undefined;
|
||||
if (user) {
|
||||
context.userId = String(user.id);
|
||||
context.userEmail = user.email;
|
||||
if (user.profiles?.[0]?.organizationId) {
|
||||
context.userOrgId = String(user.profiles?.[0].organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
const organizationId = (request as any).organizationId as number | undefined;
|
||||
if (organizationId) {
|
||||
context.organizationId = String(organizationId);
|
||||
}
|
||||
|
||||
const team = (request as any).team as Team | undefined;
|
||||
if (team) {
|
||||
context.teamId = String(team.id);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { tap } from "rxjs/operators";
|
||||
@@ -14,6 +15,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
const { method, url } = request;
|
||||
const userContext = extractUserContext(request);
|
||||
const startTime = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
@@ -39,6 +41,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
||||
responseTime,
|
||||
responseBody: jsonBodyString,
|
||||
timestamp: new Date().toISOString(),
|
||||
...userContext,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -95,7 +95,9 @@ export class EventTypesAtomService {
|
||||
}
|
||||
|
||||
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
|
||||
const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged);
|
||||
const onlyManagedTeamMembers = eventType.teamMembers.filter(
|
||||
(user) => user.isPlatformManaged && user.membership === "MEMBER"
|
||||
);
|
||||
eventType.teamMembers = onlyManagedTeamMembers;
|
||||
|
||||
return eventType;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingSeatRepository {
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ export class CalUnifiedCalendarsController {
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get meeting Details from calendar",
|
||||
summary: "Get meeting details from calendar",
|
||||
description: "Returns detailed information about a meeting including attendance metrics",
|
||||
})
|
||||
async getCalendarEventDetails(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { JWT } from "googleapis-common";
|
||||
|
||||
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@@ -87,7 +87,7 @@ export class ConferencingController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app auth url" })
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app auth URL" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
@@ -130,7 +130,7 @@ export class ConferencingController {
|
||||
@Get("/:app/oauth/callback")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "conferencing apps oauths callback" })
|
||||
@ApiOperation({ summary: "Conferencing app OAuth callback" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
export class ConferencingRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
private readonly credentialSelect = {
|
||||
id: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
@@ -20,7 +20,7 @@ export class ConferencingRepository {
|
||||
billingCycleStart: true,
|
||||
invalid: true,
|
||||
delegationCredentialId: true,
|
||||
});
|
||||
} satisfies Prisma.CredentialSelect;
|
||||
|
||||
async findConferencingApps(userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||
|
||||
+4
@@ -92,6 +92,8 @@ describe("Platform Destination Calendar Endpoints", () => {
|
||||
publisher: "",
|
||||
url: "",
|
||||
email: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
// calendars: {
|
||||
// externalId:
|
||||
@@ -118,6 +120,8 @@ describe("Platform Destination Calendar Endpoints", () => {
|
||||
id: 0,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { PrismaWriteService } from "../../prisma/prisma-write.service";
|
||||
|
||||
|
||||
+2
-2
@@ -134,7 +134,7 @@ 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" })
|
||||
@ApiOperation({ summary: "Get all assigned attribute options by attribute ID" })
|
||||
async getOrganizationAttributeAssignedOptions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@@ -158,7 +158,7 @@ export class OrganizationsAttributesOptionsController {
|
||||
@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" })
|
||||
@ApiOperation({ summary: "Get all assigned attribute options by attribute slug" })
|
||||
async getOrganizationAttributeAssignedOptionsBySlug(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeSlug") attributeSlug: string,
|
||||
|
||||
@@ -34,7 +34,7 @@ export class OrganizationsBookingsController {
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get Organization Bookings" })
|
||||
@ApiOperation({ summary: "Get organization bookings" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ export class OrganizationsConferencingController {
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app's auth url for a team" })
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app's auth URL for a team" })
|
||||
async getTeamOAuthUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ export class OrganizationsDelegationCredentialController {
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@ApiOperation({ summary: "Save delegation credentials for your organization." })
|
||||
@ApiOperation({ summary: "Save delegation credentials for your organization" })
|
||||
async createDelegationCredential(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetUser() delegatedServiceAccountUser: User,
|
||||
@@ -72,7 +72,7 @@ export class OrganizationsDelegationCredentialController {
|
||||
@Patch("/:credentialId")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@ApiOperation({ summary: "Update delegation credentials of your organization." })
|
||||
@ApiOperation({ summary: "Update delegation credentials of your organization" })
|
||||
async updateDelegationCredential(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetUser() delegatedServiceAccountUser: User,
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsDelegationCredentialRepository {
|
||||
|
||||
+12
-6
@@ -304,8 +304,10 @@ describe("Assign all team members", () => {
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
expect(data.schedulingType).toEqual("collective");
|
||||
evaluateHost({ userId: firstManagedUser.user.id }, data.hosts[0]);
|
||||
evaluateHost({ userId: secondManagedUser.user.id }, data.hosts[1]);
|
||||
const dataFirstHost = data.hosts.find((host) => host.userId === firstManagedUser.user.id);
|
||||
const dataSecondHost = data.hosts.find((host) => host.userId === secondManagedUser.user.id);
|
||||
evaluateHost({ userId: firstManagedUser.user.id }, dataFirstHost);
|
||||
evaluateHost({ userId: secondManagedUser.user.id }, dataSecondHost);
|
||||
|
||||
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
|
||||
expect(eventTypeHosts.length).toEqual(2);
|
||||
@@ -341,13 +343,15 @@ describe("Assign all team members", () => {
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
expect(data.schedulingType).toEqual("roundRobin");
|
||||
const dataFirstHost = data.hosts.find((host) => host.userId === firstManagedUser.user.id);
|
||||
const dataSecondHost = data.hosts.find((host) => host.userId === secondManagedUser.user.id);
|
||||
evaluateHost(
|
||||
{ userId: firstManagedUser.user.id, mandatory: false, priority: "medium" },
|
||||
data.hosts[0]
|
||||
dataFirstHost
|
||||
);
|
||||
evaluateHost(
|
||||
{ userId: secondManagedUser.user.id, mandatory: false, priority: "medium" },
|
||||
data.hosts[1]
|
||||
dataSecondHost
|
||||
);
|
||||
|
||||
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
|
||||
@@ -381,13 +385,15 @@ describe("Assign all team members", () => {
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
expect(data.schedulingType).toEqual("roundRobin");
|
||||
const dataFirstHost = data.hosts.find((host) => host.userId === firstManagedUser.user.id);
|
||||
const dataSecondHost = data.hosts.find((host) => host.userId === secondManagedUser.user.id);
|
||||
evaluateHost(
|
||||
{ userId: firstManagedUser.user.id, mandatory: false, priority: "medium" },
|
||||
data.hosts[0]
|
||||
dataFirstHost
|
||||
);
|
||||
evaluateHost(
|
||||
{ userId: secondManagedUser.user.id, mandatory: false, priority: "medium" },
|
||||
data.hosts[1]
|
||||
dataSecondHost
|
||||
);
|
||||
|
||||
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
|
||||
|
||||
@@ -69,7 +69,7 @@ export class OrganizationsRepository {
|
||||
return id;
|
||||
}
|
||||
|
||||
async findTeamIdFromClientId(clientId: string) {
|
||||
async findTeamIdAndSlugFromClientId(clientId: string) {
|
||||
return this.dbRead.prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
platformOAuthClient: {
|
||||
@@ -80,6 +80,7 @@ export class OrganizationsRepository {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class ManagedOrganizationsRepository {
|
||||
|
||||
+418
-4
@@ -37,7 +37,13 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
let user: User;
|
||||
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let routingEventType: {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
teamId: number | null;
|
||||
userId: number | null;
|
||||
title: string;
|
||||
}
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
@@ -72,6 +78,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
@@ -93,14 +100,74 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `${keyString}`;
|
||||
|
||||
// Create an event type for routing form to route to
|
||||
routingEventType = await prismaWriteService.prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event Type",
|
||||
slug: "test-event-type",
|
||||
length: 30,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: JSON.stringify([]),
|
||||
fields: JSON.stringify([]),
|
||||
settings: JSON.stringify({}),
|
||||
routes: [
|
||||
{
|
||||
id: "route-1",
|
||||
queryValue: {
|
||||
id: "route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: routingEventType.id,
|
||||
value: `team/${team.slug}/${routingEventType.slug}`
|
||||
},
|
||||
isFallback: false
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" }
|
||||
}
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1"
|
||||
},
|
||||
{
|
||||
id: "question2",
|
||||
type: "text",
|
||||
label: "Question 2",
|
||||
required: false,
|
||||
identifier: "question2"
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false
|
||||
},
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
@@ -175,6 +242,353 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
||||
it("should return 403 when organization does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should return 404 when routing form does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should return 401 when authentication token is missing", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should create response and return available slots when routing to event type", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1", // This matches the route condition
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routing?.responseId).toBeDefined();
|
||||
expect(typeof data.routing?.responseId).toBe("number");
|
||||
expect(data.eventTypeId).toEqual(routingEventType.id);
|
||||
expect(data.slots).toBeDefined();
|
||||
expect(typeof data.slots).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 when required form fields are missing", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question2: "answer2", // Missing required question1
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create response and return custom message if the routing is to custom page", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "different-answer", // This won't match any route
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routingCustomMessage).toBeDefined();
|
||||
expect(data.routingCustomMessage).toBe("Fallback Message");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 when required slot query parameters are missing", async () => {
|
||||
// Missing start parameter
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
// Missing end parameter
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when date parameters have invalid format", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when end date is before start date", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 403 when user lacks permission to access organization", async () => {
|
||||
// Create a new user without organization access
|
||||
const unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: `unauthorized-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${unauthorizedApiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
// Clean up
|
||||
await prismaWriteService.prisma.user.delete({
|
||||
where: { id: unauthorizedUser.id },
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle queued response creation", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data.routing?.queuedResponseId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 500 when event type is not found", async () => {
|
||||
// Create a routing form with an invalid eventTypeId
|
||||
const routingFormWithInvalidEventType = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form with Invalid Event Type",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
id: "route-1",
|
||||
queryValue: {
|
||||
id: "route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: 99999, // Invalid event type ID
|
||||
value: `team/${team.slug}/non-existent-event-type`
|
||||
},
|
||||
isFallback: false
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" }
|
||||
}
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1"
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false
|
||||
},
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Try to create a response for the form with invalid event type
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
|
||||
// Clean up the form
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: routingFormWithInvalidEventType.id }
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle routing with team member assignments", async () => {
|
||||
// This test verifies that routing forms can handle team member assignments
|
||||
// and that the routing returns the correct team member information
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1", // This matches the route condition
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routing?.responseId).toBeDefined();
|
||||
expect(typeof data.routing?.responseId).toBe("number");
|
||||
expect(data.eventTypeId).toEqual(routingEventType.id);
|
||||
expect(data.slots).toBeDefined();
|
||||
// Team member assignments would be part of the routing data
|
||||
// This test validates the basic routing functionality works
|
||||
});
|
||||
});
|
||||
|
||||
it("should return external redirect URL when routing to external URL", async () => {
|
||||
// Create a routing form with external redirect action
|
||||
const externalRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test External Routing Form",
|
||||
description: "Test Description for External Redirect",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
id: "external-route-1",
|
||||
queryValue: {
|
||||
id: "external-route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["external"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
action: {
|
||||
type: "externalRedirectUrl",
|
||||
value: "https://example.com/external-booking"
|
||||
},
|
||||
isFallback: false
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" }
|
||||
}
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1"
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false
|
||||
},
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "external", // This matches the route condition for external redirect
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routingExternalRedirectUrl).toBeDefined();
|
||||
expect(data.routingExternalRedirectUrl).toContain("https://example.com/external-booking");
|
||||
expect(data.routingExternalRedirectUrl).toContain("cal.action=externalRedirectUrl");
|
||||
|
||||
// Verify that it doesn't contain event type routing data
|
||||
expect(data.eventTypeId).toBeUndefined();
|
||||
expect(data.slots).toBeUndefined();
|
||||
expect(data.routing).toBeUndefined();
|
||||
expect(data.routingCustomMessage).toBeUndefined();
|
||||
|
||||
// Clean up the external routing form
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: externalRoutingForm.id }
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
|
||||
it("should not update routing form response for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
|
||||
+39
-1
@@ -9,13 +9,28 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-form-responses.output";
|
||||
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
|
||||
import { Body, Controller, Get, Param, Patch, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
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";
|
||||
|
||||
import { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
|
||||
import { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
|
||||
import { UpdateRoutingFormResponseInput } from "../inputs/update-routing-form-response.input";
|
||||
import { CreateRoutingFormResponseOutput } from "../outputs/create-routing-form-response.output";
|
||||
import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-response.output";
|
||||
|
||||
@Controller({
|
||||
@@ -56,6 +71,29 @@ export class OrganizationsRoutingFormsResponsesController {
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({ summary: "Create routing form response and get available slots" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async createRoutingFormResponse(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("routingFormId") routingFormId: string,
|
||||
@Query() query: CreateRoutingFormResponseInput,
|
||||
@Req() request: Request
|
||||
): Promise<CreateRoutingFormResponseOutput> {
|
||||
const result = await this.organizationsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
|
||||
orgId,
|
||||
routingFormId,
|
||||
query,
|
||||
request
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:responseId")
|
||||
@ApiOperation({ summary: "Update routing form response" })
|
||||
@Roles("ORG_ADMIN")
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsOptional } from "class-validator";
|
||||
|
||||
import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
|
||||
|
||||
export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_09_04 {
|
||||
@Transform(({ value }: { value: string | boolean }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true';
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
description: "Whether to queue the form response.",
|
||||
example: true,
|
||||
})
|
||||
queueResponse?: boolean;
|
||||
}
|
||||
+4
-1
@@ -4,7 +4,9 @@ import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsRoutingFormsResponsesController } from "./controllers/organizations-routing-forms-responses.controller";
|
||||
@@ -14,7 +16,7 @@ import { OrganizationsRoutingFormsResponsesService } from "./services/organizati
|
||||
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04],
|
||||
providers: [
|
||||
MembershipsRepository,
|
||||
OrganizationsRepository,
|
||||
@@ -22,6 +24,7 @@ import { OrganizationsRoutingFormsService } from "./services/organizations-routi
|
||||
OrganizationsRoutingFormsService,
|
||||
OrganizationsRoutingFormsResponsesService,
|
||||
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
TeamsEventTypesRepository,
|
||||
],
|
||||
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
|
||||
})
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types";
|
||||
class Routing {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "The ID of the queued form response. Only present if the form response was queued.",
|
||||
example: "123",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
queuedResponseId?: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
description: "The ID of the routing form response.",
|
||||
example: 123,
|
||||
})
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
responseId?: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
type: [Number],
|
||||
description: "Array of team member IDs that were routed to handle this booking.",
|
||||
example: [101, 102],
|
||||
})
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
teamMemberIds!: number[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The email of the team member assigned to handle this booking.",
|
||||
example: "john.doe@example.com",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
teamMemberEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
description: "Whether to skip contact owner assignment from CRM integration.",
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
skipContactOwner?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The CRM application slug for integration.",
|
||||
example: "salesforce",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
crmAppSlug?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The CRM owner record type for contact assignment.",
|
||||
example: "Account",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
crmOwnerRecordType?: string;
|
||||
}
|
||||
|
||||
export class CreateRoutingFormResponseOutputData {
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
description: "The ID of the event type that was routed to.",
|
||||
example: 123,
|
||||
})
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
eventTypeId?: number;
|
||||
|
||||
@ValidateNested()
|
||||
@ApiProperty({ type: Routing })
|
||||
@Type(() => Routing)
|
||||
@ApiPropertyOptional({
|
||||
type: Routing,
|
||||
description: "The routing information.",
|
||||
example: {
|
||||
eventTypeId: 123,
|
||||
routing: {
|
||||
teamMemberIds: [101, 102],
|
||||
teamMemberEmail: "john.doe@example.com",
|
||||
skipContactOwner: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Routing)
|
||||
routing?: Routing;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "A custom message to be displayed to the user in case of routing to a custom page.",
|
||||
example: "This is a custom message.",
|
||||
})
|
||||
routingCustomMessage?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The external redirect URL to be used in case of routing to a non cal.com event type URL.",
|
||||
example: "https://example.com/",
|
||||
})
|
||||
routingExternalRedirectUrl?: string;
|
||||
|
||||
@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 CreateRoutingFormResponseOutput extends ApiResponseWithoutData {
|
||||
@ValidateNested()
|
||||
@ApiProperty({ type: CreateRoutingFormResponseOutputData })
|
||||
@Type(() => CreateRoutingFormResponseOutputData)
|
||||
data!: CreateRoutingFormResponseOutputData;
|
||||
}
|
||||
+203
-2
@@ -1,12 +1,23 @@
|
||||
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
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 { BadRequestException, Injectable, NotFoundException, InternalServerErrorException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { getRoutedUrl } from "@calcom/platform-libraries";
|
||||
import { ById_2024_09_04_type } from "@calcom/platform-types";
|
||||
|
||||
import type { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
|
||||
import type { CreateRoutingFormResponseOutputData } from "../outputs/create-routing-form-response.output";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRoutingFormsResponsesService {
|
||||
constructor(
|
||||
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
|
||||
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService
|
||||
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
private readonly slotsService: SlotsService_2024_09_04,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
) {}
|
||||
|
||||
async getOrganizationRoutingFormResponses(
|
||||
@@ -52,4 +63,194 @@ export class OrganizationsRoutingFormsResponsesService {
|
||||
|
||||
return this.outputService.getRoutingFormResponses([updatedResponse])[0];
|
||||
}
|
||||
|
||||
async createRoutingFormResponseWithSlots(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
query: CreateRoutingFormResponseInput,
|
||||
request: Request
|
||||
): Promise<CreateRoutingFormResponseOutputData> {
|
||||
const { queueResponse, ...slotsQuery } = query;
|
||||
|
||||
this.validateDateRange(slotsQuery.start, slotsQuery.end);
|
||||
|
||||
const { redirectUrl, customMessage } = await this.getRoutingUrl(request, routingFormId, queueResponse ?? false);
|
||||
|
||||
// If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen
|
||||
if (!redirectUrl) {
|
||||
return {
|
||||
routingCustomMessage: customMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
|
||||
return {
|
||||
routingExternalRedirectUrl: redirectUrl.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
// Extract event type information from the routed URL
|
||||
const { eventTypeId, crmParams } = await this.extractEventTypeAndCrmParams(redirectUrl);
|
||||
|
||||
const paramsForGetAvailableSlots = {
|
||||
type: ById_2024_09_04_type,
|
||||
eventTypeId,
|
||||
...slotsQuery,
|
||||
...crmParams,
|
||||
} as const;
|
||||
|
||||
// Get available slots using the slots service with CRM parameters
|
||||
const slots = await this.slotsService.getAvailableSlots(paramsForGetAvailableSlots);
|
||||
const teamMemberIds = crmParams.routedTeamMemberIds ?? [];
|
||||
const teamMemberEmail = crmParams.teamMemberEmail ?? undefined;
|
||||
const skipContactOwner = crmParams.skipContactOwner ?? undefined;
|
||||
const queuedResponseId = crmParams.queuedFormResponseId ?? null;
|
||||
const responseId = crmParams.routingFormResponseId ?? null;
|
||||
const crmAppSlug = crmParams.crmAppSlug ?? undefined;
|
||||
const crmOwnerRecordType = crmParams.crmOwnerRecordType ?? undefined;
|
||||
|
||||
if (responseId) {
|
||||
return {
|
||||
routing: {
|
||||
responseId,
|
||||
teamMemberEmail,
|
||||
teamMemberIds,
|
||||
skipContactOwner,
|
||||
crmAppSlug,
|
||||
crmOwnerRecordType,
|
||||
},
|
||||
eventTypeId,
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
if (!queuedResponseId) {
|
||||
throw new InternalServerErrorException("No routing form response ID or queued form response ID could be found.");
|
||||
}
|
||||
|
||||
return {
|
||||
routing: {
|
||||
queuedResponseId,
|
||||
teamMemberEmail,
|
||||
teamMemberIds,
|
||||
skipContactOwner,
|
||||
crmAppSlug,
|
||||
crmOwnerRecordType,
|
||||
},
|
||||
eventTypeId,
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
private validateDateRange(start: string, end: string) {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
if (endDate < startDate) {
|
||||
throw new BadRequestException("End date cannot be before start date.");
|
||||
}
|
||||
}
|
||||
|
||||
private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) {
|
||||
const params = Object.fromEntries(new URLSearchParams(request.body));
|
||||
const routedUrlData = await getRoutedUrl({
|
||||
req: request,
|
||||
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
|
||||
}, true);
|
||||
|
||||
if (routedUrlData.notFound) {
|
||||
throw new NotFoundException("Routing form not found.");
|
||||
}
|
||||
|
||||
if (routedUrlData?.props?.errorMessage) {
|
||||
throw new BadRequestException(routedUrlData.props.errorMessage);
|
||||
}
|
||||
|
||||
const destination = routedUrlData?.redirect?.destination;
|
||||
|
||||
if (!destination) {
|
||||
if (routedUrlData?.props?.message) {
|
||||
return {
|
||||
redirectUrl: null,
|
||||
customMessage: routedUrlData.props.message,
|
||||
};
|
||||
}
|
||||
// This should never happen because there is always a fallback route
|
||||
throw new InternalServerErrorException("No route found.");
|
||||
}
|
||||
|
||||
return {
|
||||
redirectUrl: new URL(destination),
|
||||
customMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async extractEventTypeAndCrmParams(routingUrl: URL) {
|
||||
|
||||
|
||||
// Extract team and event type information
|
||||
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
|
||||
const eventType = await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug);
|
||||
|
||||
if (!eventType?.id) {
|
||||
// This could only happen if the event-type earlier selected as route action was deleted
|
||||
throw new InternalServerErrorException("Chosen event type not found.");
|
||||
}
|
||||
|
||||
// Extract CRM parameters from URL
|
||||
const urlParams = routingUrl.searchParams;
|
||||
const crmParams = {
|
||||
teamMemberEmail: urlParams.get("cal.crmContactOwnerEmail") || undefined,
|
||||
routedTeamMemberIds: urlParams.get("cal.routedTeamMemberIds")
|
||||
? urlParams
|
||||
.get("cal.routedTeamMemberIds")!
|
||||
.split(",")
|
||||
.map((id) => parseInt(id))
|
||||
: undefined,
|
||||
routingFormResponseId: urlParams.get("cal.routingFormResponseId")
|
||||
? parseInt(urlParams.get("cal.routingFormResponseId")!)
|
||||
: undefined,
|
||||
queuedFormResponseId: urlParams.get("cal.queuedFormResponseId")
|
||||
? (urlParams.get("cal.queuedFormResponseId") as string)
|
||||
: undefined,
|
||||
skipContactOwner: urlParams.get("cal.skipContactOwner") === "true" ? true : false,
|
||||
crmAppSlug: urlParams.get("cal.crmAppSlug") || undefined,
|
||||
crmOwnerRecordType: urlParams.get("cal.crmContactOwnerRecordType") || undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
eventTypeId: eventType.id,
|
||||
crmParams,
|
||||
};
|
||||
}
|
||||
|
||||
private isEventTypeRedirectUrl(routingUrl: URL) {
|
||||
const routingSearchParams = routingUrl.searchParams;
|
||||
return routingSearchParams.get("cal.action") === "eventTypeRedirectUrl";
|
||||
}
|
||||
|
||||
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 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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export type OAuthCallbackState = {
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/stripe",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Organizations/Teams Stripe")
|
||||
@DocsTags("Orgs / Teams / Stripe")
|
||||
export class OrganizationsStripeController {
|
||||
constructor(
|
||||
private readonly organizationsStripeService: OrganizationsStripeService,
|
||||
@@ -65,7 +65,7 @@ export class OrganizationsStripeController {
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get stripe connect URL for a team" })
|
||||
@ApiOperation({ summary: "Get Stripe connect URL for a team" })
|
||||
async getTeamStripeConnectUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@@ -104,7 +104,7 @@ export class OrganizationsStripeController {
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Check team stripe connection" })
|
||||
@ApiOperation({ summary: "Check team Stripe connection" })
|
||||
async checkTeamStripeConnection(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
@@ -116,7 +116,7 @@ export class OrganizationsStripeController {
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/save")
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
@ApiOperation({ summary: "Save Stripe credentials" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export class OrganizationsTeamsBookingsController {
|
||||
IsAdminAPIEnabledGuard
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Get 'Booking References' for a booking",
|
||||
summary: "Get booking references",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getBookingReferences(
|
||||
|
||||
+3
-1
@@ -6,7 +6,9 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
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";
|
||||
|
||||
import { OrganizationsTeamsRoutingFormsResponsesController } from "./controllers/organizations-teams-routing-forms-responses.controller";
|
||||
@@ -18,7 +20,7 @@ import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organ
|
||||
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04, TeamsEventTypesModule],
|
||||
providers: [
|
||||
OrganizationsTeamsRoutingFormsService,
|
||||
OrganizationsTeamsRoutingFormsResponsesService,
|
||||
|
||||
+10
-10
@@ -52,8 +52,8 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
export class OrgTeamsVerifiedResourcesController {
|
||||
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||
@ApiOperation({
|
||||
summary: "Request Email Verification Code",
|
||||
description: `Sends a verification code to the email.`,
|
||||
summary: "Request email verification code",
|
||||
description: `Sends a verification code to the email`,
|
||||
})
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Throttle({
|
||||
@@ -82,8 +82,8 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Request Phone Number Verification Code",
|
||||
description: `Sends a verification code to the phone number.`,
|
||||
summary: "Request phone number verification code",
|
||||
description: `Sends a verification code to the phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -108,7 +108,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify an email for an org team.",
|
||||
summary: "Verify an email for an org team",
|
||||
description: `Use code to verify an email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -134,7 +134,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify a phone number for an org team.",
|
||||
summary: "Verify a phone number for an org team",
|
||||
description: `Use code to verify a phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -166,7 +166,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified emails of an org team.",
|
||||
summary: "Get list of verified emails of an org team",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -189,7 +189,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified phone numbers of an org team.",
|
||||
summary: "Get list of verified phone numbers of an org team",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@@ -214,7 +214,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified email of an org team by id.",
|
||||
summary: "Get verified email of an org team by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -233,7 +233,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified phone number of an org team by id.",
|
||||
summary: "Get verified phone number of an org team by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ export class OrganizationsUsersBookingsController {
|
||||
@Get("/")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiOperation({ summary: "Get all bookings of an organization user" })
|
||||
@ApiOperation({ summary: "Get all bookings for an organization user" })
|
||||
async getOrganizationUserBookings(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsUsersRepository {
|
||||
|
||||
+5
-5
@@ -66,7 +66,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@ApiOperation({ summary: "Get all ooo entries of a user" })
|
||||
@ApiOperation({ summary: "Get all out-of-office entries for a user" })
|
||||
async getOrganizationUserOOO(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Query() query: GetOutOfOfficeEntryFiltersDTO
|
||||
@@ -84,7 +84,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@ApiOperation({ summary: "Create an ooo entry for user" })
|
||||
@ApiOperation({ summary: "Create an out-of-office entry for a user" })
|
||||
async createOrganizationUserOOO(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Body() input: CreateOutOfOfficeEntryDto
|
||||
@@ -100,7 +100,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg, IsUserOOO)
|
||||
@ApiOperation({ summary: "Update ooo entry of a user" })
|
||||
@ApiOperation({ summary: "Update an out-of-office entry for a user" })
|
||||
async updateOrganizationUserOOO(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Param("oooId", ParseIntPipe) oooId: number,
|
||||
@@ -118,7 +118,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg, IsUserOOO)
|
||||
@ApiOperation({ summary: "Delete ooo entry of a user" })
|
||||
@ApiOperation({ summary: "Delete an out-of-office entry for a user" })
|
||||
async deleteOrganizationUserOOO(
|
||||
@Param("oooId", ParseIntPipe) oooId: number
|
||||
): Promise<UserOooOutputResponseDto> {
|
||||
@@ -132,7 +132,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Get("/ooo")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiOperation({ summary: "Get all OOO entries of org users" })
|
||||
@ApiOperation({ summary: "Get all out-of-office entries for organization users" })
|
||||
async getOrganizationUsersOOO(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: GetOrgUsersOutOfOfficeEntryFiltersDTO
|
||||
|
||||
@@ -25,9 +25,9 @@ export class RouterController {
|
||||
@Param("formId") formId: string,
|
||||
@Body() body?: Record<string, string>
|
||||
): Promise<void | (ApiResponse<unknown> & { redirect: boolean })> {
|
||||
|
||||
const params = Object.fromEntries(new URLSearchParams(body ?? {}));
|
||||
const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } });
|
||||
|
||||
if (routedUrlData?.notFound) {
|
||||
throw new NotFoundException("Route not found. Please check the provided form parameter.");
|
||||
}
|
||||
@@ -37,6 +37,13 @@ export class RouterController {
|
||||
}
|
||||
|
||||
if (routedUrlData?.props) {
|
||||
if (routedUrlData.props.errorMessage) {
|
||||
return {
|
||||
status: "error",
|
||||
error: { code: "ROUTING_ERROR", message: routedUrlData.props.errorMessage },
|
||||
redirect: false,
|
||||
};
|
||||
}
|
||||
return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false };
|
||||
}
|
||||
|
||||
@@ -54,6 +61,7 @@ export class RouterController {
|
||||
) {
|
||||
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
|
||||
}
|
||||
console.log("handleRedirect Regular called", { destination });
|
||||
|
||||
return { status: "success", data: destination, redirect: true };
|
||||
}
|
||||
@@ -62,6 +70,7 @@ export class RouterController {
|
||||
routingUrl: URL,
|
||||
routingSearchParams: URLSearchParams
|
||||
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
|
||||
console.log("handleRedirectWithContactOwner called", { routingUrl, routingSearchParams });
|
||||
const pathNameParams = routingUrl.pathname.split("/");
|
||||
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
|
||||
const teamId = Number(routingSearchParams.get("cal.teamId"));
|
||||
|
||||
@@ -23,7 +23,6 @@ export class RoutingFormsService {
|
||||
if (!eventTypeId) {
|
||||
throw new NotFoundException("Event type not found.");
|
||||
}
|
||||
|
||||
const slots = await this.slotsService.getAvailableSlots({
|
||||
type: ById_2024_09_04_type,
|
||||
eventTypeId,
|
||||
|
||||
@@ -58,7 +58,7 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
summary: "Find out when is an event type ready to be booked.",
|
||||
summary: "Get available time slots for an event type",
|
||||
description: `
|
||||
There are 4 ways to get available slots for event type of an individual user:
|
||||
|
||||
@@ -290,7 +290,7 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
@Patch("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Updated reserved a slot",
|
||||
summary: "Update a reserved slot",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateReservedSlot(
|
||||
|
||||
@@ -10,7 +10,6 @@ import { DateTime } from "luxon";
|
||||
|
||||
import { dynamicEvent } from "@calcom/platform-libraries";
|
||||
import {
|
||||
ById_2024_09_04,
|
||||
ByUsernameAndEventTypeSlug_2024_09_04,
|
||||
ByTeamSlugAndEventTypeSlug_2024_09_04,
|
||||
GetSlotsInput_2024_09_04,
|
||||
@@ -47,6 +46,10 @@ export class SlotsInputService_2024_09_04 {
|
||||
const timeZone = query.timeZone;
|
||||
const orgSlug = "organizationSlug" in query ? query.organizationSlug : null;
|
||||
const rescheduleUid = query.bookingUidToReschedule || null;
|
||||
const routedTeamMemberIds = query.routedTeamMemberIds || null;
|
||||
const skipContactOwner = query.skipContactOwner || false;
|
||||
const teamMemberEmail = query.teamMemberEmail || null;
|
||||
const routingFormResponseId = query.routingFormResponseId || null;
|
||||
|
||||
return {
|
||||
isTeamEvent,
|
||||
@@ -59,6 +62,10 @@ export class SlotsInputService_2024_09_04 {
|
||||
timeZone,
|
||||
orgSlug,
|
||||
rescheduleUid,
|
||||
routedTeamMemberIds,
|
||||
skipContactOwner,
|
||||
teamMemberEmail,
|
||||
routingFormResponseId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export class SlotsService_2024_09_04 {
|
||||
const availableSlots: TimeSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...queryTransformed,
|
||||
routingFormResponseId: queryTransformed.routingFormResponseId ?? undefined,
|
||||
},
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ export class StripeController {
|
||||
@Get("/connect")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get stripe connect URL" })
|
||||
@ApiOperation({ summary: "Get Stripe connect URL" })
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@@ -79,7 +79,7 @@ export class StripeController {
|
||||
@Get("/save")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
@ApiOperation({ summary: "Save Stripe credentials" })
|
||||
/**
|
||||
* Handles saving Stripe credentials.
|
||||
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
|
||||
@@ -151,7 +151,7 @@ export class StripeController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Check stripe connection" })
|
||||
@ApiOperation({ summary: "Check Stripe connection" })
|
||||
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/cr
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export const MembershipUserSelect: Prisma.UserSelect = {
|
||||
username: true,
|
||||
|
||||
+10
-10
@@ -47,8 +47,8 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
export class TeamsVerifiedResourcesController {
|
||||
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||
@ApiOperation({
|
||||
summary: "Request Email Verification Code",
|
||||
description: `Sends a verification code to the Email.`,
|
||||
summary: "Request email verification code",
|
||||
description: `Sends a verification code to the Email`,
|
||||
})
|
||||
@Roles("TEAM_ADMIN")
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -71,8 +71,8 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Request Phone Number Verification Code",
|
||||
description: `Sends a verification code to the phone number.`,
|
||||
summary: "Request phone number verification code",
|
||||
description: `Sends a verification code to the phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -92,7 +92,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify an email for a team.",
|
||||
summary: "Verify an email for a team",
|
||||
description: `Use code to verify an email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -117,7 +117,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify a phone number for an org team.",
|
||||
summary: "Verify a phone number for an org team",
|
||||
description: `Use code to verify a phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -143,7 +143,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified emails of a team.",
|
||||
summary: "Get list of verified emails of a team",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -165,7 +165,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified phone numbers of a team.",
|
||||
summary: "Get list of verified phone numbers of a team",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/phones")
|
||||
@@ -189,7 +189,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified email of a team by id.",
|
||||
summary: "Get verified email of a team by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -207,7 +207,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified phone number of a team by id.",
|
||||
summary: "Get verified phone number of a team by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
|
||||
@@ -26,6 +26,7 @@ export class LocaleValidator implements ValidatorConstraintInterface {
|
||||
"sr",
|
||||
"sv",
|
||||
"vi",
|
||||
"bn",
|
||||
];
|
||||
|
||||
if (localeValues.includes(locale)) return true;
|
||||
|
||||
+10
-10
@@ -34,8 +34,8 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
export class UserVerifiedResourcesController {
|
||||
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||
@ApiOperation({
|
||||
summary: "Request Email Verification Code",
|
||||
description: `Sends a verification code to the email.`,
|
||||
summary: "Request email verification code",
|
||||
description: `Sends a verification code to the email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/emails/verification-code/request")
|
||||
@@ -57,8 +57,8 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Request Phone Number Verification Code",
|
||||
description: `Sends a verification code to the phone number.`,
|
||||
summary: "Request phone number verification code",
|
||||
description: `Sends a verification code to the phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/phones/verification-code/request")
|
||||
@@ -77,7 +77,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify an email.",
|
||||
summary: "Verify an email",
|
||||
description: `Use code to verify an email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -96,7 +96,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify a phone number.",
|
||||
summary: "Verify a phone number",
|
||||
description: `Use code to verify a phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -114,7 +114,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified emails.",
|
||||
summary: "Get list of verified emails",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/emails")
|
||||
@@ -135,7 +135,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified phone numbers.",
|
||||
summary: "Get list of verified phone numbers",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/phones")
|
||||
@@ -158,7 +158,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified email by id.",
|
||||
summary: "Get verified email by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/emails/:id")
|
||||
@@ -175,7 +175,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified phone number by id.",
|
||||
summary: "Get verified phone number by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/phones/:id")
|
||||
|
||||
@@ -85,7 +85,7 @@ export class WebhooksController {
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
summary: "Get all webooks",
|
||||
summary: "Get all webhooks",
|
||||
description: "Gets a paginated list of webhooks for the authenticated user.",
|
||||
})
|
||||
async getWebhooks(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,13 @@ export class ApiKeysRepositoryFixture {
|
||||
|
||||
async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
|
||||
const keyString = randomBytes(16).toString("hex");
|
||||
const hashedKey = createHash("sha256").update(keyString).digest("hex");
|
||||
console.log("createApiKey Fixutre", { hashedKey, keyString });
|
||||
const apiKey = await this.prismaWriteClient.apiKey.create({
|
||||
data: {
|
||||
userId,
|
||||
teamId,
|
||||
hashedKey: createHash("sha256").update(keyString).digest("hex"),
|
||||
hashedKey,
|
||||
expiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class AttendeeRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class BookingSeatRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Booking, User } from "@prisma/client";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class BookingsRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
export class CredentialsRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { EventType } from "@prisma/client";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class EventTypesRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class HostsRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
export class ProfileRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Schedule } from "@prisma/client";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class SchedulesRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
export class VerifiedResourcesRepositoryFixtures {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
export class WebhookRepositoryFixture {
|
||||
private primaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class WorkflowRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
@@ -75,6 +75,8 @@ export class CalendarsServiceMock {
|
||||
id: 0,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
} satisfies Awaited<ReturnType<typeof CalendarsService.prototype.getCalendars>>;
|
||||
}
|
||||
|
||||
@@ -70,9 +70,7 @@ export const ComplexExample: React.FC = () => (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="cursor-pointer">
|
||||
<Button variant="icon" color="secondary" StartIcon="calendar" />
|
||||
</button>
|
||||
<Button variant="icon" color="secondary" StartIcon="calendar" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownItem>Rename</DropdownItem>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { unstable_cache } from "next/cache";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamService } from "@calcom/lib/server/service/team";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -37,7 +38,7 @@ export const ServerTeamsListing = async ({
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
teamNameFromInvite = await TeamRepository.inviteMemberByToken(token, userId);
|
||||
teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId);
|
||||
} catch (e) {
|
||||
errorMsgFromInvite = "Error while fetching teams";
|
||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
||||
|
||||
+47
-11
@@ -10,6 +10,8 @@ import React, { useEffect, useState, useMemo } from "react";
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { TeamFeatures } from "@calcom/features/flags/config";
|
||||
import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
@@ -177,7 +179,13 @@ const organizationAdminKeys = [
|
||||
"delegation_credential",
|
||||
];
|
||||
|
||||
const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnabled: boolean }) => {
|
||||
const useTabs = ({
|
||||
isDelegationCredentialEnabled,
|
||||
isPbacEnabled,
|
||||
}: {
|
||||
isDelegationCredentialEnabled: boolean;
|
||||
isPbacEnabled: boolean;
|
||||
}) => {
|
||||
const session = useSession();
|
||||
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
|
||||
const orgBranding = useOrgBranding();
|
||||
@@ -213,6 +221,14 @@ const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnab
|
||||
});
|
||||
}
|
||||
|
||||
// Add pbac menu item only if feature flag is enabled
|
||||
if (isPbacEnabled) {
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...tab,
|
||||
children: newArray,
|
||||
@@ -272,6 +288,7 @@ interface SettingsSidebarContainerProps {
|
||||
className?: string;
|
||||
navigationIsOpenedOnMobile?: boolean;
|
||||
bannersHeight?: number;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
}
|
||||
|
||||
const TeamListCollapsible = () => {
|
||||
@@ -438,6 +455,7 @@ const SettingsSidebarContainer = ({
|
||||
className = "",
|
||||
navigationIsOpenedOnMobile,
|
||||
bannersHeight,
|
||||
teamFeatures,
|
||||
}: SettingsSidebarContainerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
@@ -449,11 +467,24 @@ const SettingsSidebarContainer = ({
|
||||
}[]
|
||||
>();
|
||||
const session = useSession();
|
||||
const { data } = trpc.viewer.delegationCredential.check.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
|
||||
const organizationId = session.data?.user?.org?.id;
|
||||
|
||||
const isDelegationCredentialEnabled = useIsFeatureEnabledForTeam({
|
||||
teamFeatures,
|
||||
teamId: organizationId,
|
||||
feature: "delegation-credential",
|
||||
});
|
||||
|
||||
const isPbacEnabled = useIsFeatureEnabledForTeam({
|
||||
teamFeatures,
|
||||
teamId: organizationId,
|
||||
feature: "pbac",
|
||||
});
|
||||
|
||||
const tabsWithPermissions = useTabs({
|
||||
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
|
||||
isDelegationCredentialEnabled,
|
||||
isPbacEnabled,
|
||||
});
|
||||
|
||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
@@ -727,9 +758,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
||||
export type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
} & ComponentProps<typeof Shell>;
|
||||
|
||||
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
|
||||
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const state = useState(false);
|
||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||
@@ -761,6 +793,7 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
|
||||
<SidebarContainerElement
|
||||
sideContainerOpen={sideContainerOpen}
|
||||
setSideContainerOpen={setSideContainerOpen}
|
||||
teamFeatures={teamFeatures}
|
||||
/>
|
||||
}
|
||||
drawerState={state}
|
||||
@@ -778,10 +811,18 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
};
|
||||
|
||||
const SidebarContainerElement = ({
|
||||
sideContainerOpen,
|
||||
bannersHeight,
|
||||
setSideContainerOpen,
|
||||
teamFeatures,
|
||||
}: SidebarContainerElementProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
@@ -797,13 +838,8 @@ const SidebarContainerElement = ({
|
||||
<SettingsSidebarContainer
|
||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||
bannersHeight={bannersHeight}
|
||||
teamFeatures={teamFeatures}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { TeamFeatures } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
|
||||
import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
|
||||
|
||||
const getTeamFeatures = unstable_cache(
|
||||
async (teamId: number) => {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
return await featuresRepository.getTeamFeatures(teamId);
|
||||
},
|
||||
["team-features"],
|
||||
{
|
||||
revalidate: 120,
|
||||
}
|
||||
);
|
||||
|
||||
export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const userId = session?.user?.id;
|
||||
@@ -16,9 +30,21 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
let teamFeatures: Record<number, TeamFeatures> | null = null;
|
||||
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
if (orgId) {
|
||||
const features = await getTeamFeatures(orgId);
|
||||
if (features) {
|
||||
teamFeatures = {
|
||||
[orgId]: features,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayoutAppDirClient {...props} />
|
||||
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user