Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eedef35d02 | ||
|
|
08d232a816 | ||
|
|
d9b3f8657f | ||
|
|
99dc5837f5 | ||
|
|
5a35540522 | ||
|
|
c01fc08f67 | ||
|
|
7397722eb3 | ||
|
|
d51e03b14b | ||
|
|
c4af180a5b | ||
|
|
ee5f1dc879 | ||
|
|
15b43176ff | ||
|
|
03240709dc | ||
|
|
7ee451ca02 | ||
|
|
55b066c475 | ||
|
|
c52bbc4be3 | ||
|
|
31b1f31d82 | ||
|
|
ab1c1bb015 | ||
|
|
45219f4736 | ||
|
|
3296255f80 | ||
|
|
81b4651d6e | ||
|
|
e3bd90c4f2 | ||
|
|
17f1932467 | ||
|
|
1b04fe82a7 | ||
|
|
ffb36509ff | ||
|
|
dc361f7879 | ||
|
|
0b59a5d184 | ||
|
|
54c17eb827 | ||
|
|
bd1142ea7a | ||
|
|
26051833cf | ||
|
|
1d8bc6ca49 | ||
|
|
fbe8cf8223 | ||
|
|
ef8b3e83ac | ||
|
|
9d228a1d96 | ||
|
|
57df25505d | ||
|
|
31cbea0e23 | ||
|
|
edd80c056c | ||
|
|
6ebf0db389 | ||
|
|
6f4e51b859 | ||
|
|
4f5d0db792 | ||
|
|
8733bcaf7a | ||
|
|
5aaca2b7aa | ||
|
|
eacd05bf3b | ||
|
|
d51e0182b5 | ||
|
|
220897ba86 | ||
|
|
78c0203a45 | ||
|
|
720fd90066 | ||
|
|
ca8ee749a2 | ||
|
|
ce2d3a4b00 | ||
|
|
2923684c7c | ||
|
|
879ae597be | ||
|
|
d21f7ccbc8 | ||
|
|
4cc1bc6746 | ||
|
|
49a2c60212 | ||
|
|
7dfbb42ced | ||
|
|
41fafb9e5e | ||
|
|
6815f2f9c7 | ||
|
|
91ab27e070 | ||
|
|
396ccb1cd2 | ||
|
|
4e8d5cebca | ||
|
|
80f2e61189 | ||
|
|
d844fd6ef7 | ||
|
|
513f54f4fe | ||
|
|
2e6d71f493 | ||
|
|
55488c39f4 | ||
|
|
c0e123a917 | ||
|
|
ec88f16f1f | ||
|
|
a957c15062 | ||
|
|
b32b31ccdc | ||
|
|
e29f6af540 | ||
|
|
c6cb3423fa | ||
|
|
45904c07bc | ||
|
|
0623887ff8 | ||
|
|
f31c45a153 | ||
|
|
f5217caef2 |
+5
-1
@@ -443,4 +443,8 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||
|
||||
DATABASE_CHUNK_SIZE=
|
||||
DATABASE_CHUNK_SIZE=
|
||||
|
||||
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
/**/package.json @calcom/Foundation
|
||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||
/apps/ui-playground/**/* @calcom/Consumer
|
||||
/apps/web/components/**/* @calcom/Consumer
|
||||
/apps/web/modules/**/* @calcom/Consumer
|
||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/**/layout.tsx @calcom/Foundation
|
||||
/apps/web/lib/**/* @calcom/Foundation
|
||||
/apps/web/middleware.ts @calcom/Foundation
|
||||
@@ -27,4 +27,4 @@
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||
/packages/ui/**/* @calcom/Consumer
|
||||
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
cleanup-report:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout GitHub Pages Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: calcom/test-results
|
||||
ref: gh-pages
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
- name: Set Git User
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Cleanup HTML reports on PR close/merge
|
||||
env:
|
||||
BRANCH_REPORTS_DIR: reports/${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
if [ -d "$BRANCH_REPORTS_DIR" ]; then
|
||||
rm -rf "$BRANCH_REPORTS_DIR"
|
||||
git add .
|
||||
git commit -m "workflow: remove all reports for branch ${{ github.event.pull_request.head.ref }} (PR closed/merged)"
|
||||
git push
|
||||
else
|
||||
echo "No reports found for branch ${{ github.event.pull_request.head.ref }}. Nothing to clean up."
|
||||
fi
|
||||
@@ -219,6 +219,17 @@ jobs:
|
||||
uses: ./.github/workflows/publish-report.yml
|
||||
secrets: inherit
|
||||
|
||||
cleanup-report:
|
||||
name: Cleanup HTML report
|
||||
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && (github.event.pull_request.merged == true || github.event.pull_request.state == 'closed') }}
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
needs: [changes, check-label, publish-report]
|
||||
uses: ./.github/workflows/cleanup-report.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs:
|
||||
[
|
||||
|
||||
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"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
Possible issue:
|
||||
- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set.
|
||||
|
||||
## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials
|
||||
- Delete Credentials when DelegationCredential is disabled
|
||||
|
||||
|
||||
TO Test
|
||||
- New members beyond the batch size are processed
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({
|
||||
currentCredentialId: number | null;
|
||||
}) {
|
||||
if (parsedBody.integration && parsedBody.externalId) {
|
||||
const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials);
|
||||
const calendarCredentials = getCalendarCredentialsWithoutDelegation(
|
||||
userCredentials.map((cred) => ({
|
||||
...cred,
|
||||
delegationCredentialId: null,
|
||||
}))
|
||||
);
|
||||
|
||||
const { connectedCalendars } = await getConnectedCalendars(
|
||||
calendarCredentials,
|
||||
|
||||
@@ -112,6 +112,8 @@ $ yarn run test:cov
|
||||
### Guards
|
||||
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
|
||||
2. In case a guard would return "false" for "canActivate" DO NOT cache the result in redis, because we don't want that someone is forbidden, updates whatever was the problem, and then has to wait for cache to expire. We only cache in redis guard results where "canAccess" is "true".
|
||||
3. If you use ApiAuthGuard but want that only specific auth method is allowed, for example, api key, then you also need to add `@ApiAuthGuardOnlyAllow(["API_KEY"])` under the `@UseGuards(ApiAuthGuard)`. Shortly, use `ApiAuthGuardOnlyAllow` to specify which auth methods are allowed by `ApiAuthGuard`. If `ApiAuthGuardOnlyAllow` is not used or nothing is passed to it or empty array it means that
|
||||
all auth methods are allowed.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.174",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.178",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
@@ -67,6 +68,7 @@ import { Module } from "@nestjs/common";
|
||||
SelectedCalendarsRepository,
|
||||
OrganizationsTeamsRepository,
|
||||
OrganizationsRepository,
|
||||
ErrorsBookingsService_2024_08_13,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
@@ -85,34 +86,19 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||
private readonly organizationsRepository: OrganizationsRepository,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput) {
|
||||
let bookingTeamEventType = false;
|
||||
try {
|
||||
const eventType = await this.getBookedEventType(body);
|
||||
if (eventType?.team) {
|
||||
bookingTeamEventType = true;
|
||||
}
|
||||
if (!eventType) {
|
||||
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
||||
);
|
||||
}
|
||||
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||
}
|
||||
|
||||
body.eventTypeId = eventType.id;
|
||||
@@ -138,18 +124,7 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
return await this.createRegularBooking(request, body, eventType);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||
} else if (error.message === "booking_time_out_of_bounds_error") {
|
||||
throw new BadRequestException(
|
||||
`The event type can't be booked at selected time ${body.start}. This could be because it's too soon (violating the minimum booking notice) or too far in the future (outside the event's scheduling window). Try fetching available slots first using the GET /v2/slots endpoint and then make a booking with "start" time equal to one of the available slots: https://cal.com/docs/api-reference/v2/slots`
|
||||
);
|
||||
} else if (error.message === "Attempting to book a meeting in the past.") {
|
||||
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
this.errorsBookingsService.handleBookingError(error, bookingTeamEventType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
|
||||
import { CreateBookingInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class ErrorsBookingsService_2024_08_13 {
|
||||
private readonly logger = new Logger("ErrorsBookingsService_2024_08_13");
|
||||
|
||||
handleEventTypeToBeBookedNotFound(body: CreateBookingInput): never {
|
||||
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
||||
);
|
||||
}
|
||||
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
|
||||
);
|
||||
}
|
||||
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||
}
|
||||
|
||||
handleBookingError(error: unknown, bookingTeamEventType: boolean): never {
|
||||
const hostsUnavaile = "One of the hosts either already has booking at this time or is not available";
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
if (bookingTeamEventType) {
|
||||
throw new BadRequestException(hostsUnavaile);
|
||||
}
|
||||
throw new BadRequestException("User either already has booking at this time or is not available");
|
||||
} else if (error.message === "booking_time_out_of_bounds_error") {
|
||||
throw new BadRequestException(
|
||||
`The event type can't be booked at the "start" time provided. This could be because it's too soon (violating the minimum booking notice) or too far in the future (outside the event's scheduling window). Try fetching available slots first using the GET /v2/slots endpoint and then make a booking with "start" time equal to one of the available slots: https://cal.com/docs/api-reference/v2/slots`
|
||||
);
|
||||
} else if (error.message === "Attempting to book a meeting in the past.") {
|
||||
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||
} else if (error.message === "hosts_unavailable_for_booking") {
|
||||
throw new BadRequestException(hostsUnavaile);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -51,7 +52,7 @@ import {
|
||||
APPLE_CALENDAR,
|
||||
CREDENTIAL_CALENDARS,
|
||||
} from "@calcom/platform-constants";
|
||||
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
|
||||
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/calendars",
|
||||
@@ -135,6 +136,7 @@ export class CalendarsController {
|
||||
name: "calendar",
|
||||
})
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN"])
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -173,7 +175,7 @@ export class CalendarsController {
|
||||
@Get("/:calendar/save")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save an oAuth calendar credentials" })
|
||||
@ApiOperation({ summary: "Save Google or Outlook calendar credentials" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@@ -221,11 +223,11 @@ export class CalendarsController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/:calendar/credentials")
|
||||
@ApiOperation({ summary: "Sync credentials" })
|
||||
@ApiOperation({ summary: "Save Apple calendar credentials" })
|
||||
async syncCredentials(
|
||||
@GetUser() user: User,
|
||||
@Param("calendar") calendar: string,
|
||||
@Body() body: { username: string; password: string }
|
||||
@Body() body: CreateCalendarCredentialsInput
|
||||
): Promise<{ status: string }> {
|
||||
const { username, password } = body;
|
||||
|
||||
|
||||
@@ -60,8 +60,9 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
|
||||
if (username.length <= 1 || password.length <= 1)
|
||||
if (!username || !password || username.length <= 1 || password.length <= 1) {
|
||||
throw new BadRequestException(`Username or password cannot be empty`);
|
||||
}
|
||||
|
||||
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
@@ -118,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
const dav = new CalendarService({
|
||||
|
||||
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
teamId: null,
|
||||
appId: ICS_CALENDAR,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
+8
@@ -490,6 +490,7 @@ describe("Event types Endpoints", () => {
|
||||
requiresBookerEmailVerification: false,
|
||||
hideCalendarNotes: false,
|
||||
hideCalendarEventDetails: false,
|
||||
hideOrganizerEmail: false,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -529,6 +530,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(createdEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -610,6 +612,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1009,6 +1012,7 @@ describe("Event types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -1073,6 +1077,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(updatedEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1093,6 +1098,7 @@ describe("Event types Endpoints", () => {
|
||||
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
|
||||
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
|
||||
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
|
||||
eventType.hideOrganizerEmail = updatedEventType.hideOrganizerEmail;
|
||||
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
|
||||
eventType.color = updatedEventType.color;
|
||||
eventType.bookingFields = updatedEventType.bookingFields;
|
||||
@@ -1163,6 +1169,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
@@ -1204,6 +1211,7 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
|
||||
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
|
||||
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
|
||||
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
|
||||
eventType.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
|
||||
+3
@@ -87,6 +87,7 @@ type Input = Pick<
|
||||
| "destinationCalendar"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -123,6 +124,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
seatsShowAttendees,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
@@ -197,6 +199,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
destinationCalendar,
|
||||
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
@@ -48,8 +46,6 @@ export class GcalController {
|
||||
|
||||
constructor(
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
|
||||
private readonly config: ConfigService,
|
||||
private readonly gcalService: GCalService,
|
||||
private readonly calendarsService: CalendarsService
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH";
|
||||
|
||||
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
|
||||
@@ -1,7 +1,20 @@
|
||||
import { ExecutionContext, Inject } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
import { ApiAuthGuardOnlyAllow } from "../../decorators/api-auth-guard-only-allow.decorator";
|
||||
|
||||
export class ApiAuthGuard extends AuthGuard("api-auth") {
|
||||
constructor() {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
getRequest(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const allowedMethods = this.reflector.get(ApiAuthGuardOnlyAllow, context.getHandler());
|
||||
request.allowedAuthMethods = allowedMethods;
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,14 @@ import { getToken } from "next-auth/jwt";
|
||||
|
||||
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator";
|
||||
|
||||
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
|
||||
export type ApiAuthGuardRequest = Request & {
|
||||
authMethod: AuthMethods;
|
||||
organizationId: number | null;
|
||||
user: ApiAuthGuardUser;
|
||||
allowedAuthMethods?: AllowedAuthMethod[];
|
||||
};
|
||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
||||
@@ -49,12 +52,19 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
const oAuthClientId = params.clientId || request.get(X_CAL_CLIENT_ID);
|
||||
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (oAuthClientId && oAuthClientSecret) {
|
||||
const allowedMethods = request.allowedAuthMethods;
|
||||
const noSpecificAuthExpected = !allowedMethods || !allowedMethods.length;
|
||||
const oAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("OAUTH_CLIENT_CREDENTIALS");
|
||||
const apiKeyAllowed = noSpecificAuthExpected || allowedMethods.includes("API_KEY");
|
||||
const accessTokenAllowed = noSpecificAuthExpected || allowedMethods.includes("ACCESS_TOKEN");
|
||||
const nextAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("NEXT_AUTH");
|
||||
|
||||
if (oAuthClientId && oAuthClientSecret && oAuthAllowed) {
|
||||
request.authMethod = AuthMethods["OAUTH_CLIENT"];
|
||||
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
|
||||
}
|
||||
|
||||
if (bearerToken) {
|
||||
if (bearerToken && (apiKeyAllowed || accessTokenAllowed)) {
|
||||
const requestOrigin = request.get("Origin");
|
||||
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? AuthMethods["API_KEY"]
|
||||
@@ -64,13 +74,18 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
|
||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||
|
||||
if (nextAuthToken) {
|
||||
if (nextAuthToken && nextAuthAllowed) {
|
||||
request.authMethod = AuthMethods["NEXT_AUTH"];
|
||||
return await this.authenticateNextAuth(nextAuthToken, request);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||
if (noAuthProvided) {
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||
}
|
||||
throw new UnauthorizedException(
|
||||
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${allowedMethods}`
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return this.error(err);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Delete,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
@@ -96,6 +97,19 @@ export class BillingController {
|
||||
};
|
||||
}
|
||||
|
||||
@Delete("/:organizationId/unsubscribe")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@MembershipRoles(["OWNER", "ADMIN"])
|
||||
async cancelTeamSubscriptionInStripe(
|
||||
@Param("organizationId") organizationId: number
|
||||
): Promise<ApiResponse> {
|
||||
await this.billingService.cancelTeamSubscription(organizationId);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/webhook")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async stripeWebhook(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { InjectQueue } from "@nestjs/bull";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
@@ -313,6 +314,28 @@ export class BillingService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelTeamSubscription(teamId: number) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
const customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
if (!customerId) {
|
||||
throw new NotFoundException("No customer id found for team in Stripe");
|
||||
}
|
||||
|
||||
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
|
||||
throw new NotFoundException("Team plan not found");
|
||||
}
|
||||
|
||||
try {
|
||||
await this.stripeService
|
||||
.getStripe()
|
||||
.subscriptions.cancel(teamWithBilling?.platformBilling?.subscriptionId);
|
||||
} catch (error) {
|
||||
this.logger.log(error, "error while cancelling team subscription in stripe");
|
||||
throw new BadRequestException("Failed to cancel team subscription");
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await this.billingQueue.close();
|
||||
|
||||
+4
@@ -340,6 +340,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -371,6 +372,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
@@ -1099,6 +1101,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -1130,6 +1133,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
|
||||
@@ -70,6 +70,7 @@ type Input = Pick<
|
||||
| "eventName"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
| "team"
|
||||
>;
|
||||
|
||||
|
||||
+10
@@ -10,6 +10,16 @@ export class CreateOrganizationInput extends RefreshApiKeyInput {
|
||||
@ApiProperty({ description: "Name of the organization", example: "CalTeam" })
|
||||
readonly name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description:
|
||||
"Organization slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||
example: "cal-tel",
|
||||
})
|
||||
readonly slug?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description: METADATA_DOCS,
|
||||
|
||||
+2
@@ -46,6 +46,7 @@ import {
|
||||
X_CAL_SECRET_KEY,
|
||||
} from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { ApiSuccessResponse, CreateOAuthClientInput } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@@ -176,6 +177,7 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
managedOrg = responseBody.data;
|
||||
expect(managedOrg?.id).toBeDefined();
|
||||
expect(managedOrg?.name).toEqual(body.name);
|
||||
expect(managedOrg?.slug).toEqual(slugify(body.name));
|
||||
expect(managedOrg?.metadata).toEqual(body.metadata);
|
||||
expect(managedOrg?.apiKey).toBeDefined();
|
||||
|
||||
|
||||
+6
@@ -16,6 +16,12 @@ export class ManagedOrganizationOutput {
|
||||
@ApiProperty()
|
||||
readonly name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly slug?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
example: { key: "value" },
|
||||
|
||||
+6
@@ -10,6 +10,8 @@ import { ManagedOrganizationsOutputService } from "@/modules/organizations/organ
|
||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class ManagedOrganizationsService {
|
||||
constructor(
|
||||
@@ -36,6 +38,10 @@ export class ManagedOrganizationsService {
|
||||
|
||||
const { apiKeyDaysValid, apiKeyNeverExpires, ...organizationData } = organizationInput;
|
||||
|
||||
if (!organizationData.slug) {
|
||||
organizationData.slug = slugify(organizationData.name);
|
||||
}
|
||||
|
||||
const organization = await this.managedOrganizationsRepository.createManagedOrganization(
|
||||
managerOrganizationId,
|
||||
{
|
||||
|
||||
+28
@@ -36,6 +36,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
let team2: Team;
|
||||
let teamCreatedViaApi: Team;
|
||||
let teamCreatedViaApi2: Team;
|
||||
let teamCreatedViaApi3: Team;
|
||||
|
||||
const userEmail = `organizations-teams-admin-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
@@ -245,11 +246,38 @@ describe("Organizations Team Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create the team of the org with automatically set slug", async () => {
|
||||
const teamName = `Organizations Teams Automatic Slug`;
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.send({
|
||||
name: teamName,
|
||||
bio: "This is our test team created via API",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
teamCreatedViaApi3 = responseBody.data;
|
||||
expect(teamCreatedViaApi3.name).toEqual(teamName);
|
||||
expect(teamCreatedViaApi3.slug).toEqual("organizations-teams-automatic-slug");
|
||||
expect(teamCreatedViaApi3.bio).toEqual("This is our test team created via API");
|
||||
expect(teamCreatedViaApi3.parentId).toEqual(org.id);
|
||||
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
|
||||
user.id,
|
||||
teamCreatedViaApi3.id
|
||||
);
|
||||
expect(membership?.role ?? "").toEqual("OWNER");
|
||||
expect(membership?.accepted).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
|
||||
await teamsRepositoryFixture.delete(teamCreatedViaApi3.id);
|
||||
await teamsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+10
@@ -5,6 +5,8 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsService {
|
||||
constructor(
|
||||
@@ -40,6 +42,10 @@ export class OrganizationsTeamsService {
|
||||
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
|
||||
const { autoAcceptCreator, ...rest } = data;
|
||||
|
||||
if (!rest.slug) {
|
||||
rest.slug = slugify(rest.name);
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, rest);
|
||||
|
||||
if (user.role !== "ADMIN") {
|
||||
@@ -56,6 +62,10 @@ export class OrganizationsTeamsService {
|
||||
) {
|
||||
const { autoAcceptCreator, ...rest } = data;
|
||||
|
||||
if (!rest.slug) {
|
||||
rest.slug = slugify(rest.name);
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamRepository.createPlatformOrgTeam(
|
||||
organizationId,
|
||||
oAuthClientId,
|
||||
|
||||
@@ -7,6 +7,9 @@ const ensureUserLevelWhere = {
|
||||
eventTypeId: null,
|
||||
};
|
||||
|
||||
export const NO_SELECTED_CALENDAR_FOUND = "No SelectedCalendar found.";
|
||||
export const MULTIPLE_SELECTED_CALENDARS_FOUND = "Multiple SelecteCalendars found. Skipping deletion";
|
||||
|
||||
@Injectable()
|
||||
export class SelectedCalendarsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -113,11 +116,11 @@ export class SelectedCalendarsRepository {
|
||||
|
||||
// Make the behaviour same as .delete which throws error if no record is found
|
||||
if (records.length === 0) {
|
||||
throw new Error("No SelectedCalendar found.");
|
||||
throw new Error(NO_SELECTED_CALENDAR_FOUND);
|
||||
}
|
||||
|
||||
if (records.length > 1) {
|
||||
throw new Error("Multiple SelecteCalendars found. Skipping deletion");
|
||||
throw new Error(MULTIPLE_SELECTED_CALENDARS_FOUND);
|
||||
}
|
||||
|
||||
return await this.dbWrite.prisma.selectedCalendar.delete({
|
||||
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
SelectedCalendarsInputDto,
|
||||
SelectedCalendarsQueryParamsInputDto,
|
||||
} from "@/modules/selected-calendars/inputs/selected-calendars.input";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import {
|
||||
MULTIPLE_SELECTED_CALENDARS_FOUND,
|
||||
NO_SELECTED_CALENDAR_FOUND,
|
||||
SelectedCalendarsRepository,
|
||||
} from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { SelectedCalendarRepository } from "@calcom/platform-libraries";
|
||||
|
||||
@@ -111,13 +115,23 @@ export class SelectedCalendarsService {
|
||||
}
|
||||
}
|
||||
|
||||
const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar(
|
||||
user.id,
|
||||
integration,
|
||||
externalId,
|
||||
delegationCredentialId
|
||||
);
|
||||
try {
|
||||
const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar(
|
||||
user.id,
|
||||
integration,
|
||||
externalId,
|
||||
delegationCredentialId
|
||||
);
|
||||
|
||||
return removedCalendarEntry;
|
||||
return removedCalendarEntry;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === NO_SELECTED_CALENDAR_FOUND) {
|
||||
throw new NotFoundException(NO_SELECTED_CALENDAR_FOUND);
|
||||
} else if (error.message === MULTIPLE_SELECTED_CALENDARS_FOUND) {
|
||||
throw new BadRequestException(MULTIPLE_SELECTED_CALENDARS_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export class SlotsController_2024_09_04 {
|
||||
summary: "Find out when is an event type ready to be booked.",
|
||||
description: `
|
||||
There are 4 ways to get available slots for event type of an individual user:
|
||||
|
||||
|
||||
1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
|
||||
@@ -72,7 +72,7 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
And 3 ways to get available slots for team event type:
|
||||
|
||||
1. By team event type id. Example '/v2/slots?teamEventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
@@ -109,9 +109,9 @@ export class SlotsController_2024_09_04 {
|
||||
name: "usernames",
|
||||
required: false,
|
||||
description: `The usernames for which available slots should be checked separated by a comma.
|
||||
|
||||
|
||||
Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.
|
||||
|
||||
|
||||
Must contain at least 2 usernames.`,
|
||||
example: "alice,bob",
|
||||
})
|
||||
@@ -154,9 +154,9 @@ export class SlotsController_2024_09_04 {
|
||||
required: true,
|
||||
description: `
|
||||
Time until which available slots should be checked.
|
||||
|
||||
|
||||
Must be in UTC timezone as ISO 8601 datestring.
|
||||
|
||||
|
||||
You can pass date without hours which defaults to end of day or specify hours:
|
||||
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",
|
||||
@@ -166,9 +166,9 @@ export class SlotsController_2024_09_04 {
|
||||
required: true,
|
||||
description: `
|
||||
Time starting from which available slots should be checked.
|
||||
|
||||
|
||||
Must be in UTC timezone as ISO 8601 datestring.
|
||||
|
||||
|
||||
You can pass date without hours which defaults to start of day or specify hours:
|
||||
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.`,
|
||||
example: "2050-09-05",
|
||||
@@ -300,6 +300,9 @@ export class SlotsController_2024_09_04 {
|
||||
}
|
||||
|
||||
@Delete("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Delete a reserved slot",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@DocsResponse({
|
||||
status: 200,
|
||||
|
||||
+2
@@ -312,6 +312,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
requiresBookerEmailVerification: true,
|
||||
hideCalendarNotes: true,
|
||||
hideCalendarEventDetails: true,
|
||||
hideOrganizerEmail: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
color: {
|
||||
darkThemeHex: "#292929",
|
||||
@@ -343,6 +344,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
|
||||
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { TeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
describe("Teams endpoint", () => {
|
||||
@@ -75,7 +76,7 @@ describe("Teams endpoint", () => {
|
||||
describe("User has membership in created team", () => {
|
||||
it("should create first team", async () => {
|
||||
const body: CreateTeamInput = {
|
||||
name: `teams-dog-${randomString()}`,
|
||||
name: `teams dog ${randomString()}`,
|
||||
metadata: {
|
||||
teamKey: "teamValue",
|
||||
},
|
||||
@@ -93,6 +94,7 @@ describe("Teams endpoint", () => {
|
||||
expect(responseData).toBeDefined();
|
||||
expect(responseData.id).toBeDefined();
|
||||
expect(responseData.name).toEqual(body.name);
|
||||
expect(responseData.slug).toEqual(slugify(body.name));
|
||||
expect(responseData.metadata).toEqual(body.metadata);
|
||||
team1 = responseData;
|
||||
});
|
||||
|
||||
@@ -11,7 +11,11 @@ export class CreateTeamInput {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" })
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Team slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||
example: "caltel",
|
||||
})
|
||||
readonly slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -6,6 +6,8 @@ import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { BadRequestException, Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
private isTeamBillingEnabled = this.configService.get("stripe.isTeamBillingEnabled");
|
||||
@@ -19,6 +21,9 @@ export class TeamsService {
|
||||
|
||||
async createTeam(input: CreateTeamInput, ownerId: number) {
|
||||
const { autoAcceptCreator, ...teamData } = input;
|
||||
if (!teamData.slug) {
|
||||
teamData.slug = slugify(teamData.name);
|
||||
}
|
||||
|
||||
const existingTeam = await this.teamsMembershipsRepository.findTeamMembershipsByNameAndUser(
|
||||
input.name,
|
||||
|
||||
@@ -7697,7 +7697,7 @@
|
||||
"/v2/calendars/{calendar}/save": {
|
||||
"get": {
|
||||
"operationId": "CalendarsController_save",
|
||||
"summary": "Save an oAuth calendar credentials",
|
||||
"summary": "Save Google or Outlook calendar credentials",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
@@ -7741,7 +7741,7 @@
|
||||
"/v2/calendars/{calendar}/credentials": {
|
||||
"post": {
|
||||
"operationId": "CalendarsController_syncCredentials",
|
||||
"summary": "Sync credentials",
|
||||
"summary": "Save Apple calendar credentials",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "calendar",
|
||||
@@ -7764,6 +7764,16 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateCalendarCredentialsInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
@@ -9942,7 +9952,7 @@
|
||||
"get": {
|
||||
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
|
||||
"summary": "Find out when is an event type ready to be booked.",
|
||||
"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?teamEventTypeId=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 - slotFormat: 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 ",
|
||||
"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 - slotFormat: 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 ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -9958,7 +9968,7 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"example": "2050-09-05",
|
||||
"schema": {}
|
||||
},
|
||||
@@ -9966,7 +9976,7 @@
|
||||
"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.",
|
||||
"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": {}
|
||||
},
|
||||
@@ -10014,7 +10024,7 @@
|
||||
"name": "usernames",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The usernames for which available slots should be checked separated by a comma.\n \n Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.\n \n Must contain at least 2 usernames.",
|
||||
"description": "The usernames for which available slots should be checked separated by a comma.\n\n Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.\n\n Must contain at least 2 usernames.",
|
||||
"example": "alice,bob",
|
||||
"schema": {}
|
||||
},
|
||||
@@ -10288,6 +10298,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "SlotsController_2024_09_04_deleteReservedSlot",
|
||||
"summary": "Delete a reserved slot",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -13337,6 +13348,7 @@
|
||||
},
|
||||
"enabledLayouts": {
|
||||
"type": "array",
|
||||
"description": "Array of valid layouts - month, week or column",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -13624,6 +13636,10 @@
|
||||
"description": "A valid URL where the booker will redirect to, once the booking is completed successfully",
|
||||
"example": "https://masterchief.com/argentina/flan/video/9129412"
|
||||
},
|
||||
"hideOrganizerEmail": {
|
||||
"type": "boolean",
|
||||
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
|
||||
},
|
||||
"locations": {
|
||||
"type": "array",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
||||
@@ -15270,6 +15286,10 @@
|
||||
"hideCalendarEventDetails": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideOrganizerEmail": {
|
||||
"type": "boolean",
|
||||
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
|
||||
},
|
||||
"ownerId": {
|
||||
"type": "number",
|
||||
"example": 10
|
||||
@@ -15604,6 +15624,10 @@
|
||||
"description": "A valid URL where the booker will redirect to, once the booking is completed successfully",
|
||||
"example": "https://masterchief.com/argentina/flan/video/9129412"
|
||||
},
|
||||
"hideOrganizerEmail": {
|
||||
"type": "boolean",
|
||||
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
|
||||
},
|
||||
"locations": {
|
||||
"type": "array",
|
||||
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
|
||||
@@ -16153,7 +16177,7 @@
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": "Team slug",
|
||||
"description": "Team slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||
"example": "caltel"
|
||||
},
|
||||
"logoUrl": {
|
||||
@@ -17408,6 +17432,10 @@
|
||||
"description": "A valid URL where the booker will redirect to, once the booking is completed successfully",
|
||||
"example": "https://masterchief.com/argentina/flan/video/9129412"
|
||||
},
|
||||
"hideOrganizerEmail": {
|
||||
"type": "boolean",
|
||||
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
|
||||
},
|
||||
"schedulingType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -17824,6 +17852,10 @@
|
||||
"hideCalendarEventDetails": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideOrganizerEmail": {
|
||||
"type": "boolean",
|
||||
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
|
||||
},
|
||||
"teamId": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -18255,6 +18287,10 @@
|
||||
"description": "A valid URL where the booker will redirect to, once the booking is completed successfully",
|
||||
"example": "https://masterchief.com/argentina/flan/video/9129412"
|
||||
},
|
||||
"hideOrganizerEmail": {
|
||||
"type": "boolean",
|
||||
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -19352,7 +19388,7 @@
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": "Team slug",
|
||||
"description": "Team slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||
"example": "caltel"
|
||||
},
|
||||
"logoUrl": {
|
||||
@@ -20176,6 +20212,21 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"CreateCalendarCredentialsInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"username",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"DeleteCalendarCredentialsInputBodyDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -20265,6 +20316,11 @@
|
||||
"description": "Name of the organization",
|
||||
"example": "CalTeam"
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": "Organization slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||
"example": "cal-tel"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here.\nMetadata must have at most 50 keys, each key up to 40 characters.\nValues can be strings (up to 500 characters), numbers, or booleans.",
|
||||
@@ -20287,6 +20343,9 @@
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"slug": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"example": {
|
||||
@@ -20333,6 +20392,9 @@
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"slug": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"example": {
|
||||
@@ -21103,10 +21165,20 @@
|
||||
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
|
||||
"example": "2024-08-13T09:00:00Z"
|
||||
},
|
||||
"lengthInMinutes": {
|
||||
"type": "number",
|
||||
"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."
|
||||
"attendee": {
|
||||
"description": "The attendee's details.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Attendee"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values for custom booking fields added by you.",
|
||||
"example": {
|
||||
"customField": "customValue"
|
||||
}
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
@@ -21133,14 +21205,6 @@
|
||||
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.",
|
||||
"example": "acme-corp"
|
||||
},
|
||||
"attendee": {
|
||||
"description": "The attendee's details.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Attendee"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guests": {
|
||||
"description": "An optional list of guest emails attending the event.",
|
||||
"example": [
|
||||
@@ -21194,12 +21258,10 @@
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
|
||||
"example": {
|
||||
"customField": "customValue"
|
||||
}
|
||||
"lengthInMinutes": {
|
||||
"type": "number",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -21215,10 +21277,20 @@
|
||||
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
|
||||
"example": "2024-08-13T09:00:00Z"
|
||||
},
|
||||
"lengthInMinutes": {
|
||||
"type": "number",
|
||||
"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."
|
||||
"attendee": {
|
||||
"description": "The attendee's details.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Attendee"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values for custom booking fields added by you.",
|
||||
"example": {
|
||||
"customField": "customValue"
|
||||
}
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
@@ -21245,14 +21317,6 @@
|
||||
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.",
|
||||
"example": "acme-corp"
|
||||
},
|
||||
"attendee": {
|
||||
"description": "The attendee's details.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Attendee"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guests": {
|
||||
"description": "An optional list of guest emails attending the event.",
|
||||
"example": [
|
||||
@@ -21306,12 +21370,10 @@
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
|
||||
"example": {
|
||||
"customField": "customValue"
|
||||
}
|
||||
"lengthInMinutes": {
|
||||
"type": "number",
|
||||
"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."
|
||||
},
|
||||
"instant": {
|
||||
"type": "boolean",
|
||||
@@ -21333,10 +21395,20 @@
|
||||
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
|
||||
"example": "2024-08-13T09:00:00Z"
|
||||
},
|
||||
"lengthInMinutes": {
|
||||
"type": "number",
|
||||
"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."
|
||||
"attendee": {
|
||||
"description": "The attendee's details.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Attendee"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values for custom booking fields added by you.",
|
||||
"example": {
|
||||
"customField": "customValue"
|
||||
}
|
||||
},
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
@@ -21363,14 +21435,6 @@
|
||||
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.",
|
||||
"example": "acme-corp"
|
||||
},
|
||||
"attendee": {
|
||||
"description": "The attendee's details.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Attendee"
|
||||
}
|
||||
]
|
||||
},
|
||||
"guests": {
|
||||
"description": "An optional list of guest emails attending the event.",
|
||||
"example": [
|
||||
@@ -21424,12 +21488,10 @@
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
|
||||
"example": {
|
||||
"customField": "customValue"
|
||||
}
|
||||
"lengthInMinutes": {
|
||||
"type": "number",
|
||||
"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."
|
||||
},
|
||||
"recurrenceCount": {
|
||||
"type": "number",
|
||||
|
||||
@@ -2,9 +2,9 @@ export default function Page() {
|
||||
return (
|
||||
<div className="font-cal -mt-16 flex min-h-screen w-screen items-center justify-center border pt-16 text-center ">
|
||||
<div>
|
||||
<h1 className="text-7xl font-bold uppercase leading-none tracking-tight md:text-8xl">
|
||||
<h1 className="text-7xl leading-none md:text-8xl">
|
||||
Open UI for absolutely
|
||||
<br /> Everyone
|
||||
<br /> everyone
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateAvailabilityList() {
|
||||
revalidatePath("/availability");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
return <Alert severity="error" title={t("something_went_wrong")} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import AvailabilityLoader from "app/(use-page-wrapper)/(main-nav)/availability/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <AvailabilityLoader />;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
// import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
// import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
// import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
// import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import AvailabilityPage, { AvailabilityCTA } from "~/availability/availability-view";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { AvailabilityList, AvailabilityCTA } from "~/availability/availability-view";
|
||||
|
||||
import { ShellMainAppDir } from "../ShellMainAppDir";
|
||||
|
||||
@@ -19,31 +21,43 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Page = async () => {
|
||||
// const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
// const userId = session?.user?.id;
|
||||
// const orgId = session?.user?.org?.id;
|
||||
// if (!userId || !orgId) {
|
||||
// notFound();
|
||||
// }
|
||||
const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
const searchParams = await _searchParams;
|
||||
const t = await getTranslate();
|
||||
|
||||
try {
|
||||
// const currentOrg = await OrganizationRepository.findCurrentOrg({
|
||||
// orgId,
|
||||
// userId,
|
||||
// });
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("availability")}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={<AvailabilityCTA />}>
|
||||
<AvailabilityPage />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
const [meCaller, availabilityCaller] = await Promise.all([
|
||||
createRouterCaller(meRouter),
|
||||
createRouterCaller(availabilityRouter),
|
||||
]);
|
||||
|
||||
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
|
||||
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
|
||||
const isOrgPrivate = organizationId
|
||||
? await OrganizationRepository.checkIfPrivate({
|
||||
orgId: organizationId,
|
||||
})
|
||||
: false;
|
||||
const canViewTeamAvailability = me.organization?.isOrgAdmin || !isOrgPrivate;
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("availability")}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={
|
||||
<AvailabilityCTA
|
||||
toggleGroupOptions={[
|
||||
{ value: "mine", label: t("my_availability") },
|
||||
...(canViewTeamAvailability ? [{ value: "team", label: t("team_availability") }] : []),
|
||||
]}
|
||||
/>
|
||||
}>
|
||||
{searchParams?.type === "team" && canViewTeamAvailability ? (
|
||||
<AvailabilitySliderTable userTimeFormat={me?.timeFormat ?? null} isOrg={!!organizationId} />
|
||||
) : (
|
||||
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} me={me} />
|
||||
)}
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
|
||||
import SkeletonLoader from "@calcom/features/availability/components/SkeletonLoader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { AvailabilityCTA } from "~/availability/availability-view";
|
||||
|
||||
export default function AvailabilityLoader() {
|
||||
const { t } = useLocale();
|
||||
const toggleGroupOptions = [
|
||||
{ value: "mine", label: t("my_availability") },
|
||||
{ value: "team", label: t("team_availability") },
|
||||
];
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("availability")}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={<AvailabilityCTA toggleGroupOptions={toggleGroupOptions} />}>
|
||||
<SkeletonLoader />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateEventTypesList() {
|
||||
revalidatePath("/event-types");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
return <Alert severity="error" title={t("something_went_wrong")} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EventTypesSkeleton } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <EventTypesSkeleton />;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -32,19 +33,27 @@ const Page = async ({ params, searchParams }: PageProps) => {
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
const caller = await createRouterCaller(eventTypesRouter);
|
||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||
const initialData = await caller.getUserEventGroups({ filters });
|
||||
|
||||
const [meCaller, eventTypesCaller] = await Promise.all([
|
||||
createRouterCaller(meRouter),
|
||||
createRouterCaller(eventTypesRouter),
|
||||
]);
|
||||
|
||||
const [me, userEventGroupsData] = await Promise.all([
|
||||
meCaller.get(),
|
||||
eventTypesCaller.getUserEventGroups({ filters }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("event_types_page_title")}
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
CTA={<EventTypesCTA initialData={initialData} filters={filters} />}>
|
||||
<EventTypes initialData={initialData} filters={filters} />
|
||||
CTA={<EventTypesCTA userEventGroupsData={userEventGroupsData} />}>
|
||||
<EventTypes userEventGroupsData={userEventGroupsData} user={me} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export const revalidate = 3600; // 1 hour in seconds
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
|
||||
import { EventTypesSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export function EventTypesSkeleton() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<ShellMainAppDir heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")}>
|
||||
<EventTypesSkeletonLoader />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
}
|
||||
+2
-11
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
export const TeamsCTA = () => {
|
||||
const { t } = useLocale();
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
return !user.organizationId || user.organization.isOrgAdmin ? (
|
||||
return (
|
||||
<Button
|
||||
data-testid="new-team-btn"
|
||||
variant="fab"
|
||||
@@ -18,11 +15,5 @@ export const TeamsCTA = () => {
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
function Teams() {
|
||||
return <TeamsListing />;
|
||||
}
|
||||
|
||||
export default Teams;
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateTeamsList() {
|
||||
revalidatePath("/teams");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TeamsListSkeleton } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <TeamsListSkeleton />;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import TeamsView, { TeamsCTA } from "~/teams/teams-view";
|
||||
import { ServerTeamsListing } from "./server-page";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -30,13 +30,11 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const { Main, CTA } = await ServerTeamsListing({ searchParams });
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
CTA={<TeamsCTA />}
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}>
|
||||
<TeamsView />
|
||||
<ShellMainAppDir CTA={CTA} heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
||||
{Main}
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { SearchParams } from "app/_types";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { TeamsCTA } from "./CTA";
|
||||
|
||||
export const ServerTeamsListing = async ({ searchParams }: { searchParams: SearchParams }) => {
|
||||
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
|
||||
|
||||
const [teamsCaller, meCaller] = await Promise.all([
|
||||
createRouterCaller(viewerTeamsRouter),
|
||||
createRouterCaller(meRouter),
|
||||
]);
|
||||
|
||||
let teamNameFromInvite,
|
||||
errorMsgFromInvite = null;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
teamNameFromInvite = await teamsCaller.inviteMemberByToken({
|
||||
token,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
});
|
||||
} catch (e) {
|
||||
errorMsgFromInvite = "Error while fetching teams";
|
||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const [user, teams] = await Promise.all([
|
||||
meCaller.get(),
|
||||
teamsCaller.list({
|
||||
includeOrgs: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
Main: (
|
||||
<TeamsListing
|
||||
teams={teams}
|
||||
user={user}
|
||||
teamNameFromInvite={teamNameFromInvite ?? null}
|
||||
errorMsgFromInvite={errorMsgFromInvite}
|
||||
/>
|
||||
),
|
||||
CTA: !user.organizationId || user.organization.isOrgAdmin ? <TeamsCTA /> : null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import { TeamsCTA } from "app/(use-page-wrapper)/(main-nav)/teams/CTA";
|
||||
|
||||
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export const TeamsListSkeleton = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={<TeamsCTA />}>
|
||||
<SkeletonLoaderTeamList />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppHomePageSkeleton } from "app/(use-page-wrapper)/apps/(homepage)/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <AppHomePageSkeleton />;
|
||||
}
|
||||
+32
-17
@@ -1,15 +1,28 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req } = context;
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import AppsPage from "~/apps/apps-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("app_store"),
|
||||
(t) => t("app_store_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/apps"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const req = buildLegacyRequest(await headers(), await cookies());
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
let appStore, userAdminTeamsIds: number[];
|
||||
if (session?.user?.id) {
|
||||
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
|
||||
@@ -30,18 +43,20 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
return c;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return {
|
||||
props: {
|
||||
categories: Object.entries(categories)
|
||||
.map(([name, count]): { name: AppCategories; count: number } => ({
|
||||
name: name as AppCategories,
|
||||
count,
|
||||
}))
|
||||
.sort(function (a, b) {
|
||||
return b.count - a.count;
|
||||
}),
|
||||
appStore,
|
||||
userAdminTeams: userAdminTeamsIds,
|
||||
},
|
||||
const props = {
|
||||
categories: Object.entries(categories)
|
||||
.map(([name, count]): { name: AppCategories; count: number } => ({
|
||||
name: name as AppCategories,
|
||||
count,
|
||||
}))
|
||||
.sort(function (a, b) {
|
||||
return b.count - a.count;
|
||||
}),
|
||||
appStore,
|
||||
userAdminTeams: userAdminTeamsIds,
|
||||
};
|
||||
|
||||
return <AppsPage {...props} isAdmin={session?.user?.role === "ADMIN"} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import AppsLayout from "@components/apps/layouts/AppsLayout";
|
||||
|
||||
export function AppHomePageSkeleton() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<AppsLayout
|
||||
isPublic
|
||||
heading={t("app_store")}
|
||||
subtitle={t("app_store_description")}
|
||||
headerClassName="sm:hidden lg:block hidden"
|
||||
isAdmin={false}>
|
||||
<SkeletonContainer>
|
||||
<div className="flex flex-col gap-y-8">
|
||||
{/* Featured Categories section */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonText className="h-6 w-40 font-semibold" />
|
||||
<div className="flex">
|
||||
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Left arrow */}
|
||||
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Right arrow */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
{/* Category cards - 5 items based on screenshot */}
|
||||
{Array(5)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={`category-${i}`}
|
||||
className="bg-subtle flex h-48 flex-col items-center justify-center rounded-md p-4">
|
||||
<div className="mb-3 h-14 w-14 rounded-full bg-gray-200" /> {/* Icon placeholder */}
|
||||
<SkeletonText className="h-5 w-24 text-center" /> {/* Category name */}
|
||||
<SkeletonText className="mt-1 h-4 w-16 text-center" /> {/* Apps count */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Most Popular section */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonText className="h-6 w-40 font-semibold" />
|
||||
<div className="flex">
|
||||
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Left arrow */}
|
||||
<SkeletonButton className="mx-1 h-8 w-8 rounded-md" /> {/* Right arrow */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{/* App cards - 3 items */}
|
||||
{Array(3)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div key={`app-${i}`} className="border-subtle flex flex-col rounded-md border p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3 h-10 w-10 rounded-md bg-gray-200" /> {/* App icon */}
|
||||
<div className="flex-1">
|
||||
<SkeletonText className="h-5 w-32" /> {/* App name */}
|
||||
<SkeletonText className="mt-1 h-4 w-24" /> {/* Developer name */}
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonText className="mt-3 h-4 w-full" /> {/* Description line 1 */}
|
||||
<SkeletonText className="mt-2 h-4 w-3/4" /> {/* Description line 2 */}
|
||||
<div className="mt-4 flex justify-between">
|
||||
<SkeletonButton className="h-9 w-24 rounded-md" /> {/* Details button */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All apps section */}
|
||||
<div className="mt-4">
|
||||
<SkeletonText className="h-6 w-32 font-semibold" />
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{/* App cards - 6 items */}
|
||||
{Array(6)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div key={`all-app-${i}`} className="border-subtle flex flex-col rounded-md border p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-3 h-10 w-10 rounded-md bg-gray-200" /> {/* App icon */}
|
||||
<div className="flex-1">
|
||||
<SkeletonText className="h-5 w-32" /> {/* App name */}
|
||||
<SkeletonText className="mt-1 h-4 w-24" /> {/* Developer name */}
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonText className="mt-3 h-4 w-full" /> {/* Description line 1 */}
|
||||
<SkeletonText className="mt-2 h-4 w-3/4" /> {/* Description line 2 */}
|
||||
<div className="mt-4 flex justify-between">
|
||||
<SkeletonButton className="h-9 w-24 rounded-md" /> {/* Details button */}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</AppsLayout>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,11 @@ import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import type { OnboardingPageProps } from "~/apps/installation/[[...step]]/step-view";
|
||||
import Page from "~/apps/installation/[[...step]]/step-view";
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
const { appMetadata } = await getData(legacyCtx);
|
||||
export const generateMetadata = async ({ params }: PageProps) => {
|
||||
const stepParam = (await params).step;
|
||||
const step = stepParam && Array.isArray(stepParam) ? stepParam.join("/") : "";
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
|
||||
(t) => t("app_installation"),
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getServerSideProps } from "@lib/apps/getServerSideProps";
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import AppsPage from "~/apps/apps-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => t("app_store"),
|
||||
(t) => t("app_store_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/apps"
|
||||
);
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
const props = await getData(context);
|
||||
return <AppsPage {...props} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -23,7 +23,6 @@ const normalizePages = (pages: string[] | string | undefined) => {
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
|
||||
const { mainPage } = normalizePages((await params).pages);
|
||||
return await _generateMetadata(
|
||||
// TODO: Need to show the actual form name instead of "Form"
|
||||
(t) => (mainPage === "routing-link" ? `Form | Cal.com Forms` : `${t("routing_forms")} | Cal.com Forms`),
|
||||
() => "",
|
||||
undefined,
|
||||
@@ -48,15 +47,16 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
</FormProvider>
|
||||
);
|
||||
|
||||
if (mainPage === "routing-link") {
|
||||
return <FinalComponent />;
|
||||
// TODO: we should really be using layout for this.
|
||||
if (mainPage === "forms") {
|
||||
return (
|
||||
<Shell withoutMain>
|
||||
<FinalComponent />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell withoutMain>
|
||||
<FinalComponent />
|
||||
</Shell>
|
||||
);
|
||||
return <FinalComponent />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateSchedulePage(scheduleId: number) {
|
||||
revalidatePath(`/availability/${scheduleId}`);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AvailabilityScheduleSkeleton } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <AvailabilityScheduleSkeleton />;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
import { travelSchedulesRouter } from "@calcom/trpc/server/routers/viewer/travelSchedules/_router";
|
||||
|
||||
import { AvailabilitySettingsWebWrapper } from "~/availability/[schedule]/schedule-view";
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -29,8 +33,25 @@ const Page = async ({ params }: PageProps) => {
|
||||
if (!parsed.success) {
|
||||
notFound();
|
||||
}
|
||||
const scheduleId = parsed.data.schedule;
|
||||
|
||||
return <AvailabilitySettingsWebWrapper />;
|
||||
const [availabilityCaller, travelSchedulesCaller] = await Promise.all([
|
||||
createRouterCaller(availabilityRouter),
|
||||
createRouterCaller(travelSchedulesRouter),
|
||||
]);
|
||||
|
||||
const [scheduleData, travelSchedulesData] = await Promise.all([
|
||||
availabilityCaller.schedule.get({ scheduleId }),
|
||||
travelSchedulesCaller.get(),
|
||||
]);
|
||||
|
||||
if (!scheduleData) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<AvailabilitySettingsWebWrapper scheduleData={scheduleData} travelSchedulesData={travelSchedulesData} />
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export function AvailabilityScheduleSkeleton() {
|
||||
return (
|
||||
<Shell withoutMain={true}>
|
||||
<SkeletonContainer>
|
||||
<div className="max-w-full">
|
||||
{/* Header section */}
|
||||
<div className="bg-default sticky top-0 z-10 mb-0 flex items-center py-2 md:mb-6 md:mt-0 lg:mb-9">
|
||||
{/* Back button */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-6 w-6 rounded-md ltr:mr-2 rtl:ml-2" /> {/* Back button */}
|
||||
</div>
|
||||
|
||||
{/* Title section */}
|
||||
<header className="flex w-full max-w-full items-center truncate">
|
||||
<div className="w-full truncate ltr:mr-4 rtl:ml-4 md:block">
|
||||
<SkeletonText className="font-cal max-w-28 sm:max-w-72 md:max-w-80 hidden h-6 truncate text-xl font-semibold tracking-wide md:block" />
|
||||
{/* Title */}
|
||||
<SkeletonText className="mt-1 hidden h-4 w-64 text-sm md:block" /> {/* Subtitle */}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="relative flex-shrink-0 md:relative md:bottom-auto md:right-auto">
|
||||
{/* Desktop buttons - Set to Default, Delete, and Save */}
|
||||
<div className="flex items-center justify-end">
|
||||
{/* Set to Default - desktop only */}
|
||||
<div className="hidden items-center rounded-md sm:flex">
|
||||
<SkeletonText className="h-4 w-28" /> {/* "Set to Default" text */}
|
||||
<div className="ml-2 h-5 w-11 rounded-full bg-gray-200" /> {/* Toggle */}
|
||||
<div className="mx-3 h-4 w-px bg-gray-200" /> {/* Divider */}
|
||||
</div>
|
||||
{/* Delete button */}
|
||||
<SkeletonButton className="mr-2 hidden h-9 w-9 rounded-md sm:inline-flex" />
|
||||
{/* Second divider */}
|
||||
<div className="mx-3 hidden h-4 w-px bg-gray-200 sm:inline" /> {/* Divider */}
|
||||
{/* Save button - updated to match screenshot */}
|
||||
<SkeletonButton className="h-9 w-20 rounded-md bg-gray-200" /> {/* Save button */}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="mt-4 w-full md:mt-0">
|
||||
<div className="flex flex-col sm:mx-0 xl:flex-row xl:space-x-6">
|
||||
<div className="flex-1 flex-row xl:mr-0 xl:w-[75%]">
|
||||
{/* Schedule container */}
|
||||
<div className="border-subtle mb-6 rounded-md border">
|
||||
<div className="flex flex-col gap-4 p-2 sm:p-4">
|
||||
{/* Days of the week */}
|
||||
{Array(7)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex w-full flex-col gap-4 last:mb-0 sm:flex-row sm:gap-6 sm:px-0">
|
||||
{/* Day toggle and name */}
|
||||
<div className="flex h-[36px] items-center justify-between sm:w-32">
|
||||
<div>
|
||||
<div className="text-default flex flex-row items-center space-x-2 rtl:space-x-reverse">
|
||||
<div className="mr-2 h-6 w-11 rounded-full bg-gray-200" /> {/* Toggle */}
|
||||
<SkeletonText className="h-5 w-16 text-sm" /> {/* Day name */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time inputs - conditional rendering based on day being active */}
|
||||
{i !== 0 &&
|
||||
i !== 6 && ( // Only show time inputs for Monday-Friday
|
||||
<div className="flex sm:gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-1 last:mb-0 sm:gap-2">
|
||||
<div className="flex flex-row gap-2 sm:gap-3">
|
||||
<SkeletonText className="h-9 w-[90px] rounded-md sm:w-[100px]" />{" "}
|
||||
{/* Time input */}
|
||||
<span className="text-default w-2 self-center"> - </span>
|
||||
<SkeletonText className="h-9 w-[90px] rounded-md sm:w-[100px]" />{" "}
|
||||
{/* Time input */}
|
||||
</div>
|
||||
<SkeletonText className="h-9 w-9 rounded-md" /> {/* Add button */}
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonText className="h-9 w-9 rounded-md" /> {/* Copy button */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date overrides section */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-5 w-32 font-medium" /> {/* Date overrides */}
|
||||
<SkeletonText className="ml-2 h-4 w-4 rounded-full" /> {/* Info icon */}
|
||||
</div>
|
||||
<SkeletonText className="mb-4 mt-1 h-4 w-full max-w-md text-sm" /> {/* Description */}
|
||||
<div className="space-y-2">
|
||||
<SkeletonButton className="h-9 w-36 rounded-md" /> {/* Add an override button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - only visible on desktop */}
|
||||
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1 xl:w-[25%]">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<SkeletonText className="mb-1 h-4 w-24 text-sm" /> {/* Timezone */}
|
||||
<SkeletonText className="mt-1 h-9 w-72 rounded-md" /> {/* Timezone selector */}
|
||||
</div>
|
||||
|
||||
<hr className="border-subtle my-6 mr-8" />
|
||||
|
||||
<div className="rounded-md">
|
||||
<SkeletonText className="h-5 w-64 text-sm" /> {/* Something doesn't look right? */}
|
||||
<div className="mt-3 flex">
|
||||
<SkeletonButton className="h-9 w-40 rounded-md" /> {/* Launch troubleshooter */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateEventTypeEditPage(id: number) {
|
||||
revalidatePath(`/event-types/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Shell>
|
||||
<Alert severity="error" title={t("something_went_wrong")} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EventTypeEditPageSkeleton } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <EventTypeEditPageSkeleton />;
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { PageProps as _PageProps } from "app/_types";
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
|
||||
import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/event-types/[type]/getServerSideProps";
|
||||
|
||||
import EventTypePageWrapper from "~/event-types/views/event-types-single-view";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const querySchema = z.object({
|
||||
type: z
|
||||
@@ -19,36 +20,34 @@ const querySchema = z.object({
|
||||
.transform((val) => Number(val)),
|
||||
});
|
||||
|
||||
export const generateMetadata = async ({ params }: _PageProps) => {
|
||||
const parsed = querySchema.safeParse(await params);
|
||||
if (!parsed.success) {
|
||||
return await _generateMetadata(
|
||||
(t) => `${t("event_type")}`,
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
"/event-types"
|
||||
);
|
||||
}
|
||||
|
||||
const data = await EventTypeRepository.findTitleById({
|
||||
id: parsed.data.type,
|
||||
});
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => (data?.title ? `${data.title} | ${t("event_type")}` : `${t("event_type")}`),
|
||||
(t) => `${t("event_type")}`,
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
`/event-types/${parsed.data.type}`
|
||||
"/event-types"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: _PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getServerSideProps(legacyCtx);
|
||||
const ServerPage = async ({ params }: PageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
return <EventTypePageWrapper {...props} />;
|
||||
const parsed = querySchema.safeParse(await params);
|
||||
if (!parsed.success) {
|
||||
throw new Error("Invalid Event Type id");
|
||||
}
|
||||
const eventTypeId = parsed.data.type;
|
||||
const caller = await createRouterCaller(eventTypesRouter);
|
||||
const data = await caller.get({ id: eventTypeId });
|
||||
if (!data?.eventType) {
|
||||
throw new Error("This event type does not exist");
|
||||
}
|
||||
|
||||
return <EventTypeWebWrapper data={data} id={eventTypeId} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import {
|
||||
SkeletonButton,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
SkeletonAvatar,
|
||||
} from "@calcom/ui/components/skeleton";
|
||||
|
||||
export const EventTypeEditPageSkeleton = () => {
|
||||
const backButtonSkeleton = (
|
||||
<div className="bg-default sticky top-0 z-10 mb-0 flex items-center py-2 md:mb-9 md:mt-0">
|
||||
<div className="rounded-md ltr:mr-2 rtl:ml-2">
|
||||
<SkeletonButton className="h-8 w-8 rounded-md" />
|
||||
</div>
|
||||
<div className="w-full truncate ltr:mr-4 rtl:ml-4 md:block">
|
||||
<SkeletonText className="h-8 w-48" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonButton className="h-9 w-9 rounded-md" />
|
||||
<SkeletonButton className="h-9 w-9 rounded-md" />
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonButton className="h-9 w-24 rounded-md" />
|
||||
<SkeletonButton className="h-9 w-24 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Shell subtitle={<SkeletonText className="hidden h-4 w-24" />} afterHeading={backButtonSkeleton}>
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-6">
|
||||
<div className="hidden xl:block">
|
||||
<div className="primary-navigation w-64 flex-shrink-0">
|
||||
<div className="flex flex-col space-y-1">
|
||||
{/* Tab navigation items */}
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group flex w-64 flex-row items-center rounded-md px-3 py-[10px] text-sm font-medium leading-none">
|
||||
<div className="mr-3 h-4 w-4">
|
||||
<SkeletonAvatar className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
<SkeletonText className="mt-1 h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
||||
<div className="bg-default border-subtle mt-4 rounded-md sm:mx-0 md:border md:p-6 xl:mt-0">
|
||||
<SkeletonContainer>
|
||||
<div className="mb-8">
|
||||
<SkeletonText className="h-8 w-full max-w-md" />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 space-y-6">
|
||||
{/* Form fields */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-4 w-40" />
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
<SkeletonButton className="ml-2 h-10 w-10 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-4 w-36" />
|
||||
<SkeletonText className="h-24 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-4 w-28" />
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
<SkeletonButton className="ml-2 h-10 w-10 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<SkeletonButton className="h-10 w-24 rounded-md" />
|
||||
<SkeletonButton className="h-10 w-24 rounded-md" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
return <Alert severity="error" title={t("something_went_wrong")} />;
|
||||
}
|
||||
+10
-17
@@ -1,6 +1,5 @@
|
||||
import { type Params } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
@@ -36,23 +35,17 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
|
||||
const Page = async ({ params }: { params: Params }) => {
|
||||
const input = orgIdSchema.safeParse(await params);
|
||||
|
||||
if (!input.success) notFound();
|
||||
if (!input.success) throw new Error("Invalid access");
|
||||
|
||||
try {
|
||||
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={`${t("editing_org")}: ${org.name}`}
|
||||
description={t("admin_orgs_edit_description")}>
|
||||
<LicenseRequired>
|
||||
<OrgForm org={org} />
|
||||
</LicenseRequired>
|
||||
</SettingsHeader>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<SettingsHeader title={`${t("editing_org")}: ${org.name}`} description={t("admin_orgs_edit_description")}>
|
||||
<LicenseRequired>
|
||||
<OrgForm org={org} />
|
||||
</LicenseRequired>
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
return <Alert severity="error" title={t("something_went_wrong")} />;
|
||||
}
|
||||
+10
-17
@@ -1,6 +1,5 @@
|
||||
import { type Params } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
@@ -36,24 +35,18 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
|
||||
const Page = async ({ params }: { params: Params }) => {
|
||||
const input = userIdSchema.safeParse(await params);
|
||||
|
||||
if (!input.success) {
|
||||
notFound();
|
||||
}
|
||||
if (!input.success) throw new Error("Invalid access");
|
||||
|
||||
try {
|
||||
const user = await UserRepository.adminFindById(input.data.id);
|
||||
const t = await getTranslate();
|
||||
const user = await UserRepository.adminFindById(input.data.id);
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
|
||||
<LicenseRequired>
|
||||
<UsersEditView user={user} />
|
||||
</LicenseRequired>
|
||||
</SettingsHeader>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
return (
|
||||
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
|
||||
<LicenseRequired>
|
||||
<UsersEditView user={user} />
|
||||
</LicenseRequired>
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
+11
-33
@@ -16,7 +16,6 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { IdentityProvider, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -255,7 +254,7 @@ const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnab
|
||||
const BackButtonInSidebar = ({ name }: { name: string }) => {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
href="/event-types"
|
||||
className="hover:bg-subtle todesktop:mt-10 [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group my-6 flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2 text-sm font-medium leading-4 transition"
|
||||
data-testid={`vertical-tab-${name}`}>
|
||||
<Icon
|
||||
@@ -273,8 +272,6 @@ interface SettingsSidebarContainerProps {
|
||||
className?: string;
|
||||
navigationIsOpenedOnMobile?: boolean;
|
||||
bannersHeight?: number;
|
||||
currentOrg: SettingsLayoutProps["currentOrg"];
|
||||
otherTeams: SettingsLayoutProps["otherTeams"];
|
||||
}
|
||||
|
||||
const TeamListCollapsible = () => {
|
||||
@@ -441,10 +438,9 @@ const SettingsSidebarContainer = ({
|
||||
className = "",
|
||||
navigationIsOpenedOnMobile,
|
||||
bannersHeight,
|
||||
currentOrg: currentOrgProp,
|
||||
otherTeams: otherTeamsProp,
|
||||
}: SettingsSidebarContainerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
const { t } = useLocale();
|
||||
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
|
||||
{
|
||||
@@ -453,19 +449,17 @@ const SettingsSidebarContainer = ({
|
||||
}[]
|
||||
>();
|
||||
const session = useSession();
|
||||
const { data: _currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org && !currentOrgProp,
|
||||
const { data } = trpc.viewer.delegationCredential.check.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
});
|
||||
|
||||
const tabsWithPermissions = useTabs({
|
||||
isDelegationCredentialEnabled: !!_currentOrg?.features?.delegationCredential,
|
||||
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
|
||||
});
|
||||
|
||||
const { data: _otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org && !otherTeamsProp,
|
||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
});
|
||||
const currentOrg = currentOrgProp ?? _currentOrg;
|
||||
const otherTeams = otherTeamsProp ?? _otherTeams;
|
||||
|
||||
// Same as above but for otherTeams
|
||||
useEffect(() => {
|
||||
if (otherTeams) {
|
||||
@@ -485,8 +479,7 @@ const SettingsSidebarContainer = ({
|
||||
}
|
||||
}, [searchParams?.get("id"), otherTeams]);
|
||||
|
||||
const isOrgAdminOrOwner =
|
||||
currentOrg && currentOrg?.user?.role && ["OWNER", "ADMIN"].includes(currentOrg?.user?.role);
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgBranding?.role);
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -569,7 +562,7 @@ const SettingsSidebarContainer = ({
|
||||
</div>
|
||||
</Link>
|
||||
<TeamListCollapsible />
|
||||
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
|
||||
{(!orgBranding?.id || isOrgAdminOrOwner) && (
|
||||
<VerticalTabItem
|
||||
name={t("add_a_team")}
|
||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||
@@ -733,17 +726,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
||||
|
||||
export type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
currentOrg: Awaited<ReturnType<typeof OrganizationRepository.findCurrentOrg>> | null;
|
||||
otherTeams: Awaited<ReturnType<typeof OrganizationRepository.findTeamsInOrgIamNotPartOf>> | null;
|
||||
containerClassName?: string;
|
||||
} & ComponentProps<typeof Shell>;
|
||||
|
||||
export default function SettingsLayoutAppDirClient({
|
||||
children,
|
||||
currentOrg,
|
||||
otherTeams,
|
||||
...rest
|
||||
}: SettingsLayoutProps) {
|
||||
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const state = useState(false);
|
||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||
@@ -773,8 +759,6 @@ export default function SettingsLayoutAppDirClient({
|
||||
{...rest}
|
||||
SidebarContainer={
|
||||
<SidebarContainerElement
|
||||
currentOrg={currentOrg}
|
||||
otherTeams={otherTeams}
|
||||
sideContainerOpen={sideContainerOpen}
|
||||
setSideContainerOpen={setSideContainerOpen}
|
||||
/>
|
||||
@@ -798,8 +782,6 @@ const SidebarContainerElement = ({
|
||||
sideContainerOpen,
|
||||
bannersHeight,
|
||||
setSideContainerOpen,
|
||||
currentOrg,
|
||||
otherTeams,
|
||||
}: SidebarContainerElementProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
@@ -815,8 +797,6 @@ const SidebarContainerElement = ({
|
||||
<SettingsSidebarContainer
|
||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||
bannersHeight={bannersHeight}
|
||||
currentOrg={currentOrg}
|
||||
otherTeams={otherTeams}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -826,6 +806,4 @@ type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
currentOrg: SettingsLayoutProps["currentOrg"];
|
||||
otherTeams: SettingsLayoutProps["otherTeams"];
|
||||
};
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
|
||||
import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
|
||||
|
||||
type SettingsLayoutAppDirProps = Omit<SettingsLayoutProps, "currentOrg" | "otherTeams">;
|
||||
|
||||
export default async function SettingsLayoutAppDir(props: SettingsLayoutAppDirProps) {
|
||||
export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
const userId = session?.user?.id ?? -1;
|
||||
const orgId = session?.user?.org?.id ?? -1;
|
||||
let currentOrg = null;
|
||||
let otherTeams = null;
|
||||
|
||||
try {
|
||||
currentOrg = await OrganizationRepository.findCurrentOrg({ userId, orgId });
|
||||
} catch (err) {}
|
||||
|
||||
try {
|
||||
otherTeams = await OrganizationRepository.findTeamsInOrgIamNotPartOf({
|
||||
userId,
|
||||
parentId: orgId,
|
||||
});
|
||||
} catch (err) {}
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayoutAppDirClient {...props} currentOrg={currentOrg} otherTeams={otherTeams} />
|
||||
<SettingsLayoutAppDirClient {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# Delegation Credentials
|
||||
|
||||
## Setting up Delegation Credential for Google Calendar API
|
||||
|
||||
Step 1: Create a Google Cloud Project
|
||||
|
||||
Before you can create a service account, you'll need to set up a Google Cloud project.
|
||||
|
||||
1. Create a Google Cloud Project:
|
||||
- Go to the Google Cloud Console
|
||||
- Select Create Project
|
||||
- Give your project a name and select your billing account (if applicable)
|
||||
- Click Create
|
||||
2. Enable the Google Calendar API:
|
||||
1. Go to the Google Cloud Console
|
||||
2. Select API & Services → Library
|
||||
3. Search for "Google Calendar API"
|
||||
4. Click Enable
|
||||
|
||||
Step 2: Create a Service Account
|
||||
|
||||
A service account is needed to act on behalf of users
|
||||
|
||||
1. Navigate to the Service Accounts page:
|
||||
- In the Google Cloud Console, go to IAM & Admin → Service Accounts
|
||||
2. Create a New Service Account:
|
||||
- Click on Create Service Account
|
||||
- Give your service account a name and description
|
||||
- Click Create and Continue
|
||||
|
||||
Step 3: To Be taken by Cal.com instance admin:
|
||||
|
||||
- Create a Workspace Platform with slug="google". Slug has to be exactly this. This is how we know we need to use Google Calendar and Google Meet.
|
||||
|
||||
Last Step (To Be Taken By Cal.com organization Owner/Admin): Assign Specific API Permissions via OAuth Scopes:
|
||||
- Create Delegation Credential with workspace platform "google"
|
||||
- User must be a member of the Google Workspace to be able to enable Delegation Credential as there is a validation if the user's calendar can be accessed through the service account
|
||||
- Get the Client ID from there
|
||||
- Go to your Google Admin Console (admin-google-com)
|
||||
- Navigate to Security → Access and Data Controls -> API controls -> Manage Domain-Wide Delegation
|
||||
- Here, you'll authorize the Client ID(Unique ID) to access the Google Calendar API
|
||||
- Add the necessary API scopes for Google Calendar(Full access to Google Calendar)
|
||||
<https://www.googleapis.com/auth/calendar>
|
||||
|
||||
## Onboarding Improvements
|
||||
|
||||
- Just adding a member to the organization would do the following:
|
||||
- Member to receive events in their calendar, even if they don't login to their account and complete the onboarding process.
|
||||
- The booking location would be Google Meet, even if the user hasn't set it as default(Though Cal Video would show up as default, but we still use Google Meet in this case. We will fix it later.)
|
||||
- It would still not use their calendar for conflict checking, but user can complete the onboarding(just select one calendar there for conflict checking)
|
||||
- Onboarding process: Google Calendar is pre-installed for any new member of the organization(assuming the user has an email of the Delegation Credential domain) and Destination Calendar and Selected Calendar are configurable. On next step, Google Meet is pre-installed and shown at the top and could be set as default.
|
||||
|
||||
## Restrictions after enabling Delegation Credential
|
||||
|
||||
- Enabling Delegation Credential for a particular workspace in Cal.com(only google/outlook supported at the moment) disables the user from disconnecting that credential.
|
||||
|
||||
## Who can create Delegation Credential and enable it?
|
||||
|
||||
- Only the owner/admin of the organization can create Delegation Credential
|
||||
- Only the owner/admin of the organization can enable the created Delegation Credential. Following additional requirements are there:
|
||||
- The client ID must be added to the Google Admin Console
|
||||
- The user's email must be a member of the Google Workspace
|
||||
- The user's email must be verified.
|
||||
|
||||
## Disabling Delegation Credential
|
||||
|
||||
- Disabling Delegation **Credential** maintains the user's preferences in terms of SelectedCalendars and DestinationCalendar.
|
||||
|
||||
## Developer Notes
|
||||
|
||||
### Terminology
|
||||
|
||||
- Delegation Credential: A Delegation Credential service account key along with user's email becomes the Delegation Credential which is an alternative to regular Credential in DB.
|
||||
- DWD: Domain Wide Delegation
|
||||
- non-dwd credential: Regular credentials that are stored in Credentials table
|
||||
|
||||
### How Delegation Credential works
|
||||
|
||||
- We use the Cal.com user's email to impersonate that user using Delegation Credential(which is just a service account key at the moment)
|
||||
- That gives us read/write permission to get availability of the user and create new events in their calendar.
|
||||
|
||||
### What is a Delegation Credential?
|
||||
|
||||
- A Delegation Credential service account key along with user's email becomes the Delegation Credential which is an alternative to regular Credential in DB.
|
||||
- Delegation Credential doesn't completely replace the regular credentials. Delegation Credential gives access to the cal.com user's email in Google Calendar. So, if the user needs to connect to some other email's calendar, we need to use the regular credentials.
|
||||
|
||||
### Important Points
|
||||
|
||||
- No Credential table entry is created when enabling Delegation Credential. The workspace platform's related apps will be considered as "installed" for the users with email matching dwd domain. An in-memory credential like object is created for this purpose. It allows avoiding creation of thousands of records for all the members of the organization when Delegation Credential is enabled.
|
||||
- Delegation Credential is applicable to Users only.
|
||||
- For team, we don't use Delegation Credential as you can impersonate a user and not team through Delegation Credential. Currently supported apps(Google Calendar and Google Meet) don't support team installation, so we could simply allow enabling Delegation Credential without any issues.
|
||||
- Disabling a workspace platform stops it from being used for any new organizations and also disables any Delegation Credential using the workspace platform from being edited.
|
||||
- It still all existing Delegation Credentials to keep on working
|
||||
- Workspace default service account is unused and is to be removed.
|
||||
|
||||
### How apps/installed loads the credentials
|
||||
|
||||
1. Identify the logged in user's email
|
||||
2. Identify the domainWideDelegations for that email's domain
|
||||
3. Build in-memory credentials for the domainWideDelegations and use them along with the actual credentials(that user might have connected) of the user
|
||||
4. We don't show the non-dwd connected calendar(if there is a corresponding dwd connected calendar). Though we use the non-dwd credentials to identify the selected calendars, for the dwd connected calendar.
|
||||
|
||||
### Impact of disabling Delegation Credential
|
||||
|
||||
Disabling effectively stops generating in-memory delegation user credentials. So, any members who haven't manually connected their Calendar and thus their calendar connections were working only because of Delegation Credential, would have their connections broken.
|
||||
|
||||
#### What would not work correctly ?
|
||||
|
||||
- Calendar won't be checked for conflicts. So, they could get booked at a time when they are marked busy in their calendar.
|
||||
- Cal.com bookings would still be checked for conflicts.
|
||||
- Bookings might not appear in the attendee and host's Google Calendar. Because we would be unable to use the API to create the events in calendar and Google doesn't always add events to calendar automatically based on .ics file alone.
|
||||
- Cal Video would be used as the booking location instead of Google Meet.
|
||||
|
||||
#### What would work correctly ?
|
||||
|
||||
- Bookings would still go through. People relying on Salesforce for booking details, would face no issues.
|
||||
- Cal.com bookings would still be checked for conflicts.
|
||||
|
||||
### Notes when testing locally
|
||||
|
||||
- You need to enable the feature through feature flag.
|
||||
- You could use Acme org and login as <owner1-acme@example.com>
|
||||
- Make sure to change the email of the user above to your workspace owner's email(other member's email might also work). This is necessary otherwise you won't be able to enable Delegation Credential for the organization.
|
||||
- Note: After changing the email, you would have to logout and login again
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { SkeletonLoader } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("org_admin_other_teams"),
|
||||
(t) => t("org_admin_other_teams_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/teams/other"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
const organizationId = session?.user?.org?.id;
|
||||
const otherTeams = organizationId
|
||||
? await OrganizationRepository.findTeamsInOrgIamNotPartOf({
|
||||
userId: session?.user.id,
|
||||
parentId: organizationId,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")}>
|
||||
<OtherTeamsListing teams={otherTeams} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export function SkeletonLoader() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<SettingsHeader title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")}>
|
||||
<SkeletonLoaderTeamList />
|
||||
</SettingsHeader>
|
||||
);
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
|
||||
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/other-team-listing-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("org_admin_other_teams"),
|
||||
(t) => t("org_admin_other_teams_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/teams/other"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")}>
|
||||
<LegacyPage />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
|
||||
import LegacyPage from "@calcom/features/ee/teams/pages/team-listing-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("teams"),
|
||||
(t) => t("create_manage_teams_collaborative"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/teams"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("teams")} description={t("create_manage_teams_collaborative")}>
|
||||
<LegacyPage />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
|
||||
export default function Error() {
|
||||
const { t } = useLocale();
|
||||
return <Alert severity="error" title={t("something_went_wrong")} />;
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
// import { cookies, headers } from "next/headers";
|
||||
// import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
// import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import LegacyPage from "@calcom/features/ee/workflows/pages/workflow";
|
||||
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
||||
|
||||
const querySchema = z.object({
|
||||
workflow: z
|
||||
@@ -20,33 +15,11 @@ const querySchema = z.object({
|
||||
.transform((val) => Number(val)),
|
||||
});
|
||||
|
||||
const getWorkflow = cache((id: number) => WorkflowRepository.getById({ id }));
|
||||
|
||||
export const generateMetadata = async ({ params }: PageProps): Promise<Metadata | null> => {
|
||||
const parsed = querySchema.safeParse(await params);
|
||||
if (!parsed.success) {
|
||||
notFound();
|
||||
}
|
||||
const workflow = await getWorkflow(parsed.data.workflow);
|
||||
if (!workflow) {
|
||||
notFound();
|
||||
}
|
||||
return await _generateMetadata(
|
||||
(t) => (workflow && workflow.name ? workflow.name : t("untitled")),
|
||||
() => "",
|
||||
undefined,
|
||||
undefined,
|
||||
`/workflows/${parsed.data.workflow}`
|
||||
);
|
||||
};
|
||||
|
||||
const Page = async ({ params }: PageProps) => {
|
||||
// const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
// const user = session?.user;
|
||||
const parsed = querySchema.safeParse(await params);
|
||||
if (!parsed.success) {
|
||||
notFound();
|
||||
}
|
||||
if (!parsed.success) throw new Error("Invalid workflow id");
|
||||
|
||||
// const workflow = await WorkflowRepository.getById({ id: +parsed.data.workflow });
|
||||
// let verifiedEmails, verifiedNumbers;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("workflows"),
|
||||
(t) => t("workflows_to_automate_notifications")
|
||||
);
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
// import { cookies, headers } from "next/headers";
|
||||
// import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
// import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -8,16 +5,7 @@ import { _generateMetadata } from "app/_utils";
|
||||
// import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
||||
import LegacyPage from "@calcom/features/ee/workflows/pages/index";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("workflows"),
|
||||
(t) => t("workflows_to_automate_notifications"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/workflows"
|
||||
);
|
||||
|
||||
const Page = async ({ params, searchParams }: PageProps) => {
|
||||
const Page = async () => {
|
||||
// const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
// const user = session?.user;
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
|
||||
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["CreateCredentials"] });
|
||||
const batchSizeToCreateCredentials = 100;
|
||||
const validateRequest = (req: NextRequest) => {
|
||||
const url = new URL(req.url);
|
||||
const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey");
|
||||
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCreateCredentials() {
|
||||
const delegationCredentials = await DelegationCredentialRepository.findAllEnabledIncludeDelegatedMembers();
|
||||
|
||||
if (!delegationCredentials.length) {
|
||||
return {
|
||||
message: "No enabled delegation credentials found",
|
||||
success: 0,
|
||||
failures: 0,
|
||||
};
|
||||
}
|
||||
let totalSuccess = 0;
|
||||
let totalFailures = 0;
|
||||
for (const delegationCredential of delegationCredentials) {
|
||||
const { workspacePlatform, organization } = delegationCredential;
|
||||
|
||||
if (workspacePlatform.slug !== "google") {
|
||||
log.info(
|
||||
`Skipping credential creation for workspace platform ${workspacePlatform.slug} - only google is supported`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// We can't know by looking at a Credential record in DB, if it has access to the `user.email` calendar in Google Calendar.
|
||||
// It could be a credential of a personal calendar of the same user. So, we can't just reuse any existing credential for the user, we have to check if this specific delegationCredential's UserCredential exists or not
|
||||
const existingCredentials =
|
||||
await CredentialRepository.findAllDelegationByUserIdsListAndDelegationCredentialIdAndType({
|
||||
userIds: organization.delegatedMembers.map((member) => member.userId),
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
type: "google_calendar",
|
||||
});
|
||||
|
||||
const existingCredentialUserIds = new Set(existingCredentials.map((cred) => cred.userId));
|
||||
|
||||
const membersNeedingCredentials = organization.delegatedMembers.filter(
|
||||
(member) => !existingCredentialUserIds.has(member.userId)
|
||||
);
|
||||
|
||||
const toProcessMembers = membersNeedingCredentials.slice(0, batchSizeToCreateCredentials);
|
||||
|
||||
log.info(
|
||||
`Creating credentials for ${toProcessMembers.length} members in organization ${organization.id}`
|
||||
);
|
||||
|
||||
const credentialCreationPromises = toProcessMembers.map(async (member) => {
|
||||
try {
|
||||
await CredentialRepository.create({
|
||||
type: "google_calendar",
|
||||
key: {
|
||||
// The in-memory credential that we create for Delegation Credential has access_token as "NOOP_UNUSED_DELEGATION_TOKEN. This is to mark it is in DB, in case we need to identify that.
|
||||
access_token: "NOOP_UNUSED_DELEGATION_TOKEN_DB",
|
||||
},
|
||||
userId: member.userId,
|
||||
appId: "google-calendar",
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
});
|
||||
log.info(`Created credential for member ${member.userId}`);
|
||||
} catch (error) {
|
||||
log.error(`Error creating credential for member ${member.userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const results = await Promise.allSettled(credentialCreationPromises);
|
||||
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||
const failureCount = results.filter((r) => r.status === "rejected").length;
|
||||
|
||||
totalSuccess += successCount;
|
||||
totalFailures += failureCount;
|
||||
}
|
||||
|
||||
return {
|
||||
executedAt: new Date().toISOString(),
|
||||
success: totalSuccess,
|
||||
failures: totalFailures,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDeleteCredentials() {
|
||||
const delegationCredentials =
|
||||
await DelegationCredentialRepository.findAllDisabledAndIncludeNextBatchOfMembersToProcess();
|
||||
|
||||
if (!delegationCredentials.length) {
|
||||
return {
|
||||
message: "No disabled delegation credentials found",
|
||||
success: 0,
|
||||
failures: 0,
|
||||
};
|
||||
}
|
||||
let totalSuccess = 0;
|
||||
for (const delegationCredential of delegationCredentials) {
|
||||
const { workspacePlatform } = delegationCredential;
|
||||
|
||||
if (workspacePlatform.slug !== "google") {
|
||||
log.info(
|
||||
`Skipping credential deletion for workspace platform ${workspacePlatform.slug} - only google is supported`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// DB?: can deleting all credentials at once be problematic?
|
||||
const { count } = await CredentialRepository.deleteAllByDelegationCredentialId({
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
});
|
||||
totalSuccess += count;
|
||||
}
|
||||
log.info(`Deleted ${totalSuccess} credentials for disabled delegation credentials`);
|
||||
|
||||
return {
|
||||
executedAt: new Date().toISOString(),
|
||||
success: totalSuccess,
|
||||
failures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const handler = async (request: NextRequest) => {
|
||||
validateRequest(request);
|
||||
const result = await Promise.allSettled([handleCreateCredentials(), handleDeleteCredentials()]);
|
||||
const response = result.map((r, index) => {
|
||||
if (r.status === "fulfilled") {
|
||||
return {
|
||||
...r.value,
|
||||
message: `Successfully executed ${index === 0 ? "create" : "delete"} credentials`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...r.reason,
|
||||
message: `Failed to execute ${index === 0 ? "create" : "delete"} credentials`,
|
||||
};
|
||||
});
|
||||
return NextResponse.json(response);
|
||||
};
|
||||
|
||||
export const GET = defaultResponderForAppDir(handler);
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { findUniqueDelegationCalendarCredential } from "@calcom/lib/delegationCredential/server";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
const limitOnQueryingGoogleCalendar = 50;
|
||||
const log = logger.getSubLogger({ prefix: ["[api] selected-calendars/cron"] });
|
||||
const validateRequest = (req: NextRequest) => {
|
||||
const url = new URL(req.url);
|
||||
const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey");
|
||||
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
|
||||
}
|
||||
};
|
||||
|
||||
async function handleCreateSelectedCalendars() {
|
||||
// These are in DB delegation user credentials in contrast to in-memory delegation user credentials that are used elsewhere
|
||||
const allDelegationUserCredentials = await CredentialRepository.findAllDelegationByTypeIncludeUserAndTake({
|
||||
type: "google_calendar",
|
||||
take: limitOnQueryingGoogleCalendar,
|
||||
});
|
||||
|
||||
if (!allDelegationUserCredentials.length) {
|
||||
return {
|
||||
message: "No delegation credentials found",
|
||||
success: 0,
|
||||
failures: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Groups delegationUserCredentials by delegationCredentialId
|
||||
const groupedDelegationUserCredentials = allDelegationUserCredentials.reduce((acc, curr) => {
|
||||
acc[curr.delegationCredentialId] = [...(acc[curr.delegationCredentialId] || []), curr];
|
||||
return acc;
|
||||
}, {} as Record<string, typeof allDelegationUserCredentials>);
|
||||
|
||||
let totalSuccess = 0;
|
||||
let totalFailures = 0;
|
||||
|
||||
for (const [delegationCredentialId, delegationUserCredentials] of Object.entries(
|
||||
groupedDelegationUserCredentials
|
||||
)) {
|
||||
// First, create an array of promises that check if each credential should be processed
|
||||
const checkedDelegationUserCredentials = await Promise.all(
|
||||
delegationUserCredentials.map(async (delegationUserCredential) => {
|
||||
if (!delegationUserCredential.user) {
|
||||
log.error(`Credential ${delegationUserCredential.id} has no user`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all selectedCalendars for this user, regardless of credentialId as we want to reuse the existing SelectedCalendar(which has their googleChannel related column set) after Delegation Credential has been enabled
|
||||
const hasSelectedCalendarForUserEmail = !!(await SelectedCalendarRepository.findFirst({
|
||||
where: {
|
||||
userId: delegationUserCredential.user.id,
|
||||
externalId: delegationUserCredential.user.email,
|
||||
},
|
||||
}));
|
||||
|
||||
if (hasSelectedCalendarForUserEmail) {
|
||||
log.info(`Found a reusable SelectedCalendar for ${delegationUserCredential.user.id}`);
|
||||
return null;
|
||||
}
|
||||
return delegationUserCredential;
|
||||
})
|
||||
);
|
||||
|
||||
const delegationUserCredentialsToProcess = checkedDelegationUserCredentials.filter(
|
||||
(credential): credential is Ensure<(typeof delegationUserCredentials)[number], "user"> =>
|
||||
credential !== null
|
||||
);
|
||||
|
||||
log.info(
|
||||
`Found ${delegationUserCredentialsToProcess.length} delegationUserCredentials to process for delegationCredentialId: ${delegationCredentialId}`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
delegationUserCredentialsToProcess.map(async (delegationUserCredential) => {
|
||||
try {
|
||||
const credentialForCalendarService = await findUniqueDelegationCalendarCredential({
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
});
|
||||
|
||||
if (!credentialForCalendarService) {
|
||||
log.error(
|
||||
`Credential not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.user.id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!credentialForCalendarService.delegatedTo ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey ||
|
||||
!credentialForCalendarService.delegatedTo.serviceAccountKey.client_email
|
||||
) {
|
||||
log.error(
|
||||
`Invalid delegatedTo for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`,
|
||||
safeStringify({
|
||||
delegatedToSet: !!credentialForCalendarService.delegatedTo,
|
||||
serviceAccountKeySet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey,
|
||||
clientEmailSet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey?.client_email,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const googleCalendarService = new GoogleCalendarService(
|
||||
credentialForCalendarService as CredentialForCalendarServiceWithEmail
|
||||
);
|
||||
|
||||
let primaryCalendar;
|
||||
try {
|
||||
primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Error fetching primary calendar for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}`,
|
||||
safeStringify(error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!primaryCalendar || !primaryCalendar.id) {
|
||||
log.error(
|
||||
`Primary calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await SelectedCalendarRepository.create({
|
||||
integration: "google_calendar",
|
||||
externalId: primaryCalendar.id,
|
||||
userId: delegationUserCredential.user.id,
|
||||
delegationCredentialId: delegationUserCredential.delegationCredentialId,
|
||||
credentialId: delegationUserCredential.id,
|
||||
});
|
||||
|
||||
log.debug(`Created SelectedCalendar for user ${delegationUserCredential.userId}`);
|
||||
} catch (error) {
|
||||
log.error(`Error processing credential ${delegationUserCredential.id}:`, safeStringify(error));
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||
const failureCount = results.filter((r) => r.status === "rejected").length;
|
||||
|
||||
totalSuccess += successCount;
|
||||
totalFailures += failureCount;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Completed processing all selected calendars. Total Success: ${totalSuccess}, Total Failures: ${totalFailures}`
|
||||
);
|
||||
|
||||
return {
|
||||
message: "All selected calendars processed",
|
||||
executedAt: new Date().toISOString(),
|
||||
success: totalSuccess,
|
||||
failures: totalFailures,
|
||||
};
|
||||
}
|
||||
|
||||
const handler = async (request: NextRequest) => {
|
||||
validateRequest(request);
|
||||
return NextResponse.json(await handleCreateSelectedCalendars());
|
||||
};
|
||||
|
||||
export const GET = defaultResponderForAppDir(handler);
|
||||
@@ -52,10 +52,6 @@ const getDownloadLinkOfCalVideo = async (recordingId: string) => {
|
||||
};
|
||||
|
||||
export async function postHandler(request: NextRequest) {
|
||||
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
|
||||
return NextResponse.json({ message: "No SendGrid API key or email" }, { status: 405 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
if (testRequestSchema.safeParse(body).success) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ComponentProps } from "react";
|
||||
import React from "react";
|
||||
@@ -9,17 +8,15 @@ import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
|
||||
type AppsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
isAdmin: boolean;
|
||||
actions?: (className?: string) => JSX.Element;
|
||||
emptyStore?: boolean;
|
||||
} & Omit<ComponentProps<typeof Shell>, "actions">;
|
||||
|
||||
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
|
||||
export default function AppsLayout({ children, actions, emptyStore, isAdmin, ...rest }: AppsLayoutProps) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const isAdmin = session.data?.user.role === "ADMIN";
|
||||
|
||||
if (session.status === "loading") return <></>;
|
||||
return (
|
||||
<Shell {...rest} actions={actions?.("block")}>
|
||||
<div className="flex flex-col xl:flex-row">
|
||||
|
||||
@@ -195,6 +195,11 @@ describe("getUniqueIdentifierForBookingPage", () => {
|
||||
const result = getUniqueIdentifierForBookingPage({ pathname: path });
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("should return / for root path", () => {
|
||||
const result = getUniqueIdentifierForBookingPage({ pathname: "/" });
|
||||
expect(result).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Team Pages", () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
currency: true,
|
||||
bookingFields: true,
|
||||
allowReschedulingPastBookings: true,
|
||||
hideOrganizerEmail: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
disableGuests: true,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const getBooking = async (bookingId: number) => {
|
||||
teamId: true,
|
||||
parentId: true,
|
||||
canSendCalVideoTranscriptionEmails: true,
|
||||
customReplyToEmail: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
|
||||
@@ -34,6 +34,7 @@ export const getCalendarEvent = async (booking: getBookingResponse) => {
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
customReplyToEmail: booking.eventType?.customReplyToEmail,
|
||||
};
|
||||
|
||||
return Promise.resolve(evt);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
|
||||
export type PageProps = {
|
||||
type: number;
|
||||
data: RouterOutputs["viewer"]["eventTypes"]["get"];
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, query } = context;
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
const typeParam = parseInt(asStringOrThrow(query.type));
|
||||
|
||||
if (Number.isNaN(typeParam)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
const getEventTypeById = async (eventTypeId: number) => {
|
||||
const caller = await createRouterCaller(eventTypesRouter);
|
||||
|
||||
try {
|
||||
return await caller.get({ id: eventTypeId });
|
||||
} catch (e: unknown) {
|
||||
logger.error(safeStringify(e));
|
||||
// reject, user has no access to this event type.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const data = await getEventTypeById(typeParam);
|
||||
if (!data?.eventType) {
|
||||
return redirect("/event-types");
|
||||
}
|
||||
return {
|
||||
data,
|
||||
type: typeParam,
|
||||
};
|
||||
};
|
||||
@@ -16,6 +16,11 @@ const enum ThemeSupport {
|
||||
* The theme stays same for same identifier.
|
||||
*/
|
||||
export function getUniqueIdentifierForBookingPage({ pathname }: { pathname: string }) {
|
||||
if (pathname === "/") {
|
||||
// For Org domains, we could have a booking page at root path too, so we use '/' as identifier
|
||||
// As localStorage isn't shared for different org domains, it's safe to use '/' as identifier
|
||||
return "/";
|
||||
}
|
||||
const pathTokens = pathname.split("/").slice(1);
|
||||
|
||||
// If it is a booking page then it could be one of the following:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user