Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -164,6 +164,7 @@ SENTRY_PROJECT=
|
|||||||
SENTRY_AUTH_TOKEN=
|
SENTRY_AUTH_TOKEN=
|
||||||
# Used for capturing exceptions and logging messages
|
# Used for capturing exceptions and logging messages
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN_CLIENT=
|
||||||
SENTRY_DEBUG=
|
SENTRY_DEBUG=
|
||||||
SENTRY_MAX_SPANS=
|
SENTRY_MAX_SPANS=
|
||||||
SENTRY_SAMPLE_RATE=
|
SENTRY_SAMPLE_RATE=
|
||||||
|
|||||||
@@ -27,8 +27,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Configure git
|
- name: Configure git
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "github-actions@github.com"
|
# Define authorized users and their emails using organization secret
|
||||||
git config --local user.name "GitHub Actions"
|
# 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
|
- uses: ./.github/actions/yarn-install
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||||
organization-name: calcom
|
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:
|
apply-labels-from-issue:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
outputs:
|
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:
|
steps:
|
||||||
- name: Check if PR exists with ready-for-e2e label for this SHA
|
- name: Check if PR exists with ready-for-e2e label for this SHA
|
||||||
id: check-if-pr-has-label
|
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
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
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// 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} */
|
/** @type {import("next").NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
experimental: {
|
|
||||||
instrumentationHook: true,
|
|
||||||
},
|
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@calcom/app-store",
|
"@calcom/app-store",
|
||||||
"@calcom/dayjs",
|
"@calcom/dayjs",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@sentry/nextjs": "^9.15.0",
|
"@sentry/nextjs": "^9.15.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"next": "^13.5.9",
|
"next": "^15.3.0",
|
||||||
"next-api-middleware": "^1.0.1",
|
"next-api-middleware": "^1.0.1",
|
||||||
"next-axiom": "^0.17.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
watchAttempts: 0,
|
watchAttempts: 0,
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
unwatchAttempts: 0,
|
unwatchAttempts: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
@@ -136,6 +138,8 @@ describe("POST /api/selected-calendars", () => {
|
|||||||
watchAttempts: 0,
|
watchAttempts: 0,
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
unwatchAttempts: 0,
|
unwatchAttempts: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|||||||
@@ -38,8 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.239",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
|
|||||||
@@ -232,6 +232,15 @@ export class BookingsController_2024_04_15 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (bookingUid) {
|
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 {
|
try {
|
||||||
req.body.uid = bookingUid;
|
req.body.uid = bookingUid;
|
||||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||||
|
|||||||
@@ -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", () => {
|
describe("calendar events", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
|||||||
@@ -186,6 +186,20 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
guests,
|
guests,
|
||||||
location,
|
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,
|
location,
|
||||||
},
|
},
|
||||||
schedulingType: eventType.schedulingType,
|
schedulingType: eventType.schedulingType,
|
||||||
|
...this.getRoutingFormData(inputBooking.routing),
|
||||||
});
|
});
|
||||||
|
|
||||||
switch (timeBetween) {
|
switch (timeBetween) {
|
||||||
@@ -711,6 +726,12 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
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
|
const oAuthClientParams = booking.eventTypeId
|
||||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
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,
|
id: true,
|
||||||
appId: true,
|
appId: true,
|
||||||
type: true,
|
type: true,
|
||||||
@@ -16,7 +16,7 @@ const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.Credential
|
|||||||
teamId: true,
|
teamId: true,
|
||||||
key: true,
|
key: true,
|
||||||
invalid: true,
|
invalid: true,
|
||||||
});
|
} satisfies Prisma.CredentialSelect;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CalendarsRepository {
|
export class CalendarsRepository {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export class CalendarsController {
|
|||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@Get("/:calendar/connect")
|
@Get("/:calendar/connect")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Get oAuth connect URL" })
|
@ApiOperation({ summary: "Get OAuth connect URL" })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: "redir",
|
name: "redir",
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { z } from "zod";
|
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 { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.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 { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||||
import {
|
import {
|
||||||
@@ -33,13 +34,19 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Delete,
|
Delete,
|
||||||
Query,
|
Query,
|
||||||
|
Headers,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import {
|
||||||
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
|
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";
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
@@ -51,7 +58,8 @@ import { PrismaClient } from "@calcom/prisma";
|
|||||||
export class EventTypesController_2024_04_15 {
|
export class EventTypesController_2024_04_15 {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly eventTypesService: EventTypesService_2024_04_15,
|
private readonly eventTypesService: EventTypesService_2024_04_15,
|
||||||
private readonly prismaReadService: PrismaReadService
|
private readonly prismaReadService: PrismaReadService,
|
||||||
|
private readonly organizationsRepository: OrganizationsRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post("/")
|
@Post("/")
|
||||||
@@ -109,19 +117,30 @@ export class EventTypesController_2024_04_15 {
|
|||||||
async getPublicEventType(
|
async getPublicEventType(
|
||||||
@Param("username") username: string,
|
@Param("username") username: string,
|
||||||
@Param("eventSlug") eventSlug: 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> {
|
): Promise<GetEventTypePublicOutput> {
|
||||||
try {
|
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(
|
const event = await getPublicEvent(
|
||||||
username.toLowerCase(),
|
username.toLowerCase(),
|
||||||
eventSlug,
|
eventSlug,
|
||||||
queryParams.isTeamEvent,
|
queryParams.isTeamEvent,
|
||||||
queryParams.org || null,
|
orgSlug ?? null,
|
||||||
this.prismaReadService.prisma as unknown as PrismaClient,
|
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
|
// 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`
|
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: event,
|
data: event,
|
||||||
status: SUCCESS_STATUS,
|
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 { 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 { 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 { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
|
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
@@ -9,7 +10,14 @@ import { UsersModule } from "@/modules/users/users.module";
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
MembershipsModule,
|
||||||
|
TokensModule,
|
||||||
|
UsersModule,
|
||||||
|
SelectedCalendarsModule,
|
||||||
|
OrganizationsModule,
|
||||||
|
],
|
||||||
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
|
providers: [EventTypesRepository_2024_04_15, EventTypesService_2024_04_15],
|
||||||
controllers: [EventTypesController_2024_04_15],
|
controllers: [EventTypesController_2024_04_15],
|
||||||
exports: [EventTypesService_2024_04_15, EventTypesRepository_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",
|
description: "undefined booking fields",
|
||||||
length: 40,
|
length: 40,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
slug: "undefined-booking-fields",
|
slug: `undefined-booking-fields-${randomString()}`,
|
||||||
locations: [],
|
locations: [],
|
||||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||||
};
|
};
|
||||||
@@ -1751,6 +1751,14 @@ describe("Event types Endpoints", () => {
|
|||||||
disableOnPrefill: false,
|
disableOnPrefill: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
disableOnPrefill: false,
|
||||||
|
hidden: true,
|
||||||
|
isDefault: true,
|
||||||
|
required: false,
|
||||||
|
slug: "attendeePhoneNumber",
|
||||||
|
type: "phone",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
type: "radioInput",
|
type: "radioInput",
|
||||||
|
|||||||
+9
-11
@@ -46,11 +46,10 @@ export class SchedulesController_2024_04_15 {
|
|||||||
@Body() bodySchedule: CreateScheduleInput_2024_04_15
|
@Body() bodySchedule: CreateScheduleInput_2024_04_15
|
||||||
): Promise<CreateScheduleOutput_2024_04_15> {
|
): Promise<CreateScheduleOutput_2024_04_15> {
|
||||||
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule);
|
||||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: scheduleFormatted,
|
data: schedule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,13 +59,10 @@ export class SchedulesController_2024_04_15 {
|
|||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
|
||||||
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
|
||||||
const scheduleFormatted = schedule
|
|
||||||
? await this.schedulesService.formatScheduleForAtom(user, schedule)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: scheduleFormatted,
|
data: schedule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,23 +73,25 @@ export class SchedulesController_2024_04_15 {
|
|||||||
@Param("scheduleId") scheduleId: number
|
@Param("scheduleId") scheduleId: number
|
||||||
): Promise<GetScheduleOutput_2024_04_15> {
|
): Promise<GetScheduleOutput_2024_04_15> {
|
||||||
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId);
|
||||||
const scheduleFormatted = await this.schedulesService.formatScheduleForAtom(user, schedule);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: scheduleFormatted,
|
data: schedule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@Permissions([SCHEDULE_READ])
|
@Permissions([SCHEDULE_READ])
|
||||||
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
|
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_04_15> {
|
||||||
const schedules = await this.schedulesService.getUserSchedules(user.id);
|
const schedules = await this.schedulesService.getUserSchedules(
|
||||||
const schedulesFormatted = await this.schedulesService.formatSchedulesForAtom(user, schedules);
|
user.id,
|
||||||
|
user.timeZone,
|
||||||
|
user.defaultScheduleId
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
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 { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesRepository_2024_04_15 {
|
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 { 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 { 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 { 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 { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Schedule } from "@prisma/client";
|
import { Schedule } from "@prisma/client";
|
||||||
import { User } from "@prisma/client";
|
|
||||||
|
|
||||||
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
|
import { updateSchedule } from "@calcom/platform-libraries/schedules";
|
||||||
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
|
import { ScheduleRepository } from "@calcom/platform-libraries/schedules";
|
||||||
import {
|
|
||||||
transformWorkingHoursForClient,
|
|
||||||
transformAvailabilityForClient,
|
|
||||||
transformDateOverridesForClient,
|
|
||||||
} from "@calcom/platform-libraries-0.0.2";
|
|
||||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||||
|
import { PrismaClient } from "@calcom/prisma";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService_2024_04_15 {
|
export class SchedulesService_2024_04_15 {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly schedulesRepository: SchedulesRepository_2024_04_15,
|
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) {
|
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||||
@@ -48,7 +44,9 @@ export class SchedulesService_2024_04_15 {
|
|||||||
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
|
await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return createdSchedule;
|
const formattedSchedule = await this.getUserSchedule(userId, createdSchedule.id);
|
||||||
|
|
||||||
|
return formattedSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserScheduleDefault(userId: number) {
|
async getUserScheduleDefault(userId: number) {
|
||||||
@@ -56,11 +54,29 @@ export class SchedulesService_2024_04_15 {
|
|||||||
|
|
||||||
if (!user?.defaultScheduleId) return null;
|
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) {
|
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) {
|
if (!existingSchedule) {
|
||||||
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`);
|
||||||
@@ -71,8 +87,8 @@ export class SchedulesService_2024_04_15 {
|
|||||||
return existingSchedule;
|
return existingSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserSchedules(userId: number) {
|
async getUserSchedules(userId: number, timeZone: string, defaultScheduleId: number | null) {
|
||||||
return this.schedulesRepository.getSchedulesByUserId(userId);
|
return ScheduleRepository.findManyDetailedScheduleByUserId({ userId, timeZone, defaultScheduleId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUserSchedule(
|
async updateUserSchedule(
|
||||||
@@ -89,7 +105,6 @@ export class SchedulesService_2024_04_15 {
|
|||||||
this.checkUserOwnsSchedule(user.id, existingSchedule);
|
this.checkUserOwnsSchedule(user.id, existingSchedule);
|
||||||
|
|
||||||
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
|
const schedule = await this.getUserSchedule(user.id, Number(scheduleId));
|
||||||
const scheduleFormatted = await this.formatScheduleForAtom(user, schedule);
|
|
||||||
|
|
||||||
if (!bodySchedule.schedule) {
|
if (!bodySchedule.schedule) {
|
||||||
// note(Lauris): When updating an availability in cal web app, lets say only its name, also
|
// 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
|
// 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 that would be passed by the web app. If we don't, then updating schedule name will erase
|
||||||
// schedule.
|
// schedule.
|
||||||
bodySchedule.schedule = scheduleFormatted.availability;
|
bodySchedule.schedule = schedule.availability;
|
||||||
}
|
}
|
||||||
|
|
||||||
return updateScheduleHandler({
|
return updateSchedule({
|
||||||
input: { scheduleId: Number(scheduleId), ...bodySchedule },
|
input: {
|
||||||
ctx: { user },
|
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);
|
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">) {
|
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
|
||||||
if (userId !== schedule.userId) {
|
if (userId !== schedule.userId) {
|
||||||
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
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 { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -39,6 +40,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
|||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
||||||
exception,
|
exception,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -46,6 +48,7 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
response.status(400).json({
|
response.status(400).json({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { extractUserContext } from "@/lib/extract-user-context";
|
||||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -16,6 +17,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
|||||||
const statusCode = exception.getStatus();
|
const statusCode = exception.getStatus();
|
||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||||
exception,
|
exception,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -23,6 +25,7 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
response.status(statusCode).json({
|
response.status(statusCode).json({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { extractUserContext } from "@/lib/extract-user-context";
|
||||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||||
import { Catch, HttpStatus, Logger } 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";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`PrismaError: ${error.message}`, {
|
this.logger.error(`PrismaError: ${error.message}`, {
|
||||||
error,
|
error,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -50,6 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
let message = "There was an error, please try again later.";
|
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 { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
@@ -120,6 +121,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
|||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||||
exception,
|
exception,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -127,6 +129,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
response.status(statusCode).json({
|
response.status(statusCode).json({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { extractUserContext } from "@/lib/extract-user-context";
|
||||||
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
import { filterReqHeaders } from "@/lib/filterReqHeaders";
|
||||||
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||||
import { Catch, HttpStatus, Logger } 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";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
this.logger.error(`ZodError: ${error.message}`, {
|
this.logger.error(`ZodError: ${error.message}`, {
|
||||||
error,
|
error,
|
||||||
body: request.body,
|
body: request.body,
|
||||||
@@ -25,6 +27,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
|
...userContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
response.status(HttpStatus.BAD_REQUEST).json({
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
|||||||
@@ -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 { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { tap } from "rxjs/operators";
|
import { tap } from "rxjs/operators";
|
||||||
@@ -14,6 +15,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
|||||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
const { method, url } = request;
|
const { method, url } = request;
|
||||||
|
const userContext = extractUserContext(request);
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
@@ -39,6 +41,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
|||||||
responseTime,
|
responseTime,
|
||||||
responseBody: jsonBodyString,
|
responseBody: jsonBodyString,
|
||||||
timestamp: new Date().toISOString(),
|
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
|
// 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;
|
eventType.teamMembers = onlyManagedTeamMembers;
|
||||||
|
|
||||||
return eventType;
|
return eventType;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingSeatRepository {
|
export class BookingSeatRepository {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { calendar_v3 } from "@googleapis/calendar";
|
|||||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
import { NotFoundException } from "@nestjs/common";
|
import { NotFoundException } from "@nestjs/common";
|
||||||
import { Logger } 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 { JWT } from "googleapis-common";
|
||||||
|
|
||||||
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class ConferencingController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({ summary: "Get OAuth conferencing app auth url" })
|
@ApiOperation({ summary: "Get OAuth conferencing app auth URL" })
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "app",
|
name: "app",
|
||||||
description: "Conferencing application type",
|
description: "Conferencing application type",
|
||||||
@@ -130,7 +130,7 @@ export class ConferencingController {
|
|||||||
@Get("/:app/oauth/callback")
|
@Get("/:app/oauth/callback")
|
||||||
@UseGuards()
|
@UseGuards()
|
||||||
@Redirect(undefined, 301)
|
@Redirect(undefined, 301)
|
||||||
@ApiOperation({ summary: "conferencing apps oauths callback" })
|
@ApiOperation({ summary: "Conferencing app OAuth callback" })
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "app",
|
name: "app",
|
||||||
description: "Conferencing application type",
|
description: "Conferencing application type",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
|||||||
export class ConferencingRepository {
|
export class ConferencingRepository {
|
||||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||||
|
|
||||||
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
|
private readonly credentialSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
type: true,
|
type: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
@@ -20,7 +20,7 @@ export class ConferencingRepository {
|
|||||||
billingCycleStart: true,
|
billingCycleStart: true,
|
||||||
invalid: true,
|
invalid: true,
|
||||||
delegationCredentialId: true,
|
delegationCredentialId: true,
|
||||||
});
|
} satisfies Prisma.CredentialSelect;
|
||||||
|
|
||||||
async findConferencingApps(userId: number) {
|
async findConferencingApps(userId: number) {
|
||||||
return this.dbRead.prisma.credential.findMany({
|
return this.dbRead.prisma.credential.findMany({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
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 { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||||
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||||
|
|||||||
+4
@@ -92,6 +92,8 @@ describe("Platform Destination Calendar Endpoints", () => {
|
|||||||
publisher: "",
|
publisher: "",
|
||||||
url: "",
|
url: "",
|
||||||
email: "",
|
email: "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
// calendars: {
|
// calendars: {
|
||||||
// externalId:
|
// externalId:
|
||||||
@@ -118,6 +120,8 @@ describe("Platform Destination Calendar Endpoints", () => {
|
|||||||
id: 0,
|
id: 0,
|
||||||
delegationCredentialId: null,
|
delegationCredentialId: null,
|
||||||
domainWideDelegationCredentialId: 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 { Injectable } from "@nestjs/common";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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";
|
import { PrismaWriteService } from "../../prisma/prisma-write.service";
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -134,7 +134,7 @@ export class OrganizationsAttributesOptionsController {
|
|||||||
@Roles("ORG_MEMBER")
|
@Roles("ORG_MEMBER")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Get("/attributes/:attributeId/options/assigned")
|
@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(
|
async getOrganizationAttributeAssignedOptions(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Param("attributeId") attributeId: string,
|
@Param("attributeId") attributeId: string,
|
||||||
@@ -158,7 +158,7 @@ export class OrganizationsAttributesOptionsController {
|
|||||||
@Roles("ORG_MEMBER")
|
@Roles("ORG_MEMBER")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@Get("/attributes/slugs/:attributeSlug/options/assigned")
|
@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(
|
async getOrganizationAttributeAssignedOptionsBySlug(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Param("attributeSlug") attributeSlug: string,
|
@Param("attributeSlug") attributeSlug: string,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class OrganizationsBookingsController {
|
|||||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@ApiOperation({ summary: "Get Organization Bookings" })
|
@ApiOperation({ summary: "Get organization bookings" })
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@ export class OrganizationsConferencingController {
|
|||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
|
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
|
||||||
@HttpCode(HttpStatus.OK)
|
@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(
|
async getTeamOAuthUrl(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Headers("Authorization") authorization: string,
|
@Headers("Authorization") authorization: string,
|
||||||
|
|||||||
+2
-2
@@ -52,7 +52,7 @@ export class OrganizationsDelegationCredentialController {
|
|||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("SCALE")
|
@PlatformPlan("SCALE")
|
||||||
@ApiOperation({ summary: "Save delegation credentials for your organization." })
|
@ApiOperation({ summary: "Save delegation credentials for your organization" })
|
||||||
async createDelegationCredential(
|
async createDelegationCredential(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@GetUser() delegatedServiceAccountUser: User,
|
@GetUser() delegatedServiceAccountUser: User,
|
||||||
@@ -72,7 +72,7 @@ export class OrganizationsDelegationCredentialController {
|
|||||||
@Patch("/:credentialId")
|
@Patch("/:credentialId")
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("SCALE")
|
@PlatformPlan("SCALE")
|
||||||
@ApiOperation({ summary: "Update delegation credentials of your organization." })
|
@ApiOperation({ summary: "Update delegation credentials of your organization" })
|
||||||
async updateDelegationCredential(
|
async updateDelegationCredential(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@GetUser() delegatedServiceAccountUser: User,
|
@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 { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsDelegationCredentialRepository {
|
export class OrganizationsDelegationCredentialRepository {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class OrganizationsRepository {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findTeamIdFromClientId(clientId: string) {
|
async findTeamIdAndSlugFromClientId(clientId: string) {
|
||||||
return this.dbRead.prisma.team.findFirstOrThrow({
|
return this.dbRead.prisma.team.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
platformOAuthClient: {
|
platformOAuthClient: {
|
||||||
@@ -80,6 +80,7 @@ export class OrganizationsRepository {
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
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 { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
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";
|
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 { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { SkipTakePagination } from "@calcom/platform-types";
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ManagedOrganizationsRepository {
|
export class ManagedOrganizationsRepository {
|
||||||
|
|||||||
+418
-4
@@ -37,7 +37,13 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
|||||||
let user: User;
|
let user: User;
|
||||||
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
||||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let routingEventType: {
|
||||||
|
id: number;
|
||||||
|
slug: string | null;
|
||||||
|
teamId: number | null;
|
||||||
|
userId: number | null;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
@@ -72,6 +78,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
|||||||
role: "OWNER",
|
role: "OWNER",
|
||||||
user: { connect: { id: user.id } },
|
user: { connect: { id: user.id } },
|
||||||
team: { connect: { id: org.id } },
|
team: { connect: { id: org.id } },
|
||||||
|
accepted: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await profileRepositoryFixture.create({
|
await profileRepositoryFixture.create({
|
||||||
@@ -93,14 +100,74 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
|||||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||||
apiKeyString = `${keyString}`;
|
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({
|
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||||
data: {
|
data: {
|
||||||
name: "Test Routing Form",
|
name: "Test Routing Form",
|
||||||
description: "Test Description",
|
description: "Test Description",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
routes: JSON.stringify([]),
|
routes: [
|
||||||
fields: JSON.stringify([]),
|
{
|
||||||
settings: JSON.stringify({}),
|
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,
|
teamId: team.id,
|
||||||
userId: user.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`, () => {
|
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
|
||||||
it("should not update routing form response for non existing org", async () => {
|
it("should not update routing form response for non existing org", async () => {
|
||||||
return request(app.getHttpServer())
|
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 { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-form-responses.output";
|
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 { 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 { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
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 { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
|
||||||
import { UpdateRoutingFormResponseInput } from "../inputs/update-routing-form-response.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";
|
import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-response.output";
|
||||||
|
|
||||||
@Controller({
|
@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")
|
@Patch("/:responseId")
|
||||||
@ApiOperation({ summary: "Update routing form response" })
|
@ApiOperation({ summary: "Update routing form response" })
|
||||||
@Roles("ORG_ADMIN")
|
@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 { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.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 { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
|
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
import { OrganizationsRoutingFormsResponsesController } from "./controllers/organizations-routing-forms-responses.controller";
|
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";
|
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
|
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04],
|
||||||
providers: [
|
providers: [
|
||||||
MembershipsRepository,
|
MembershipsRepository,
|
||||||
OrganizationsRepository,
|
OrganizationsRepository,
|
||||||
@@ -22,6 +24,7 @@ import { OrganizationsRoutingFormsService } from "./services/organizations-routi
|
|||||||
OrganizationsRoutingFormsService,
|
OrganizationsRoutingFormsService,
|
||||||
OrganizationsRoutingFormsResponsesService,
|
OrganizationsRoutingFormsResponsesService,
|
||||||
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||||
|
TeamsEventTypesRepository,
|
||||||
],
|
],
|
||||||
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
|
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 { 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 { 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()
|
@Injectable()
|
||||||
export class OrganizationsRoutingFormsResponsesService {
|
export class OrganizationsRoutingFormsResponsesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
|
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(
|
async getOrganizationRoutingFormResponses(
|
||||||
@@ -52,4 +63,194 @@ export class OrganizationsRoutingFormsResponsesService {
|
|||||||
|
|
||||||
return this.outputService.getRoutingFormResponses([updatedResponse])[0];
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class OrganizationsStripeController {
|
|||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
@Get("/connect")
|
@Get("/connect")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Get stripe connect URL for a team" })
|
@ApiOperation({ summary: "Get Stripe connect URL for a team" })
|
||||||
async getTeamStripeConnectUrl(
|
async getTeamStripeConnectUrl(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Headers("Authorization") authorization: string,
|
@Headers("Authorization") authorization: string,
|
||||||
@@ -104,7 +104,7 @@ export class OrganizationsStripeController {
|
|||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
@Get("/check")
|
@Get("/check")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Check team stripe connection" })
|
@ApiOperation({ summary: "Check team Stripe connection" })
|
||||||
async checkTeamStripeConnection(
|
async checkTeamStripeConnection(
|
||||||
@Param("teamId", ParseIntPipe) teamId: number
|
@Param("teamId", ParseIntPipe) teamId: number
|
||||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||||
@@ -116,7 +116,7 @@ export class OrganizationsStripeController {
|
|||||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||||
@Get("/save")
|
@Get("/save")
|
||||||
@Redirect(undefined, 301)
|
@Redirect(undefined, 301)
|
||||||
@ApiOperation({ summary: "Save stripe credentials" })
|
@ApiOperation({ summary: "Save Stripe credentials" })
|
||||||
async save(
|
async save(
|
||||||
@Query("state") state: string,
|
@Query("state") state: string,
|
||||||
@Query("code") code: string,
|
@Query("code") code: string,
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,9 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
|
|||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.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 { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
|
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
import { OrganizationsTeamsRoutingFormsResponsesController } from "./controllers/organizations-teams-routing-forms-responses.controller";
|
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";
|
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
|
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04, TeamsEventTypesModule],
|
||||||
providers: [
|
providers: [
|
||||||
OrganizationsTeamsRoutingFormsService,
|
OrganizationsTeamsRoutingFormsService,
|
||||||
OrganizationsTeamsRoutingFormsResponsesService,
|
OrganizationsTeamsRoutingFormsResponsesService,
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ export class OrganizationsUsersBookingsController {
|
|||||||
@Get("/")
|
@Get("/")
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@ApiOperation({ summary: "Get all bookings of an organization user" })
|
@ApiOperation({ summary: "Get all bookings for an organization user" })
|
||||||
async getOrganizationUserBookings(
|
async getOrganizationUserBookings(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Param("userId", ParseIntPipe) userId: number,
|
@Param("userId", ParseIntPipe) userId: number,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrganizationsUsersRepository {
|
export class OrganizationsUsersRepository {
|
||||||
|
|||||||
+5
-5
@@ -66,7 +66,7 @@ export class OrganizationsUsersOOOController {
|
|||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg)
|
@UseGuards(IsUserInOrg)
|
||||||
@ApiOperation({ summary: "Get all ooo entries of a user" })
|
@ApiOperation({ summary: "Get all out-of-office entries for a user" })
|
||||||
async getOrganizationUserOOO(
|
async getOrganizationUserOOO(
|
||||||
@Param("userId", ParseIntPipe) userId: number,
|
@Param("userId", ParseIntPipe) userId: number,
|
||||||
@Query() query: GetOutOfOfficeEntryFiltersDTO
|
@Query() query: GetOutOfOfficeEntryFiltersDTO
|
||||||
@@ -84,7 +84,7 @@ export class OrganizationsUsersOOOController {
|
|||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg)
|
@UseGuards(IsUserInOrg)
|
||||||
@ApiOperation({ summary: "Create an ooo entry for user" })
|
@ApiOperation({ summary: "Create an out-of-office entry for a user" })
|
||||||
async createOrganizationUserOOO(
|
async createOrganizationUserOOO(
|
||||||
@Param("userId", ParseIntPipe) userId: number,
|
@Param("userId", ParseIntPipe) userId: number,
|
||||||
@Body() input: CreateOutOfOfficeEntryDto
|
@Body() input: CreateOutOfOfficeEntryDto
|
||||||
@@ -100,7 +100,7 @@ export class OrganizationsUsersOOOController {
|
|||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg, IsUserOOO)
|
@UseGuards(IsUserInOrg, IsUserOOO)
|
||||||
@ApiOperation({ summary: "Update ooo entry of a user" })
|
@ApiOperation({ summary: "Update an out-of-office entry for a user" })
|
||||||
async updateOrganizationUserOOO(
|
async updateOrganizationUserOOO(
|
||||||
@Param("userId", ParseIntPipe) userId: number,
|
@Param("userId", ParseIntPipe) userId: number,
|
||||||
@Param("oooId", ParseIntPipe) oooId: number,
|
@Param("oooId", ParseIntPipe) oooId: number,
|
||||||
@@ -118,7 +118,7 @@ export class OrganizationsUsersOOOController {
|
|||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@UseGuards(IsUserInOrg, IsUserOOO)
|
@UseGuards(IsUserInOrg, IsUserOOO)
|
||||||
@ApiOperation({ summary: "Delete ooo entry of a user" })
|
@ApiOperation({ summary: "Delete an out-of-office entry for a user" })
|
||||||
async deleteOrganizationUserOOO(
|
async deleteOrganizationUserOOO(
|
||||||
@Param("oooId", ParseIntPipe) oooId: number
|
@Param("oooId", ParseIntPipe) oooId: number
|
||||||
): Promise<UserOooOutputResponseDto> {
|
): Promise<UserOooOutputResponseDto> {
|
||||||
@@ -132,7 +132,7 @@ export class OrganizationsUsersOOOController {
|
|||||||
@Get("/ooo")
|
@Get("/ooo")
|
||||||
@Roles("ORG_ADMIN")
|
@Roles("ORG_ADMIN")
|
||||||
@PlatformPlan("ESSENTIALS")
|
@PlatformPlan("ESSENTIALS")
|
||||||
@ApiOperation({ summary: "Get all OOO entries of org users" })
|
@ApiOperation({ summary: "Get all out-of-office entries for organization users" })
|
||||||
async getOrganizationUsersOOO(
|
async getOrganizationUsersOOO(
|
||||||
@Param("orgId", ParseIntPipe) orgId: number,
|
@Param("orgId", ParseIntPipe) orgId: number,
|
||||||
@Query() query: GetOrgUsersOutOfOfficeEntryFiltersDTO
|
@Query() query: GetOrgUsersOutOfOfficeEntryFiltersDTO
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ export class RouterController {
|
|||||||
@Param("formId") formId: string,
|
@Param("formId") formId: string,
|
||||||
@Body() body?: Record<string, string>
|
@Body() body?: Record<string, string>
|
||||||
): Promise<void | (ApiResponse<unknown> & { redirect: boolean })> {
|
): Promise<void | (ApiResponse<unknown> & { redirect: boolean })> {
|
||||||
|
|
||||||
const params = Object.fromEntries(new URLSearchParams(body ?? {}));
|
const params = Object.fromEntries(new URLSearchParams(body ?? {}));
|
||||||
const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } });
|
const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } });
|
||||||
|
|
||||||
if (routedUrlData?.notFound) {
|
if (routedUrlData?.notFound) {
|
||||||
throw new NotFoundException("Route not found. Please check the provided form parameter.");
|
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) {
|
||||||
|
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 };
|
return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +61,7 @@ export class RouterController {
|
|||||||
) {
|
) {
|
||||||
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
|
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
|
||||||
}
|
}
|
||||||
|
console.log("handleRedirect Regular called", { destination });
|
||||||
|
|
||||||
return { status: "success", data: destination, redirect: true };
|
return { status: "success", data: destination, redirect: true };
|
||||||
}
|
}
|
||||||
@@ -62,6 +70,7 @@ export class RouterController {
|
|||||||
routingUrl: URL,
|
routingUrl: URL,
|
||||||
routingSearchParams: URLSearchParams
|
routingSearchParams: URLSearchParams
|
||||||
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
|
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
|
||||||
|
console.log("handleRedirectWithContactOwner called", { routingUrl, routingSearchParams });
|
||||||
const pathNameParams = routingUrl.pathname.split("/");
|
const pathNameParams = routingUrl.pathname.split("/");
|
||||||
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
|
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
|
||||||
const teamId = Number(routingSearchParams.get("cal.teamId"));
|
const teamId = Number(routingSearchParams.get("cal.teamId"));
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export class RoutingFormsService {
|
|||||||
if (!eventTypeId) {
|
if (!eventTypeId) {
|
||||||
throw new NotFoundException("Event type not found.");
|
throw new NotFoundException("Event type not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const slots = await this.slotsService.getAvailableSlots({
|
const slots = await this.slotsService.getAvailableSlots({
|
||||||
type: ById_2024_09_04_type,
|
type: ById_2024_09_04_type,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export class SlotsController_2024_09_04 {
|
|||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Find out when is an event type ready to be booked.",
|
summary: "Get available time slots for an event type",
|
||||||
description: `
|
description: `
|
||||||
There are 4 ways to get available slots for event type of an individual user:
|
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")
|
@Patch("/reservations/:uid")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Updated reserved a slot",
|
summary: "Update a reserved slot",
|
||||||
})
|
})
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async updateReservedSlot(
|
async updateReservedSlot(
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { DateTime } from "luxon";
|
|||||||
|
|
||||||
import { dynamicEvent } from "@calcom/platform-libraries";
|
import { dynamicEvent } from "@calcom/platform-libraries";
|
||||||
import {
|
import {
|
||||||
ById_2024_09_04,
|
|
||||||
ByUsernameAndEventTypeSlug_2024_09_04,
|
ByUsernameAndEventTypeSlug_2024_09_04,
|
||||||
ByTeamSlugAndEventTypeSlug_2024_09_04,
|
ByTeamSlugAndEventTypeSlug_2024_09_04,
|
||||||
GetSlotsInput_2024_09_04,
|
GetSlotsInput_2024_09_04,
|
||||||
@@ -47,6 +46,10 @@ export class SlotsInputService_2024_09_04 {
|
|||||||
const timeZone = query.timeZone;
|
const timeZone = query.timeZone;
|
||||||
const orgSlug = "organizationSlug" in query ? query.organizationSlug : null;
|
const orgSlug = "organizationSlug" in query ? query.organizationSlug : null;
|
||||||
const rescheduleUid = query.bookingUidToReschedule || 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 {
|
return {
|
||||||
isTeamEvent,
|
isTeamEvent,
|
||||||
@@ -59,6 +62,10 @@ export class SlotsInputService_2024_09_04 {
|
|||||||
timeZone,
|
timeZone,
|
||||||
orgSlug,
|
orgSlug,
|
||||||
rescheduleUid,
|
rescheduleUid,
|
||||||
|
routedTeamMemberIds,
|
||||||
|
skipContactOwner,
|
||||||
|
teamMemberEmail,
|
||||||
|
routingFormResponseId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export class SlotsService_2024_09_04 {
|
|||||||
const availableSlots: TimeSlots = await getAvailableSlots({
|
const availableSlots: TimeSlots = await getAvailableSlots({
|
||||||
input: {
|
input: {
|
||||||
...queryTransformed,
|
...queryTransformed,
|
||||||
|
routingFormResponseId: queryTransformed.routingFormResponseId ?? undefined,
|
||||||
},
|
},
|
||||||
ctx: {},
|
ctx: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class StripeController {
|
|||||||
@Get("/connect")
|
@Get("/connect")
|
||||||
@UseGuards(ApiAuthGuard)
|
@UseGuards(ApiAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: "Get stripe connect URL" })
|
@ApiOperation({ summary: "Get Stripe connect URL" })
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
async redirect(
|
async redirect(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@@ -79,7 +79,7 @@ export class StripeController {
|
|||||||
@Get("/save")
|
@Get("/save")
|
||||||
@UseGuards()
|
@UseGuards()
|
||||||
@Redirect(undefined, 301)
|
@Redirect(undefined, 301)
|
||||||
@ApiOperation({ summary: "Save stripe credentials" })
|
@ApiOperation({ summary: "Save Stripe credentials" })
|
||||||
/**
|
/**
|
||||||
* Handles saving 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;
|
* 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)
|
@UseGuards(ApiAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
@ApiOperation({ summary: "Check stripe connection" })
|
@ApiOperation({ summary: "Check Stripe connection" })
|
||||||
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||||
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
|
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 { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export const MembershipUserSelect: Prisma.UserSelect = {
|
export const MembershipUserSelect: Prisma.UserSelect = {
|
||||||
username: true,
|
username: true,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export class WebhooksController {
|
|||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get all webooks",
|
summary: "Get all webhooks",
|
||||||
description: "Gets a paginated list of webhooks for the authenticated user.",
|
description: "Gets a paginated list of webhooks for the authenticated user.",
|
||||||
})
|
})
|
||||||
async getWebhooks(
|
async getWebhooks(
|
||||||
|
|||||||
@@ -1123,7 +1123,7 @@
|
|||||||
"/v2/organizations/{orgId}/attributes/{attributeId}/options/assigned": {
|
"/v2/organizations/{orgId}/attributes/{attributeId}/options/assigned": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptions",
|
"operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptions",
|
||||||
"summary": "Get by attribute id all of the attribute options that are assigned to users",
|
"summary": "Get all assigned attribute options by attribute ID",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -1215,7 +1215,7 @@
|
|||||||
"/v2/organizations/{orgId}/attributes/slugs/{attributeSlug}/options/assigned": {
|
"/v2/organizations/{orgId}/attributes/slugs/{attributeSlug}/options/assigned": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptionsBySlug",
|
"operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptionsBySlug",
|
||||||
"summary": "Get by attribute slug all of the attribute options that are assigned to users",
|
"summary": "Get all assigned attribute options by attribute slug",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -1467,7 +1467,7 @@
|
|||||||
"/v2/organizations/{orgId}/bookings": {
|
"/v2/organizations/{orgId}/bookings": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsBookingsController_getAllOrgTeamBookings",
|
"operationId": "OrganizationsBookingsController_getAllOrgTeamBookings",
|
||||||
"summary": "Get Organization Bookings",
|
"summary": "Get organization bookings",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -1763,7 +1763,7 @@
|
|||||||
"/v2/organizations/{orgId}/delegation-credentials": {
|
"/v2/organizations/{orgId}/delegation-credentials": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "OrganizationsDelegationCredentialController_createDelegationCredential",
|
"operationId": "OrganizationsDelegationCredentialController_createDelegationCredential",
|
||||||
"summary": "Save delegation credentials for your organization.",
|
"summary": "Save delegation credentials for your organization",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -1831,7 +1831,7 @@
|
|||||||
"/v2/organizations/{orgId}/delegation-credentials/{credentialId}": {
|
"/v2/organizations/{orgId}/delegation-credentials/{credentialId}": {
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "OrganizationsDelegationCredentialController_updateDelegationCredential",
|
"operationId": "OrganizationsDelegationCredentialController_updateDelegationCredential",
|
||||||
"summary": "Update delegation credentials of your organization.",
|
"summary": "Update delegation credentials of your organization",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -2542,6 +2542,126 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Orgs / Routing forms"
|
"Orgs / Routing forms"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "OrganizationsRoutingFormsResponsesController_createRoutingFormResponse",
|
||||||
|
"summary": "Create routing form response and get available slots",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header",
|
||||||
|
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "orgId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "routingFormId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "start",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ",
|
||||||
|
"example": "2050-09-05",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "end",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z",
|
||||||
|
"example": "2050-09-06",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "timeZone",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Time zone in which the available slots should be returned. Defaults to UTC.",
|
||||||
|
"example": "Europe/Rome",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "duration",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.",
|
||||||
|
"example": "60",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "format",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Format of slot times in response. Use 'range' to get start and end times.",
|
||||||
|
"example": "range",
|
||||||
|
"schema": {
|
||||||
|
"enum": [
|
||||||
|
"range",
|
||||||
|
"time"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bookingUidToReschedule",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.",
|
||||||
|
"example": "abc123def456",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "queueResponse",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Whether to queue the form response.",
|
||||||
|
"example": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CreateRoutingFormResponseOutput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Orgs / Routing forms"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v2/organizations/{orgId}/routing-forms/{routingFormId}/responses/{responseId}": {
|
"/v2/organizations/{orgId}/routing-forms/{routingFormId}/responses/{responseId}": {
|
||||||
@@ -3454,7 +3574,7 @@
|
|||||||
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/oauth/auth-url": {
|
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/oauth/auth-url": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsConferencingController_getTeamOAuthUrl",
|
"operationId": "OrganizationsConferencingController_getTeamOAuthUrl",
|
||||||
"summary": "Get OAuth conferencing app's auth url for a team",
|
"summary": "Get OAuth conferencing app's auth URL for a team",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -5761,7 +5881,7 @@
|
|||||||
"/v2/organizations/{orgId}/users/{userId}/bookings": {
|
"/v2/organizations/{orgId}/users/{userId}/bookings": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsUsersBookingsController_getOrganizationUserBookings",
|
"operationId": "OrganizationsUsersBookingsController_getOrganizationUserBookings",
|
||||||
"summary": "Get all bookings of an organization user",
|
"summary": "Get all bookings for an organization user",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -6048,7 +6168,7 @@
|
|||||||
"/v2/organizations/{orgId}/users/{userId}/ooo": {
|
"/v2/organizations/{orgId}/users/{userId}/ooo": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO",
|
"operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO",
|
||||||
"summary": "Get all ooo entries of a user",
|
"summary": "Get all out-of-office entries for a user",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -6150,7 +6270,7 @@
|
|||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "OrganizationsUsersOOOController_createOrganizationUserOOO",
|
"operationId": "OrganizationsUsersOOOController_createOrganizationUserOOO",
|
||||||
"summary": "Create an ooo entry for user",
|
"summary": "Create an out-of-office entry for a user",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -6211,7 +6331,7 @@
|
|||||||
"/v2/organizations/{orgId}/users/{userId}/ooo/{oooId}": {
|
"/v2/organizations/{orgId}/users/{userId}/ooo/{oooId}": {
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "OrganizationsUsersOOOController_updateOrganizationUserOOO",
|
"operationId": "OrganizationsUsersOOOController_updateOrganizationUserOOO",
|
||||||
"summary": "Update ooo entry of a user",
|
"summary": "Update an out-of-office entry for a user",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -6278,7 +6398,7 @@
|
|||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"operationId": "OrganizationsUsersOOOController_deleteOrganizationUserOOO",
|
"operationId": "OrganizationsUsersOOOController_deleteOrganizationUserOOO",
|
||||||
"summary": "Delete ooo entry of a user",
|
"summary": "Delete an out-of-office entry for a user",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -6329,7 +6449,7 @@
|
|||||||
"/v2/organizations/{orgId}/ooo": {
|
"/v2/organizations/{orgId}/ooo": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsUsersOOOController_getOrganizationUsersOOO",
|
"operationId": "OrganizationsUsersOOOController_getOrganizationUsersOOO",
|
||||||
"summary": "Get all OOO entries of org users",
|
"summary": "Get all out-of-office entries for organization users",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -8362,7 +8482,7 @@
|
|||||||
"/v2/calendars/{calendar}/connect": {
|
"/v2/calendars/{calendar}/connect": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "CalendarsController_redirect",
|
"operationId": "CalendarsController_redirect",
|
||||||
"summary": "Get oAuth connect URL",
|
"summary": "Get OAuth connect URL",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -8657,7 +8777,7 @@
|
|||||||
"/v2/conferencing/{app}/oauth/auth-url": {
|
"/v2/conferencing/{app}/oauth/auth-url": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "ConferencingController_redirect",
|
"operationId": "ConferencingController_redirect",
|
||||||
"summary": "Get OAuth conferencing app auth url",
|
"summary": "Get OAuth conferencing app auth URL",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -8718,7 +8838,7 @@
|
|||||||
"/v2/conferencing/{app}/oauth/callback": {
|
"/v2/conferencing/{app}/oauth/callback": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "ConferencingController_save",
|
"operationId": "ConferencingController_save",
|
||||||
"summary": "conferencing apps oauths callback",
|
"summary": "Conferencing app OAuth callback",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "state",
|
"name": "state",
|
||||||
@@ -10488,7 +10608,7 @@
|
|||||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
|
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
|
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
|
||||||
"summary": "Get stripe connect URL for a team",
|
"summary": "Get Stripe connect URL for a team",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -10551,7 +10671,7 @@
|
|||||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/check": {
|
"/v2/organizations/{orgId}/teams/{teamId}/stripe/check": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsStripeController_checkTeamStripeConnection",
|
"operationId": "OrganizationsStripeController_checkTeamStripeConnection",
|
||||||
"summary": "Check team stripe connection",
|
"summary": "Check team Stripe connection",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "teamId",
|
"name": "teamId",
|
||||||
@@ -10582,7 +10702,7 @@
|
|||||||
"/v2/organizations/{orgId}/teams/{teamId}/stripe/save": {
|
"/v2/organizations/{orgId}/teams/{teamId}/stripe/save": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "OrganizationsStripeController_save",
|
"operationId": "OrganizationsStripeController_save",
|
||||||
"summary": "Save stripe credentials",
|
"summary": "Save Stripe credentials",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "state",
|
"name": "state",
|
||||||
@@ -11131,7 +11251,7 @@
|
|||||||
"/v2/slots": {
|
"/v2/slots": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
|
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
|
||||||
"summary": "Find out when is an event type ready to be booked.",
|
"summary": "Get available time slots for an event type",
|
||||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n - bookingUidToReschedule: When rescheduling an existing booking, provide the booking's unique identifier to exclude its time slot from busy time calculations. This ensures the original booking time appears as available for rescheduling.\n ",
|
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n - bookingUidToReschedule: When rescheduling an existing booking, provide the booking's unique identifier to exclude its time slot from busy time calculations. This ensures the original booking time appears as available for rescheduling.\n ",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -11437,7 +11557,7 @@
|
|||||||
},
|
},
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "SlotsController_2024_09_04_updateReservedSlot",
|
"operationId": "SlotsController_2024_09_04_updateReservedSlot",
|
||||||
"summary": "Updated reserved a slot",
|
"summary": "Update a reserved slot",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "cal-api-version",
|
"name": "cal-api-version",
|
||||||
@@ -11530,7 +11650,7 @@
|
|||||||
"/v2/stripe/connect": {
|
"/v2/stripe/connect": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "StripeController_redirect",
|
"operationId": "StripeController_redirect",
|
||||||
"summary": "Get stripe connect URL",
|
"summary": "Get Stripe connect URL",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -11562,7 +11682,7 @@
|
|||||||
"/v2/stripe/save": {
|
"/v2/stripe/save": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "StripeController_save",
|
"operationId": "StripeController_save",
|
||||||
"summary": "Save stripe credentials",
|
"summary": "Save Stripe credentials",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "state",
|
"name": "state",
|
||||||
@@ -11601,7 +11721,7 @@
|
|||||||
"/v2/stripe/check": {
|
"/v2/stripe/check": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "StripeController_check",
|
"operationId": "StripeController_check",
|
||||||
"summary": "Check stripe connection",
|
"summary": "Check Stripe connection",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
@@ -13219,7 +13339,7 @@
|
|||||||
},
|
},
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "WebhooksController_getWebhooks",
|
"operationId": "WebhooksController_getWebhooks",
|
||||||
"summary": "Get all webooks",
|
"summary": "Get all webhooks",
|
||||||
"description": "Gets a paginated list of webhooks for the authenticated user.",
|
"description": "Gets a paginated list of webhooks for the authenticated user.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -23931,6 +24051,51 @@
|
|||||||
"timeZone"
|
"timeZone"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"Routing": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"responseId": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The ID of the routing form response that determined this booking assignment.",
|
||||||
|
"example": 123
|
||||||
|
},
|
||||||
|
"teamMemberIds": {
|
||||||
|
"description": "Array of team member IDs that were routed to handle this booking.",
|
||||||
|
"example": [
|
||||||
|
101,
|
||||||
|
102
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"teamMemberEmail": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The email of the team member assigned to handle this booking.",
|
||||||
|
"example": "john.doe@example.com"
|
||||||
|
},
|
||||||
|
"skipContactOwner": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to skip contact owner assignment from CRM integration.",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"crmAppSlug": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The CRM application slug for integration.",
|
||||||
|
"example": "salesforce"
|
||||||
|
},
|
||||||
|
"crmOwnerRecordType": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The CRM owner record type for contact assignment.",
|
||||||
|
"example": "Account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"responseId",
|
||||||
|
"teamMemberIds"
|
||||||
|
]
|
||||||
|
},
|
||||||
"CreateBookingInput_2024_08_13": {
|
"CreateBookingInput_2024_08_13": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -24036,6 +24201,21 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"example": 30,
|
"example": 30,
|
||||||
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
||||||
|
},
|
||||||
|
"routing": {
|
||||||
|
"description": "Routing information from routing forms that determined the booking assignment. Both responseId and teamMemberIds are required if provided.",
|
||||||
|
"example": {
|
||||||
|
"responseId": 123,
|
||||||
|
"teamMemberIds": [
|
||||||
|
101,
|
||||||
|
102
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Routing"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -24149,6 +24329,21 @@
|
|||||||
"example": 30,
|
"example": 30,
|
||||||
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
||||||
},
|
},
|
||||||
|
"routing": {
|
||||||
|
"description": "Routing information from routing forms that determined the booking assignment. Both responseId and teamMemberIds are required if provided.",
|
||||||
|
"example": {
|
||||||
|
"responseId": 123,
|
||||||
|
"teamMemberIds": [
|
||||||
|
101,
|
||||||
|
102
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Routing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"instant": {
|
"instant": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Flag indicating if the booking is an instant booking. Only available for team events.",
|
"description": "Flag indicating if the booking is an instant booking. Only available for team events.",
|
||||||
@@ -24267,6 +24462,21 @@
|
|||||||
"example": 30,
|
"example": 30,
|
||||||
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
|
||||||
},
|
},
|
||||||
|
"routing": {
|
||||||
|
"description": "Routing information from routing forms that determined the booking assignment. Both responseId and teamMemberIds are required if provided.",
|
||||||
|
"example": {
|
||||||
|
"responseId": 123,
|
||||||
|
"teamMemberIds": [
|
||||||
|
101,
|
||||||
|
102
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Routing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"recurrenceCount": {
|
"recurrenceCount": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than\n event type recurrence count",
|
"description": "The number of recurrences. If not provided then event type recurrence count will be used. Can't be more than\n event type recurrence count",
|
||||||
@@ -26600,6 +26810,75 @@
|
|||||||
"data"
|
"data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"CreateRoutingFormResponseOutputData": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"eventTypeId": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The ID of the event type that was routed to.",
|
||||||
|
"example": 123
|
||||||
|
},
|
||||||
|
"routing": {
|
||||||
|
"description": "The routing information.",
|
||||||
|
"example": {
|
||||||
|
"eventTypeId": 123,
|
||||||
|
"routing": {
|
||||||
|
"teamMemberIds": [
|
||||||
|
101,
|
||||||
|
102
|
||||||
|
],
|
||||||
|
"teamMemberEmail": "john.doe@example.com",
|
||||||
|
"skipContactOwner": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Routing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"routingCustomMessage": {
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"routingExternalRedirectUrl": {
|
||||||
|
"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/"
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SlotsOutput_2024_09_04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RangeSlotsOutput_2024_09_04"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CreateRoutingFormResponseOutput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "success",
|
||||||
|
"enum": [
|
||||||
|
"success",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/CreateRoutingFormResponseOutputData"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
},
|
||||||
"RequestEmailVerificationInput": {
|
"RequestEmailVerificationInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ export class ApiKeysRepositoryFixture {
|
|||||||
|
|
||||||
async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
|
async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
|
||||||
const keyString = randomBytes(16).toString("hex");
|
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({
|
const apiKey = await this.prismaWriteClient.apiKey.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
hashedKey: createHash("sha256").update(keyString).digest("hex"),
|
hashedKey,
|
||||||
expiresAt: expiresAt,
|
expiresAt: expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
|||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export class AttendeeRepositoryFixture {
|
export class AttendeeRepositoryFixture {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
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 { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export class BookingSeatRepositoryFixture {
|
export class BookingSeatRepositoryFixture {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
private prismaReadClient: PrismaReadService["prisma"];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
import { Booking, User } from "@prisma/client";
|
import { Booking, User } from "@prisma/client";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export class BookingsRepositoryFixture {
|
export class BookingsRepositoryFixture {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
private prismaReadClient: PrismaReadService["prisma"];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
export class CredentialsRepositoryFixture {
|
export class CredentialsRepositoryFixture {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
private prismaReadClient: PrismaReadService["prisma"];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
import { EventType } from "@prisma/client";
|
import { EventType } from "@prisma/client";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export class EventTypesRepositoryFixture {
|
export class EventTypesRepositoryFixture {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
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 { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export class HostsRepositoryFixture {
|
export class HostsRepositoryFixture {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
private prismaReadClient: PrismaReadService["prisma"];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
export class ProfileRepositoryFixture {
|
export class ProfileRepositoryFixture {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
private prismaReadClient: PrismaReadService["prisma"];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
|||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
import { Schedule } from "@prisma/client";
|
import { Schedule } from "@prisma/client";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export class SchedulesRepositoryFixture {
|
export class SchedulesRepositoryFixture {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
private prismaReadClient: PrismaReadService["prisma"];
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
export class VerifiedResourcesRepositoryFixtures {
|
export class VerifiedResourcesRepositoryFixtures {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
private prismaReadClient: PrismaReadService["prisma"];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
import { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
export class WebhookRepositoryFixture {
|
export class WebhookRepositoryFixture {
|
||||||
private primaReadClient: PrismaReadService["prisma"];
|
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 { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TestingModule } from "@nestjs/testing";
|
import { TestingModule } from "@nestjs/testing";
|
||||||
|
|
||||||
import { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|
||||||
export class WorkflowRepositoryFixture {
|
export class WorkflowRepositoryFixture {
|
||||||
private prismaReadClient: PrismaReadService["prisma"];
|
private prismaReadClient: PrismaReadService["prisma"];
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export class CalendarsServiceMock {
|
|||||||
id: 0,
|
id: 0,
|
||||||
delegationCredentialId: null,
|
delegationCredentialId: null,
|
||||||
domainWideDelegationCredentialId: null,
|
domainWideDelegationCredentialId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
} satisfies Awaited<ReturnType<typeof CalendarsService.prototype.getCalendars>>;
|
} 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">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button className="cursor-pointer">
|
<Button variant="icon" color="secondary" StartIcon="calendar" />
|
||||||
<Button variant="icon" color="secondary" StartIcon="calendar" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownItem>Rename</DropdownItem>
|
<DropdownItem>Rename</DropdownItem>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { unstable_cache } from "next/cache";
|
|||||||
|
|
||||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
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 { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -37,7 +38,7 @@ export const ServerTeamsListing = async ({
|
|||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
teamNameFromInvite = await TeamRepository.inviteMemberByToken(token, userId);
|
teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMsgFromInvite = "Error while fetching teams";
|
errorMsgFromInvite = "Error while fetching teams";
|
||||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
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 { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
import type { OrganizationBranding } 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 Shell from "@calcom/features/shell/Shell";
|
||||||
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||||
@@ -177,7 +179,13 @@ const organizationAdminKeys = [
|
|||||||
"delegation_credential",
|
"delegation_credential",
|
||||||
];
|
];
|
||||||
|
|
||||||
const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnabled: boolean }) => {
|
const useTabs = ({
|
||||||
|
isDelegationCredentialEnabled,
|
||||||
|
isPbacEnabled,
|
||||||
|
}: {
|
||||||
|
isDelegationCredentialEnabled: boolean;
|
||||||
|
isPbacEnabled: boolean;
|
||||||
|
}) => {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
|
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
|
||||||
const orgBranding = useOrgBranding();
|
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 {
|
return {
|
||||||
...tab,
|
...tab,
|
||||||
children: newArray,
|
children: newArray,
|
||||||
@@ -272,6 +288,7 @@ interface SettingsSidebarContainerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
navigationIsOpenedOnMobile?: boolean;
|
navigationIsOpenedOnMobile?: boolean;
|
||||||
bannersHeight?: number;
|
bannersHeight?: number;
|
||||||
|
teamFeatures?: Record<number, TeamFeatures>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamListCollapsible = () => {
|
const TeamListCollapsible = () => {
|
||||||
@@ -438,6 +455,7 @@ const SettingsSidebarContainer = ({
|
|||||||
className = "",
|
className = "",
|
||||||
navigationIsOpenedOnMobile,
|
navigationIsOpenedOnMobile,
|
||||||
bannersHeight,
|
bannersHeight,
|
||||||
|
teamFeatures,
|
||||||
}: SettingsSidebarContainerProps) => {
|
}: SettingsSidebarContainerProps) => {
|
||||||
const searchParams = useCompatSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const orgBranding = useOrgBranding();
|
const orgBranding = useOrgBranding();
|
||||||
@@ -449,11 +467,24 @@ const SettingsSidebarContainer = ({
|
|||||||
}[]
|
}[]
|
||||||
>();
|
>();
|
||||||
const session = useSession();
|
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({
|
const tabsWithPermissions = useTabs({
|
||||||
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
|
isDelegationCredentialEnabled,
|
||||||
|
isPbacEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||||
@@ -727,9 +758,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
|||||||
export type SettingsLayoutProps = {
|
export type SettingsLayoutProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
|
teamFeatures?: Record<number, TeamFeatures>;
|
||||||
} & ComponentProps<typeof Shell>;
|
} & ComponentProps<typeof Shell>;
|
||||||
|
|
||||||
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
|
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const state = useState(false);
|
const state = useState(false);
|
||||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||||
@@ -761,6 +793,7 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
|
|||||||
<SidebarContainerElement
|
<SidebarContainerElement
|
||||||
sideContainerOpen={sideContainerOpen}
|
sideContainerOpen={sideContainerOpen}
|
||||||
setSideContainerOpen={setSideContainerOpen}
|
setSideContainerOpen={setSideContainerOpen}
|
||||||
|
teamFeatures={teamFeatures}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
drawerState={state}
|
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 = ({
|
const SidebarContainerElement = ({
|
||||||
sideContainerOpen,
|
sideContainerOpen,
|
||||||
bannersHeight,
|
bannersHeight,
|
||||||
setSideContainerOpen,
|
setSideContainerOpen,
|
||||||
|
teamFeatures,
|
||||||
}: SidebarContainerElementProps) => {
|
}: SidebarContainerElementProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
@@ -797,13 +838,8 @@ const SidebarContainerElement = ({
|
|||||||
<SettingsSidebarContainer
|
<SettingsSidebarContainer
|
||||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||||
bannersHeight={bannersHeight}
|
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 { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
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 { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
|
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
|
||||||
import SettingsLayoutAppDirClient 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) {
|
export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
const userId = session?.user?.id;
|
const userId = session?.user?.id;
|
||||||
@@ -16,9 +30,21 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
|||||||
return redirect("/auth/login");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsLayoutAppDirClient {...props} />
|
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
|
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import classNames from "@calcom/ui/classNames";
|
||||||
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
import { Checkbox, Label } from "@calcom/ui/form";
|
||||||
|
|
||||||
|
import { usePermissions } from "./usePermissions";
|
||||||
|
|
||||||
|
interface AdvancedPermissionGroupProps {
|
||||||
|
resource: Resource;
|
||||||
|
selectedPermissions: string[];
|
||||||
|
onChange: (permissions: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERNAL_DATAACCESS_KEY = "_resource";
|
||||||
|
|
||||||
|
export function AdvancedPermissionGroup({
|
||||||
|
resource,
|
||||||
|
selectedPermissions,
|
||||||
|
onChange,
|
||||||
|
}: AdvancedPermissionGroupProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions();
|
||||||
|
const resourceConfig = PERMISSION_REGISTRY[resource];
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const isAllResources = resource === "*";
|
||||||
|
const allResourcesSelected = selectedPermissions.includes("*.*");
|
||||||
|
|
||||||
|
// Get all possible permissions for this resource
|
||||||
|
const allPermissions = isAllResources
|
||||||
|
? ["*.*"]
|
||||||
|
: Object.entries(resourceConfig)
|
||||||
|
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
|
||||||
|
.map(([action]) => `${resource}.${action}`);
|
||||||
|
|
||||||
|
// Check if all permissions for this resource are selected
|
||||||
|
const isAllSelected = isAllResources
|
||||||
|
? allResourcesSelected
|
||||||
|
: allPermissions.every((p) => selectedPermissions.includes(p));
|
||||||
|
|
||||||
|
const handleToggleAll = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation(); // Stop event from triggering parent click
|
||||||
|
onChange(toggleResourcePermissionLevel(resource, isAllSelected ? "none" : "all", selectedPermissions));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if read permission is auto-enabled
|
||||||
|
const isReadAutoEnabled = (action: string) => {
|
||||||
|
if (action === CrudAction.Read) return false;
|
||||||
|
if (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) {
|
||||||
|
return selectedPermissions.includes(`${resource}.${CrudAction.Read}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-muted border-subtle mb-2 rounded-xl border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex cursor-pointer items-center justify-between gap-1.5 p-4"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
|
<Icon
|
||||||
|
name={isAllResources ? "chevron-right" : "chevron-down"}
|
||||||
|
className={classNames(
|
||||||
|
"h-4 w-4 transition-transform",
|
||||||
|
isExpanded && !isAllResources ? "rotate-180" : ""
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
onCheckedChange={() => handleToggleAll}
|
||||||
|
onClick={handleToggleAll}
|
||||||
|
/>
|
||||||
|
<span className="text-default text-sm font-medium leading-none">
|
||||||
|
{t(resourceConfig._resource?.i18nKey || "")}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-sm font-medium leading-none">{t("all_permissions")}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{isExpanded && !isAllResources && (
|
||||||
|
<div
|
||||||
|
className="bg-default border-muted m-1 flex flex-col gap-2.5 rounded-xl border p-3"
|
||||||
|
onClick={(e) => e.stopPropagation()} // Stop clicks in the permission list from affecting parent
|
||||||
|
>
|
||||||
|
{Object.entries(resourceConfig).map(([action, actionConfig]) => {
|
||||||
|
const permission = `${resource}.${action}`;
|
||||||
|
|
||||||
|
if (action === INTERNAL_DATAACCESS_KEY) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChecked = selectedPermissions.includes(permission);
|
||||||
|
const isReadPermission = action === CrudAction.Read;
|
||||||
|
const isAutoEnabled = isReadAutoEnabled(action);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={action} className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id={permission}
|
||||||
|
checked={isChecked}
|
||||||
|
className="mr-2"
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
|
||||||
|
>
|
||||||
|
<Label htmlFor={permission} className="mb-0">
|
||||||
|
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
|
||||||
|
{t(actionConfig?.i18nKey || "")}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{t(
|
||||||
|
actionConfig && "descriptionI18nKey" in actionConfig
|
||||||
|
? actionConfig.descriptionI18nKey
|
||||||
|
: ""
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isAutoEnabled && (
|
||||||
|
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
|
||||||
|
<Icon name="info" className="text-muted-foreground h-3 w-3" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}{" "}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Button } from "@calcom/ui/button";
|
||||||
|
|
||||||
|
import { useRoleSheetState } from "../hooks/useRoleQueryStates";
|
||||||
|
|
||||||
|
export function CreateRoleCTA() {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { setIsOpen } = useRoleSheetState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button StartIcon="plus" onClick={() => setIsOpen(true)}>
|
||||||
|
{t("create_role")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { Dialog } from "@calcom/ui/components/dialog";
|
||||||
|
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
||||||
|
import { DropdownItem } from "@calcom/ui/components/dropdown";
|
||||||
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
|
||||||
|
import { revalidateTeamRoles } from "../actions";
|
||||||
|
|
||||||
|
interface DeleteRoleModalProps {
|
||||||
|
roleId: string;
|
||||||
|
roleName: string;
|
||||||
|
teamId: number;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteRoleModal({ roleId, roleName, teamId, onDeleted }: DeleteRoleModalProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const deleteMutation = trpc.viewer.pbac.deleteRole.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
showToast(t("role_deleted_successfully"), "success");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
await revalidateTeamRoles();
|
||||||
|
router.refresh();
|
||||||
|
onDeleted?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
showToast(error.message || t("error_deleting_role"), "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DropdownItem StartIcon="trash" color="destructive" onClick={() => setIsModalOpen(true)}>
|
||||||
|
{t("delete")}
|
||||||
|
</DropdownItem>
|
||||||
|
<ConfirmationDialogContent
|
||||||
|
variety="danger"
|
||||||
|
title={t("delete_role")}
|
||||||
|
confirmBtnText={t("delete")}
|
||||||
|
cancelBtnText={t("cancel")}
|
||||||
|
onConfirm={() => {
|
||||||
|
deleteMutation.mutate({
|
||||||
|
teamId,
|
||||||
|
roleId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
loadingText={t("deleting_role")}>
|
||||||
|
<p className="mb-4">
|
||||||
|
{t("confirm_delete_role", {
|
||||||
|
roleName,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</ConfirmationDialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
|
export interface RoleColorPickerProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleColorPicker({ value, onChange }: RoleColorPickerProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const [, setCustomColor] = useState(value);
|
||||||
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newColor = e.target.value;
|
||||||
|
setCustomColor(newColor);
|
||||||
|
onChange(newColor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleColorSquareClick = () => {
|
||||||
|
colorInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleColorSquareClick}
|
||||||
|
className="bg-default text-default border-default hover:bg-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0">
|
||||||
|
<div
|
||||||
|
className="h-5 w-5 rounded border shadow-sm"
|
||||||
|
style={{ backgroundColor: value }}
|
||||||
|
aria-label={t("select_color")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={colorInputRef}
|
||||||
|
type="color"
|
||||||
|
value={value}
|
||||||
|
onChange={handleCustomColorChange}
|
||||||
|
className="sr-only"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useMemo, useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
|
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { Button } from "@calcom/ui/button";
|
||||||
|
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
|
||||||
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
import { Form, TextField, Checkbox, Label } from "@calcom/ui/form";
|
||||||
|
|
||||||
|
import { revalidateTeamRoles } from "../actions";
|
||||||
|
import { AdvancedPermissionGroup } from "./AdvancedPermissionGroup";
|
||||||
|
import RoleColorPicker from "./RoleColorPicker";
|
||||||
|
import { SimplePermissionItem } from "./SimplePermissionItem";
|
||||||
|
|
||||||
|
type Role = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
teamId?: number;
|
||||||
|
color?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
type: "SYSTEM" | "CUSTOM";
|
||||||
|
permissions: {
|
||||||
|
id: string;
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "role_name_required" }),
|
||||||
|
description: z.string().optional(),
|
||||||
|
isAdvancedMode: z.boolean().default(false),
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
color: z.string().default("#FF5733"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface RoleSheetProps {
|
||||||
|
role?: Role;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
teamId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const isEditing = Boolean(role);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const defaultValues = useMemo(
|
||||||
|
() => ({
|
||||||
|
name: role?.name || "",
|
||||||
|
description: role?.description || "",
|
||||||
|
color: role?.color || "#FF5733",
|
||||||
|
isAdvancedMode: false,
|
||||||
|
permissions: role?.permissions?.map((p) => `${p.resource}.${p.action}`) || [],
|
||||||
|
}),
|
||||||
|
[role]
|
||||||
|
); // Memoize default values based on role
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update form values when role changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Only reset if the sheet is open to prevent unnecessary resets
|
||||||
|
if (open) {
|
||||||
|
if (role) {
|
||||||
|
form.reset({
|
||||||
|
name: role.name,
|
||||||
|
description: role.description || "",
|
||||||
|
color: role.color || "#FF5733",
|
||||||
|
isAdvancedMode: form.getValues("isAdvancedMode"), // Preserve the current mode
|
||||||
|
permissions: role.permissions.map((p) => `${p.resource}.${p.action}`),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.reset({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
color: "#FF5733",
|
||||||
|
isAdvancedMode: false,
|
||||||
|
permissions: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [role, form, open]);
|
||||||
|
|
||||||
|
const { isAdvancedMode, permissions, color } = form.watch();
|
||||||
|
|
||||||
|
const filteredResources = useMemo(() => {
|
||||||
|
return Object.keys(PERMISSION_REGISTRY).filter((resource) =>
|
||||||
|
t(
|
||||||
|
PERMISSION_REGISTRY[resource as Resource][
|
||||||
|
CrudAction.All as keyof (typeof PERMISSION_REGISTRY)[Resource]
|
||||||
|
]?.i18nKey || ""
|
||||||
|
)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [searchQuery, t]);
|
||||||
|
|
||||||
|
const createMutation = trpc.viewer.pbac.createRole.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
showToast(t("role_created_successfully"), "success");
|
||||||
|
form.reset();
|
||||||
|
onOpenChange(false);
|
||||||
|
await revalidateTeamRoles();
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
showToast(error.message || t("error_creating_role"), "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = trpc.viewer.pbac.updateRole.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
showToast(t("role_updated_successfully"), "success");
|
||||||
|
onOpenChange(false);
|
||||||
|
await revalidateTeamRoles();
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
showToast(error.message || t("error_updating_role"), "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (values: FormValues) => {
|
||||||
|
// Store the color in localStorage
|
||||||
|
const roleKey = isEditing && role ? role.id : `new_role_${values.name}`;
|
||||||
|
localStorage.setItem(`role_color_${roleKey}`, values.color);
|
||||||
|
|
||||||
|
if (isEditing && role) {
|
||||||
|
updateMutation.mutate({
|
||||||
|
teamId,
|
||||||
|
roleId: role.id,
|
||||||
|
name: values.name,
|
||||||
|
permissions: values.permissions as any,
|
||||||
|
color: values.color,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createMutation.mutate({
|
||||||
|
teamId,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
color: values.color,
|
||||||
|
permissions: values.permissions as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{isEditing ? t("edit_role") : t("create_role")}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<Form form={form} handleSubmit={onSubmit}>
|
||||||
|
<div className="space-y-4 py-5">
|
||||||
|
<div className="flex items-end justify-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<TextField
|
||||||
|
label={t("role_name")}
|
||||||
|
{...form.register("name")}
|
||||||
|
placeholder={t("role_name_placeholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<RoleColorPicker
|
||||||
|
value={color}
|
||||||
|
onChange={(value) => form.setValue("color", value, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="">
|
||||||
|
{isAdvancedMode ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<Label className="mb-0">{t("permissions")}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.watch("isAdvancedMode")}
|
||||||
|
onCheckedChange={(checked: boolean) => form.setValue("isAdvancedMode", checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{t("advanced")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
id="permissions_search"
|
||||||
|
placeholder={t("search_permissions")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredResources.map((resource) => (
|
||||||
|
<AdvancedPermissionGroup
|
||||||
|
key={resource}
|
||||||
|
resource={resource as Resource}
|
||||||
|
selectedPermissions={permissions}
|
||||||
|
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-muted rounded-xl p-1">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<Label>{t("permissions")}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.watch("isAdvancedMode")}
|
||||||
|
onCheckedChange={(checked: boolean) => form.setValue("isAdvancedMode", checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{t("advanced")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-default border-subtle divide-subtle divide-y rounded-[10px] border">
|
||||||
|
{Object.keys(PERMISSION_REGISTRY).map((resource) => (
|
||||||
|
<SimplePermissionItem
|
||||||
|
key={resource}
|
||||||
|
resource={resource}
|
||||||
|
permissions={permissions}
|
||||||
|
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={createMutation.isPending || updateMutation.isPending}>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={createMutation.isPending || updateMutation.isPending}>
|
||||||
|
{isEditing ? t("save") : t("create")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</Form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { Badge } from "@calcom/ui/badge";
|
||||||
|
import { Button } from "@calcom/ui/button";
|
||||||
|
import classNames from "@calcom/ui/classNames";
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@calcom/ui/components/dropdown";
|
||||||
|
|
||||||
|
import { useRoleStates } from "../hooks/useRoleQueryStates";
|
||||||
|
import { DeleteRoleModal } from "./DeleteRoleModal";
|
||||||
|
import { RoleSheet } from "./RoleSheet";
|
||||||
|
|
||||||
|
type Role = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
teamId?: number;
|
||||||
|
color?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
type: "SYSTEM" | "CUSTOM";
|
||||||
|
permissions: {
|
||||||
|
id: string;
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Roles = Role[];
|
||||||
|
|
||||||
|
type Permissions = {
|
||||||
|
canCreate: boolean;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canRead: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RolesListProps {
|
||||||
|
roles: Roles;
|
||||||
|
permissions: Permissions;
|
||||||
|
initialSelectedRole?: Role;
|
||||||
|
initialSheetOpen?: boolean;
|
||||||
|
teamId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RolesList({
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
initialSelectedRole,
|
||||||
|
initialSheetOpen,
|
||||||
|
teamId,
|
||||||
|
}: RolesListProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
|
||||||
|
initialSheetOpen,
|
||||||
|
initialSelectedRole?.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedRole = roles.find((role) => role.id === selectedRoleId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
|
||||||
|
{/* Roles list header */}
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
|
||||||
|
</div>
|
||||||
|
{/* Role List Items */}
|
||||||
|
<div className="bg-default border-subtle divide-subtle flex flex-col divide-y rounded-[10px] border">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<RoleItem
|
||||||
|
role={role}
|
||||||
|
key={role.id}
|
||||||
|
onClick={() => {
|
||||||
|
// Cant edit system roles
|
||||||
|
if (role.type === "SYSTEM") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.canUpdate) {
|
||||||
|
setSelectedRoleId(role.id);
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
canUpdate={permissions.canUpdate}
|
||||||
|
permissions={permissions}
|
||||||
|
teamId={teamId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-4 ">
|
||||||
|
{/* Commenting out for now as we don't have a learn more permissions page in docs */}
|
||||||
|
{/* <p className="text-subtle text-sm font-medium leading-tight">{t("learn_more_permissions")}</p> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RoleSheet
|
||||||
|
role={selectedRole}
|
||||||
|
open={isOpen ?? false}
|
||||||
|
onOpenChange={handleSheetOpenChange}
|
||||||
|
teamId={teamId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleItem({
|
||||||
|
role,
|
||||||
|
onClick,
|
||||||
|
canUpdate,
|
||||||
|
permissions,
|
||||||
|
teamId,
|
||||||
|
}: {
|
||||||
|
role: Role;
|
||||||
|
onClick: () => void;
|
||||||
|
canUpdate: boolean;
|
||||||
|
permissions: Permissions;
|
||||||
|
teamId: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const showDropdown = role.type !== "SYSTEM" && (permissions.canUpdate || permissions.canDelete);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"border-subtle flex p-3",
|
||||||
|
canUpdate && role.type !== "SYSTEM" && "hover:bg-subtle cursor-pointer"
|
||||||
|
)}
|
||||||
|
onClick={onClick}>
|
||||||
|
<div className="flex w-full items-center gap-3 truncate">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: role.color ?? "gray" }} />
|
||||||
|
</div>
|
||||||
|
{/* Role Name */}
|
||||||
|
<div className="text-default w-24 truncate text-sm font-semibold leading-none">
|
||||||
|
<span>{role.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant={role.type === "SYSTEM" ? "grayWithoutHover" : "green"}>
|
||||||
|
{t(role.type === "SYSTEM" ? "default_role" : "custom_role")}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<div className="ml-auto" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="icon" color="secondary" StartIcon="ellipsis" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="flex flex-col">
|
||||||
|
{permissions.canUpdate && (
|
||||||
|
<DropdownItem StartIcon="pencil" onClick={onClick}>
|
||||||
|
{t("edit")}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{permissions.canDelete && role.type !== "SYSTEM" && (
|
||||||
|
<DeleteRoleModal roleId={role.id} roleName={role.name} teamId={teamId} />
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||||
|
|
||||||
|
export const SkeletonLoader = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
|
||||||
|
{/* Roles list header */}
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
|
||||||
|
</div>
|
||||||
|
{/* Role List Items */}
|
||||||
|
<div className="bg-default border-subtle divide divide-subtle flex flex-col rounded-[10px] border">
|
||||||
|
{/* Generate 3 skeleton items */}
|
||||||
|
{[...Array(3)].map((_, index) => (
|
||||||
|
<div key={index} className="border-subtle flex border-b p-3">
|
||||||
|
<div className="flex items-center gap-3 truncate">
|
||||||
|
{/* Icon skeleton */}
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="h-3 w-3 animate-pulse rounded-full bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
{/* Role name skeleton */}
|
||||||
|
<SkeletonText className="h-4 w-20" />
|
||||||
|
{/* Badge skeleton */}
|
||||||
|
<div className="h-5 w-24 animate-pulse rounded-full bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 ">
|
||||||
|
<p className="text-subtle text-sm font-medium leading-tight">{t("learn_more_permissions")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
|
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||||
|
|
||||||
|
import type { PermissionLevel } from "./usePermissions";
|
||||||
|
import { usePermissions } from "./usePermissions";
|
||||||
|
|
||||||
|
interface SimplePermissionItemProps {
|
||||||
|
resource: string;
|
||||||
|
permissions: string[];
|
||||||
|
onChange: (permissions: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimplePermissionItem({ resource, permissions, onChange }: SimplePermissionItemProps) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions();
|
||||||
|
|
||||||
|
const isAllResources = resource === "*";
|
||||||
|
const options = isAllResources
|
||||||
|
? [
|
||||||
|
{ value: "none", label: t("none") },
|
||||||
|
{ value: "all", label: t("all") },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: "none", label: t("none") },
|
||||||
|
{ value: "read", label: t("read_only") },
|
||||||
|
{ value: "all", label: t("all") },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<span className="text-default text-sm font-medium leading-none">
|
||||||
|
{t(PERMISSION_REGISTRY[resource as Resource]._resource?.i18nKey || resource)}
|
||||||
|
</span>
|
||||||
|
<ToggleGroup
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val) onChange(toggleResourcePermissionLevel(resource, val as PermissionLevel, permissions));
|
||||||
|
}}
|
||||||
|
value={getResourcePermissionLevel(resource, permissions)}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import { createSearchParamsCache, parseAsBoolean, parseAsString } from "nuqs/server";
|
||||||
|
|
||||||
|
export const roleParsers = {
|
||||||
|
// Sheet visibility state
|
||||||
|
"role-sheet": parseAsBoolean.withDefault(false),
|
||||||
|
// Selected role ID for editing
|
||||||
|
role: parseAsString.withDefault(""),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roleSearchParamsCache = createSearchParamsCache(roleParsers);
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
import { CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
|
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
|
|
||||||
|
export type PermissionLevel = "none" | "read" | "all";
|
||||||
|
|
||||||
|
interface UsePermissionsReturn {
|
||||||
|
hasAllPermissions: (permissions: string[]) => boolean;
|
||||||
|
getResourcePermissionLevel: (resource: string, permissions: string[]) => PermissionLevel;
|
||||||
|
toggleResourcePermissionLevel: (
|
||||||
|
resource: string,
|
||||||
|
level: PermissionLevel,
|
||||||
|
currentPermissions: string[]
|
||||||
|
) => string[];
|
||||||
|
toggleSinglePermission: (permission: string, enabled: boolean, currentPermissions: string[]) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions(): UsePermissionsReturn {
|
||||||
|
const getAllPossiblePermissions = () => {
|
||||||
|
const permissions: string[] = [];
|
||||||
|
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
|
||||||
|
if (resource !== "*") {
|
||||||
|
Object.keys(config).forEach((action) => {
|
||||||
|
permissions.push(`${resource}.${action}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return permissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAllPermissions = (permissions: string[]) => {
|
||||||
|
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
|
||||||
|
if (resource === "*") return true;
|
||||||
|
return Object.keys(config).every((action) => permissions.includes(`${resource}.${action}`));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResourcePermissionLevel = (resource: string, permissions: string[]): PermissionLevel => {
|
||||||
|
if (resource === "*") {
|
||||||
|
return permissions.includes("*.*") ? "all" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
|
||||||
|
if (!resourceConfig) return "none";
|
||||||
|
|
||||||
|
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
|
||||||
|
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
|
||||||
|
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
|
||||||
|
|
||||||
|
if (hasAllPerms) return "all";
|
||||||
|
if (hasReadPerm) return "read";
|
||||||
|
return "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleResourcePermissionLevel = (
|
||||||
|
resource: string,
|
||||||
|
level: PermissionLevel,
|
||||||
|
currentPermissions: string[]
|
||||||
|
): string[] => {
|
||||||
|
// First, remove *.* since we're modifying permissions
|
||||||
|
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
|
||||||
|
|
||||||
|
if (resource === "*") {
|
||||||
|
if (level === "all") {
|
||||||
|
// When selecting all resources, add *.* and all individual permissions
|
||||||
|
const allPossiblePerms = getAllPossiblePermissions();
|
||||||
|
newPermissions = ["*.*", ...allPossiblePerms];
|
||||||
|
}
|
||||||
|
// When deselecting all resources, we've already removed *.* above
|
||||||
|
} else {
|
||||||
|
// Filter out current resource permissions
|
||||||
|
newPermissions = newPermissions.filter((p) => !p.startsWith(`${resource}.`));
|
||||||
|
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
|
||||||
|
|
||||||
|
if (!resourceConfig) return currentPermissions;
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "none":
|
||||||
|
// No permissions to add, just keep other permissions
|
||||||
|
break;
|
||||||
|
case "read":
|
||||||
|
// Only add read permission
|
||||||
|
newPermissions.push(`${resource}.${CrudAction.Read}`);
|
||||||
|
break;
|
||||||
|
case "all":
|
||||||
|
// Add all permissions for this resource
|
||||||
|
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
|
||||||
|
newPermissions.push(...allResourcePerms);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add *.* back if all permissions are now selected
|
||||||
|
if (hasAllPermissions(newPermissions)) {
|
||||||
|
newPermissions.push("*.*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPermissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSinglePermission = (
|
||||||
|
permission: string,
|
||||||
|
enabled: boolean,
|
||||||
|
currentPermissions: string[]
|
||||||
|
): string[] => {
|
||||||
|
// First, remove *.* since we're modifying individual permissions
|
||||||
|
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
|
||||||
|
|
||||||
|
// Parse the permission to get resource and action
|
||||||
|
const [resource, action] = permission.split(".");
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// Add the requested permission
|
||||||
|
newPermissions.push(permission);
|
||||||
|
|
||||||
|
// If enabling create, update, or delete, automatically enable read permission
|
||||||
|
if (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) {
|
||||||
|
const readPermission = `${resource}.${CrudAction.Read}`;
|
||||||
|
if (!newPermissions.includes(readPermission)) {
|
||||||
|
newPermissions.push(readPermission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// When disabling a permission, check if we need to disable related permissions
|
||||||
|
if (action === CrudAction.Read) {
|
||||||
|
// If disabling read, also disable create, update, and delete since they depend on read
|
||||||
|
const dependentActions = [CrudAction.Create, CrudAction.Update, CrudAction.Delete];
|
||||||
|
dependentActions.forEach((dependentAction) => {
|
||||||
|
const dependentPermission = `${resource}.${dependentAction}`;
|
||||||
|
newPermissions = newPermissions.filter((p) => p !== dependentPermission);
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
action === CrudAction.Create ||
|
||||||
|
action === CrudAction.Update ||
|
||||||
|
action === CrudAction.Delete
|
||||||
|
) {
|
||||||
|
// If disabling create, update, or delete, just remove that specific permission
|
||||||
|
// Read permission remains enabled
|
||||||
|
newPermissions = newPermissions.filter((p) => p !== permission);
|
||||||
|
} else {
|
||||||
|
// For other actions (custom actions), just remove the specific permission
|
||||||
|
newPermissions = newPermissions.filter((p) => p !== permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add *.* back if all permissions are now selected
|
||||||
|
if (hasAllPermissions(newPermissions)) {
|
||||||
|
newPermissions.push("*.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPermissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasAllPermissions,
|
||||||
|
getResourcePermissionLevel,
|
||||||
|
toggleResourcePermissionLevel,
|
||||||
|
toggleSinglePermission,
|
||||||
|
};
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function revalidateTeamRoles() {
|
||||||
|
revalidatePath("/settings/organizations/roles");
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user