Compare commits

...
23 Commits
Author SHA1 Message Date
Lauris Skraucis 4cc1bc6746 fix: v2 sentry errors (#20902)
Post release / release (push) Canceled after 0s
* refactor: no_available_users_found_error for team event

* fix: hosts_unavailable_for_booking

* fix: Cannot read properties of undefined (reading 'replace')

* fix: Cannot read properties of undefined (reading 'phoneNumber')

* fix: No SelectedCalendar found.

* fix: Cannot read properties of undefined (reading 'length')

* refactor: add bookings errors service
2025-04-24 16:06:37 +03:00
GitHub Actions 49a2c60212 chore: release v5.2.6 2025-04-24 12:55:32 +00:00
Lauris Skraucis 7dfbb42ced docs: global platform event types and custom booking flows (#20940)
* docs: global event types

* docs: custom booking flow
2025-04-24 12:47:09 +00:00
Anik Dhabal Babu 41fafb9e5e fix: Include ordered attendees in booking select query (#20935)
* fix: correct ordering

* another
2025-04-24 12:33:20 +00:00
Benny Joo 6815f2f9c7 perf: server-side fetching for /availability (#20804)
* availability

* refactor

* add loader

* improve

* update

* fix loader

* rename

* satisfying types

* refactor

* remove log

* add CTA to skeleton

* refactor

* fix revalidations

* refactor

* update

* finish
2025-04-24 11:10:54 +02:00
Benny Joo 91ab27e070 feat: add loading component for /apps main page (#20924) 2025-04-24 10:48:03 +02:00
Lauris Skraucis 396ccb1cd2 refactor: v2 automatic team slugs (#20897)
* refactor: automatic team slugs

* refactor: automatic organizations team slugs

* refactor: automatic managed organizations slugs

* docs

* refactor: test
2025-04-24 10:00:01 +02:00
Benny Joo 4e8d5cebca perf: ssr for /settings/organizations/teams/other (#20927) 2025-04-24 09:42:09 +05:30
Benny Joo 80f2e61189 perf: remove next.redirect by going straight to event-types (#20920) 2025-04-23 20:23:50 +00:00
Benny Joo d844fd6ef7 perf: Slim down trpc queries in Settings Layout (#20918)
* slim down settings layout by removing server queries

* add check method

* fix type check

* refactor
2025-04-23 20:13:26 +00:00
Anik Dhabal Babu 513f54f4fe feat: ability to hide organizer email (#20782)
* feat: hide orgainzer email

* fix type error

* update

* update

* Update schema.prisma
2025-04-23 19:58:29 +00:00
Keith Williams 2e6d71f493 fix: Docs for deleting reserved slot (#20922) 2025-04-23 19:58:04 +00:00
GitHub Actions 55488c39f4 chore: release v5.2.5
Post release / release (push) Canceled after 0s
2025-04-23 17:22:16 +00:00
Joe Au-YeungandAnik Dhabal Babu c0e123a917 Pass customReplyTo as a managed event type prop (#20909)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-04-23 17:55:46 +01:00
Morgan ec88f16f1f fix: getBookings max 1 month in the past when no date filters specified (#20911)
* fix: getBookings max 1 month in the past

* bump platform libraries
2025-04-23 13:22:20 -03:00
Benny Joo a957c15062 perf: SSR - /availablilty/[schedule] (#20829)
* ssr for schedule page

* improve skeleton

* improve skeleton

* improve skeleton
2025-04-23 11:31:01 -03:00
Benny Joo b32b31ccdc perf: SSR for /teams (#20824)
* finish

* improve

* refactor

* add revalidations

* /settings/teams is not needed

* refactor

* rename

* redundant type casting
2025-04-23 15:09:25 +01:00
e29f6af540 feat(insights): add progress indicator to download button on /insights and /insights/routing (CAL-5408) (#20631)
* feat(insights): add progress indicator to routing form responses download button (CAL-5408)

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

* fix: address PR feedback - remove initial progress update and ensure no decimal parts in percentage

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

* feat(insights): add progress indicator to Download component

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

* feat: paginate rawData endpoint and implement batch fetching in Download component

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

* refactor: simplify pagination logic in trpc-router.ts

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

* show progress on tooltip

* fix download for /insights

* fix download when there is no data

* feat: replace tooltip progress indicator with toast notification

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

* fix: remove duplicate ProgressToast component from showToast.tsx

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

* fix: export ProgressToast component from index.ts

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

* fix: add missing translation for downloading_data

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

* update progress toast implementation

* fix error

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-04-23 15:59:09 +02:00
Anik Dhabal BabuandJoe Au-Yeung c6cb3423fa feat: custom reply To Email (#20771)
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2025-04-23 06:44:44 -07:00
Eunjae Lee 45904c07bc fix: eventType filter on /insights (#20823)
* fix: eventType filter on /insights

* remove log
2025-04-23 13:42:39 +00:00
Omar López 0623887ff8 refactor: Returns 400 error for ZodErrors (#20831) 2025-04-23 13:47:29 +01:00
Lauris Skraucis f31c45a153 fix: platform org members cannot access their public booking page (#20821)
* fix: platform member event pages

* chore: update slots docs

* refactor: user repository call

* refactor: remove newline
2025-04-23 14:29:26 +03:00
Vijay f5217caef2 fix: caching for sprite.svg (#20832)
* update cache-control header for sprite.svg

* updated regex
2025-04-23 11:18:04 +00:00
187 changed files with 2539 additions and 678 deletions
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+2
View File
@@ -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
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.174",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.176",
"@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,
@@ -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);
@@ -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,
@@ -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();
@@ -16,6 +16,12 @@ export class ManagedOrganizationOutput {
@ApiProperty()
readonly name!: string;
@IsString()
@IsOptional()
@Expose()
@ApiPropertyOptional()
readonly slug?: string;
@ApiPropertyOptional({
type: Object,
example: { key: "value" },
@@ -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,
{
@@ -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();
});
@@ -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,
@@ -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,
+100 -62
View File
@@ -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": [
@@ -16153,7 +16165,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": {
@@ -19352,7 +19364,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 +20188,21 @@
"data"
]
},
"CreateCalendarCredentialsInput": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": [
"username",
"password"
]
},
"DeleteCalendarCredentialsInputBodyDto": {
"type": "object",
"properties": {
@@ -20265,6 +20292,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 +20319,9 @@
"type": "string",
"minLength": 1
},
"slug": {
"type": "string"
},
"metadata": {
"type": "object",
"example": {
@@ -20333,6 +20368,9 @@
"type": "string",
"minLength": 1
},
"slug": {
"type": "string"
},
"metadata": {
"type": "object",
"example": {
@@ -21103,10 +21141,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 +21181,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 +21234,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 +21253,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 +21293,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 +21346,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 +21371,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 +21411,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 +21464,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",
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateAvailabilityList() {
revalidatePath("/availability");
}
@@ -0,0 +1,5 @@
import AvailabilityLoader from "app/(use-page-wrapper)/(main-nav)/availability/skeleton";
export default function Loading() {
return <AvailabilityLoader />;
}
@@ -1,11 +1,14 @@
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,29 +22,44 @@ 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();
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 />}>
<AvailabilityPage />
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>
);
} catch {
} catch (error) {
notFound();
}
};
@@ -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>
);
}
@@ -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 />;
}
@@ -22,8 +22,8 @@ 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} />;
};
@@ -0,0 +1,105 @@
"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">
<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>
);
}
@@ -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>
);
}
@@ -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} />
</>
);
}
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "./skeleton";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -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;
@@ -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>
);
}
@@ -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;
@@ -19,7 +19,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
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">
+1
View File
@@ -39,6 +39,7 @@ export const getEventTypesFromDB = async (id: number) => {
currency: true,
bookingFields: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
disableCancelling: true,
disableRescheduling: true,
disableGuests: true,
+1
View File
@@ -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);
+2
View File
@@ -21,6 +21,7 @@ export const POST_METHODS_ALLOWED_APP_ROUTES = [
"/settings/my-account/general",
"/settings/developer/webhooks",
"/settings/developer/api-keys",
"/teams",
];
export function checkPostMethod(req: NextRequest) {
const pathname = req.nextUrl.pathname;
@@ -194,6 +195,7 @@ export const config = {
"/settings/my-account/general",
"/settings/developer/webhooks",
"/settings/developer/api-keys",
"/teams",
],
};
@@ -1,51 +1,37 @@
"use client";
import { useRouter } from "next/navigation";
import { revalidateAvailabilityList } from "app/(use-page-wrapper)/(main-nav)/availability/actions";
import { revalidateSchedulePage } from "app/(use-page-wrapper)/availability/[schedule]/actions";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { AvailabilitySettings } from "@calcom/atoms/availability/AvailabilitySettings";
import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { withErrorFromUnknown } from "@calcom/lib/getClientErrorFromUnknown";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { ScheduleRepository } from "@calcom/lib/server/repository/schedule";
import type { TravelScheduleRepository } from "@calcom/lib/server/repository/travelSchedule";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { showToast } from "@calcom/ui/components/toast";
type PageProps = {
scheduleFetched?: Awaited<ReturnType<typeof ScheduleRepository.findDetailedScheduleById>>;
travelSchedules?: Awaited<ReturnType<typeof TravelScheduleRepository.findTravelSchedulesByUserId>>;
scheduleData: RouterOutputs["viewer"]["availability"]["schedule"]["get"];
travelSchedulesData: RouterOutputs["viewer"]["travelSchedules"]["get"];
};
export const AvailabilitySettingsWebWrapper = ({
scheduleFetched: scheduleProp,
travelSchedules: travelSchedulesProp,
scheduleData: schedule,
travelSchedulesData: travelSchedules,
}: PageProps) => {
const searchParams = useCompatSearchParams();
const searchParams = useSearchParams();
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useUtils();
const me = useMeQuery();
const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
const fromEventType = searchParams?.get("fromEventType");
const scheduleId = schedule.id;
const { timeFormat } = me.data || { timeFormat: null };
const { data: scheduleData, isPending: isFetchingPending } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId },
{
enabled: !!scheduleId && !scheduleProp,
}
);
const isPending = isFetchingPending && !scheduleProp;
const schedule = scheduleProp ?? scheduleData;
const { data: travelSchedulesData } = trpc.viewer.travelSchedules.get.useQuery(undefined, {
enabled: !travelSchedulesProp,
});
const travelSchedules = travelSchedulesProp ?? travelSchedulesData;
const [isBulkUpdateModalOpen, setIsBulkUpdateModalOpen] = useState(false);
const bulkUpdateDefaultAvailabilityMutation =
trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation();
@@ -57,11 +43,12 @@ export const AvailabilitySettingsWebWrapper = ({
bulkUpdateDefaultAvailabilityMutation.mutate(
{
eventTypeIds,
selectedDefaultScheduleId: scheduleData?.id,
selectedDefaultScheduleId: scheduleId,
},
{
onSuccess: () => {
utils.viewer.availability.list.invalidate();
revalidateAvailabilityList();
callback();
showToast(t("success"), "success");
},
@@ -86,7 +73,9 @@ export const AvailabilitySettingsWebWrapper = ({
}
}
utils.viewer.availability.schedule.get.invalidate({ scheduleId: data.schedule.id });
revalidateSchedulePage(scheduleId);
utils.viewer.availability.list.invalidate();
revalidateAvailabilityList();
showToast(
t("availability_updated_successfully", {
scheduleName: data.schedule.name,
@@ -111,24 +100,17 @@ export const AvailabilitySettingsWebWrapper = ({
},
onSuccess: () => {
showToast(t("schedule_deleted_successfully"), "success");
revalidateAvailabilityList();
router.push("/availability");
},
});
// TODO: reimplement Skeletons for this page in here
if (isPending) return null;
// We wait for the schedule to be loaded before rendering the form inside AvailabilitySettings
// since `defaultValues` cannot be redeclared after first render and using `values` will
// trigger a form reset when revalidating. Introducing flaky behavior.
if (!schedule) return null;
return (
<AvailabilitySettings
schedule={schedule}
travelSchedules={isDefaultSchedule ? travelSchedules || [] : []}
isDeleting={deleteMutation.isPending}
isLoading={isPending}
isLoading={false}
isSaving={updateMutation.isPending}
enableOverrides={true}
timeFormat={timeFormat}
@@ -1,33 +1,31 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { revalidateAvailabilityList } from "app/(use-page-wrapper)/(main-nav)/availability/actions";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { useCallback, useState } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import SkeletonLoader from "@calcom/features/availability/components/SkeletonLoader";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { ToggleGroup } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
export function AvailabilityList() {
type AvailabilityListProps = {
me: RouterOutputs["viewer"]["me"]["get"];
availabilities: RouterOutputs["viewer"]["availability"]["list"];
};
export function AvailabilityList({ availabilities, me }: AvailabilityListProps) {
const { t } = useLocale();
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const utils = trpc.useUtils();
const { data: availabilityData } = trpc.viewer.availability.list.useQuery();
const meQuery = trpc.viewer.me.get.useQuery();
const router = useRouter();
const deleteMutation = trpc.viewer.availability.schedule.delete.useMutation({
@@ -55,6 +53,7 @@ export function AvailabilityList() {
utils.viewer.availability.list.invalidate();
},
onSuccess: () => {
revalidateAvailabilityList();
showToast(t("schedule_deleted_successfully"), "success");
},
});
@@ -62,6 +61,7 @@ export function AvailabilityList() {
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
onSuccess: async ({ schedule }) => {
await utils.viewer.availability.list.invalidate();
revalidateAvailabilityList();
showToast(
t("availability_updated_successfully", {
scheduleName: schedule.name,
@@ -92,6 +92,7 @@ export function AvailabilityList() {
{
onSuccess: () => {
utils.viewer.availability.list.invalidate();
revalidateAvailabilityList();
showToast(t("success"), "success");
callback();
},
@@ -120,13 +121,9 @@ export function AvailabilityList() {
const [animationParentRef] = useAutoAnimate<HTMLUListElement>();
if (!availabilityData) {
return <SkeletonLoader />;
}
return (
<>
{availabilityData.schedules.length === 0 ? (
{availabilities.schedules.length === 0 ? (
<div className="flex justify-center">
<EmptyScreen
Icon="clock"
@@ -140,16 +137,16 @@ export function AvailabilityList() {
<>
<div className="border-subtle bg-default overflow-hidden rounded-md border">
<ul className="divide-subtle divide-y" data-testid="schedules" ref={animationParentRef}>
{availabilityData.schedules.map((schedule) => (
{availabilities.schedules.map((schedule) => (
<ScheduleListItem
displayOptions={{
hour12: meQuery.data?.timeFormat ? meQuery.data.timeFormat === 12 : undefined,
timeZone: meQuery.data?.timeZone,
weekStart: meQuery.data?.weekStart || "Sunday",
hour12: me?.timeFormat ? me.timeFormat === 12 : undefined,
timeZone: me?.timeZone,
weekStart: me?.weekStart || "Sunday",
}}
key={schedule.id}
schedule={schedule}
isDeletable={availabilityData.schedules.length !== 1}
isDeletable={availabilities.schedules.length !== 1}
updateDefault={updateMutation.mutate}
deleteFunction={deleteMutation.mutate}
duplicateFunction={duplicateMutation.mutate}
@@ -181,12 +178,13 @@ export function AvailabilityList() {
);
}
type PageProps = {
currentOrg?: Awaited<ReturnType<typeof OrganizationRepository.findCurrentOrg>> | null;
type AvailabilityCTAProps = {
toggleGroupOptions: {
value: string;
label: string;
}[];
};
export const AvailabilityCTA = () => {
const { t } = useLocale();
export const AvailabilityCTA = ({ toggleGroupOptions }: AvailabilityCTAProps) => {
const searchParams = useCompatSearchParams();
const router = useRouter();
const pathname = usePathname();
@@ -203,18 +201,6 @@ export const AvailabilityCTA = () => {
[searchParams]
);
const { data } = trpc.viewer.organizations.listCurrent.useQuery();
const isOrgAdminOrOwner = (data && checkAdminOrOwner(data.user.role)) ?? false;
const isOrgAndPrivate = data?.isOrganization && data.isPrivate;
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgAndPrivate;
const toggleGroupOptions = [{ value: "mine", label: t("my_availability") }];
if (canViewTeamAvailability) {
toggleGroupOptions.push({ value: "team", label: t("team_availability") });
}
return (
<div className="flex items-center gap-2">
<ToggleGroup
@@ -230,30 +216,3 @@ export const AvailabilityCTA = () => {
</div>
);
};
export default function AvailabilityPage({ currentOrg }: PageProps) {
const { t } = useLocale();
const searchParams = useCompatSearchParams();
const me = useMeQuery();
const { data: _data } = trpc.viewer.organizations.listCurrent.useQuery(undefined, { enabled: !currentOrg });
const data = currentOrg ?? _data;
const isOrg = Boolean(data);
const isOrgAdminOrOwner = (data && checkAdminOrOwner(data.user.role)) ?? false;
const isOrgAndPrivate = data?.isOrganization && data.isPrivate;
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgAndPrivate;
const toggleGroupOptions = [{ value: "mine", label: t("my_availability") }];
if (canViewTeamAvailability) {
toggleGroupOptions.push({ value: "team", label: t("team_availability") });
}
return searchParams?.get("type") === "team" && canViewTeamAvailability ? (
<AvailabilitySliderTable userTimeFormat={me?.data?.timeFormat ?? null} isOrg={isOrg} />
) : (
<AvailabilityList />
);
}
@@ -132,6 +132,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventTypeRaw.metadata),
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs),
hideOrganizerEmail: eventTypeRaw.hideOrganizerEmail,
bookingFields: eventTypeRaw.bookingFields.map((field) => {
return {
...field,
@@ -602,9 +602,11 @@ export default function Success(props: PageProps) {
</span>
<Badge variant="blue">{t("Host")}</Badge>
</div>
<p className="text-default">
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
</p>
{!bookingInfo.eventType?.hideOrganizerEmail && (
<p className="text-default">
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
</p>
)}
</div>
)}
{bookingInfo?.attendees.map((attendee) => (
+9 -2
View File
@@ -498,8 +498,15 @@ const nextConfig = {
headers: [CORP_CROSS_ORIGIN_HEADER],
},
{
source: "/icons/sprite.svg",
headers: [CORP_CROSS_ORIGIN_HEADER, ACCESS_CONTROL_ALLOW_ORIGIN_HEADER],
source: "/icons/sprite.svg(\\?v=[0-9a-zA-Z\\-\\.]+)?",
headers: [
CORP_CROSS_ORIGIN_HEADER,
ACCESS_CONTROL_ALLOW_ORIGIN_HEADER,
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
],
...(isOrganizationsEnabled
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.2.4",
"version": "5.2.6",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+11 -6
View File
@@ -1159,10 +1159,10 @@
"enabled_after_update": "Enabled after update",
"enabled_after_update_description": "The private link will work after saving",
"confirm_delete_api_key": "Revoke this API key",
"disable_rescheduling":"Disable Rescheduling",
"description_disable_rescheduling":"Guests can no longer reschedule the event with calendar invite or email",
"disable_cancelling":"Disable Cancelling",
"description_disable_cancelling":"Guests can no longer cancel the event with calendar invite or email",
"disable_rescheduling": "Disable Rescheduling",
"description_disable_rescheduling": "Guests can no longer reschedule the event with calendar invite or email",
"disable_cancelling": "Disable Cancelling",
"description_disable_cancelling": "Guests can no longer cancel the event with calendar invite or email",
"revoke_api_key": "Revoke API key",
"api_key_copied": "API key copied!",
"api_key_expires_on": "The API key will expire on",
@@ -1562,6 +1562,7 @@
"download_responses": "Download responses",
"download_responses_description": "Download all responses to your form in CSV format.",
"download": "Download",
"downloading": "Downloading",
"download_recording": "Download Recording",
"transcription_enabled": "Transcriptions are enabled now",
"transcription_stopped": "Transcriptions are stopped now",
@@ -1872,8 +1873,7 @@
"not_verified": "Not yet verified",
"no_availability_in_month": "No availability in {{month}}",
"no_availability_rolling": "Scheduling is only available up to {{days}} in advance. Please check again soon.",
"no_availability_range":
"Scheduling ended on {{date}}. Please check again soon.",
"no_availability_range": "Scheduling ended on {{date}}. Please check again soon.",
"view_previous_month": "View previous month",
"view_next_month": "View next month",
"send_code": "Send code",
@@ -3140,7 +3140,12 @@
"meeting_satisfaction_question": "How satisfied were you with your recent meeting?",
"meeting_not_joined_question": "didn't join the meeting?",
"reschedule_cta_short": "Reschedule here.",
"hide_organizer_email": "Hide organizer's email",
"hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events",
"you_and_conjunction": "You &",
"select_verified_email": "Select a verified email",
"custom_reply_to_email_title": "Custom 'Reply-To' email",
"custom_reply_to_email_description": "Use a different email address as the replyTo for confirmation emails instead of the organizer's email",
"email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.",
"sms_workflow_consent": "By entering your phone number you consent to receive SMS messages for this event. SMS rates may apply.",
"sms_opt_out_message": "Text STOP to opt-out of SMS messages",
@@ -16,6 +16,8 @@ import { BookingStatus, RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import { getUsersInOrgContext } from "@server/lib/[user]/getServerSideProps";
type Props = {
eventData: NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>;
booking?: GetBookingType;
@@ -228,10 +230,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
}
}
const [user] = await UserRepository.findUsersByUsername({
usernameList: [username],
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
const [user] = await getUsersInOrgContext([username], isValidOrgDomain ? currentOrgDomain : null);
if (!user) {
return {
@@ -240,6 +239,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
}
const org = isValidOrgDomain ? currentOrgDomain : null;
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we can show a 404 otherwise.
const eventData = await EventRepository.getPublicEvent(
@@ -92,10 +92,10 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
}
}
const usersInOrgContext = await UserRepository.findUsersByUsername({
const usersInOrgContext = await getUsersInOrgContext(
usernameList,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
isValidOrgDomain ? currentOrgDomain : null
);
const isDynamicGroup = usersInOrgContext.length > 1;
log.debug(safeStringify({ usersInOrgContext, isValidOrgDomain, currentOrgDomain, isDynamicGroup }));
@@ -197,3 +197,22 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
},
};
};
export async function getUsersInOrgContext(usernameList: string[], orgSlug: string | null) {
const usersInOrgContext = await UserRepository.findUsersByUsername({
usernameList,
orgSlug,
});
if (usersInOrgContext.length) {
return usersInOrgContext;
}
// note(Lauris): platform members (people who run platform) are part of platform organization while
// the platform organization does not have a domain. In this case there is no org domain but also platform member
// "User.organization" is not null so "UserRepository.findUsersByUsername" returns empty array and we do this as a last resort
// call to find platform member.
return await UserRepository.findPlatformMembersByUsernames({
usernameList,
});
}
@@ -142,6 +142,7 @@ describe("handleChildrenEventTypes", () => {
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
userId: 4,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
@@ -192,6 +193,7 @@ describe("handleChildrenEventTypes", () => {
...rest,
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
customReplyToEmail: null,
rrSegmentQueryValue: undefined,
locations: [],
scheduleId: null,
@@ -299,6 +301,7 @@ describe("handleChildrenEventTypes", () => {
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
instantMeetingScheduleId: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
@@ -354,6 +357,7 @@ describe("handleChildrenEventTypes", () => {
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
rrSegmentQueryValue: undefined,
customReplyToEmail: null,
locations: [],
hashedLink: {
deleteMany: {},
@@ -444,6 +448,7 @@ describe("handleChildrenEventTypes", () => {
durationLimits: undefined,
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
instantMeetingScheduleId: undefined,
locations: [],
lockTimeZoneToggleOnBookingPage: false,
@@ -470,6 +475,7 @@ describe("handleChildrenEventTypes", () => {
locations: [],
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
customReplyToEmail: null,
rrSegmentQueryValue: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
+42 -8
View File
@@ -7354,7 +7354,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",
@@ -7393,7 +7393,7 @@
"/v2/calendars/{calendar}/credentials": {
"post": {
"operationId": "CalendarsController_syncCredentials",
"summary": "Sync credentials",
"summary": "Save Apple calendar credentials",
"parameters": [
{
"name": "calendar",
@@ -7414,6 +7414,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateCalendarCredentialsInput"
}
}
}
},
"responses": {
"201": {
"description": ""
@@ -9472,7 +9482,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",
@@ -9488,7 +9498,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": {}
},
@@ -9496,7 +9506,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": {}
},
@@ -9544,7 +9554,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": {}
},
@@ -9810,6 +9820,7 @@
},
"delete": {
"operationId": "SlotsController_2024_09_04_deleteReservedSlot",
"summary": "Delete a reserved slot",
"parameters": [
{
"name": "cal-api-version",
@@ -15023,7 +15034,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": {
@@ -17750,7 +17761,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": {
@@ -18471,6 +18482,18 @@
},
"required": ["status", "data"]
},
"CreateCalendarCredentialsInput": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": ["username", "password"]
},
"DeleteCalendarCredentialsInputBodyDto": {
"type": "object",
"properties": {
@@ -18545,6 +18568,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.",
@@ -18565,6 +18593,9 @@
"type": "string",
"minLength": 1
},
"slug": {
"type": "string"
},
"metadata": {
"type": "object",
"example": {
@@ -18601,6 +18632,9 @@
"type": "string",
"minLength": 1
},
"slug": {
"type": "string"
},
"metadata": {
"type": "object",
"example": {
+3 -1
View File
@@ -220,7 +220,9 @@
"platform/guides/replacing-toasts",
"platform/guides/booking-fields",
"platform/guides/booking-redirects",
"platform/guides/teams-setup"
"platform/guides/teams-setup",
"platform/guides/global-event-types",
"platform/guides/custom-booking-flow"
]
},
"platform/faq"
@@ -0,0 +1,35 @@
---
title: Custom booking flow
description: Learn how to intercept a booking to introduce your custom flow and then submit the booking.
---
If you want to have a custom payment flow or any other custom action before the booking happens, you can intercept the booking
once the user clicks book in the [Booker atom](https://cal.com/docs/platform/atoms/booker), run whatever custom code you need and then finally
submit the booking with the intercepted booking data.
In your component that renders the Booker atom create a `interceptBooking` function that will be passed to the Booker atom `handleCreateBooking` hook. See [Booker atom](https://cal.com/docs/platform/atoms/booker) docs on how to use it - in this tutorial
we focus on the `handleCreateBooking` hook.
```
import { Booker, UseCreateBookingInput } from "@calcom/atoms";
export function BookingPage(props: { calUsername: string; calEmail: string }) {
...
const interceptBooking = useCallback((data: UseCreateBookingInput) => {
console.log(data);
}, []);
return (
<Booker
handleCreateBooking={interceptBooking}
...
/>
);
}
```
1. When user clicks "Book" in the Booker atom the booking will not happen. Instead, the `interceptBooking` function will be called and will receive all the booking data.
2. Then, you can hide the Booker component and instead render another component, show a modal, redirect to a new page or whatever you want to do.
3. After your custom action is finished, extract the necessary information from the booking data passed to `interceptBooking` and make a POST request to
[create booking](https://cal.com/docs/api-reference/v2/bookings/create-a-booking) endpoint to actually create the booking.
+161
View File
@@ -0,0 +1,161 @@
---
title: Global event types
description: Event types that every managed user or a subset of managed users will have automatically
---
If you want that all of your managed users or a subset of managed users have the same event type setup automatically then follow along.
## Prerequisites
1. You need to have an oAuth client setup, since we'll need the client id and client secret from the oauth client to be included in the api requests. Here is the link for the [oauth client setup guide](https://cal.com/docs/platform/quickstart)
2. You need to have an ESSENTIALS plan or higher
## Steps
Overview:
1. Create a team.
2. Create managed team event type with `assignAllTeamMembers: true` - it is an event type that you can define once and that will be used as a template to create event types for managed users. We call it the parent
managed event type and the event types created for managed users based on it are called children managed event types.
3. Create managed users.
4. For each managed user you want to have this event type, create a membership within the team.
Then the managed user will have the event type created. Once you have steps 1 and 2 setup then you simply need to add the managed user to the team
and the user will have the event type created. If you then update the parent managed event type then it will automatically update all of the children managed event types.
<Steps>
<Step>
Create a team by sending a POST request to the [create a team](https://cal.com/docs/api-reference/v2/orgs-teams/create-a-team) endpoint. Example request body:
```
{
"name": "coffee fans",
"slug": "coffee-fans"
}
```
You will need to note down the "id" of the created team from the response:
```
{
"status": "success",
"data": {
...
"id": 1041,
...
}
}
```
</Step>
<Step>
Now we will create the managed team event type. Make a POST request to the [create an organization team event type](https://cal.com/docs/api-reference/v2/orgs-teams-event-types/create-an-event-type) endpoint. Example request body:
```
{
"lengthInMinutes": 60,
"title": "coffee tasting",
"slug": "coffee-tasting",
"schedulingType": "managed",
"assignAllTeamMembers": true
}
```
Note that `"schedulingType"` has to be set to `"managed"` and `"assignAllTeamMembers"` has to be set to `true`. `"assignAllTeamMembers"` being `true` means that once a managed user has membership in the team this event type will be automatically created for the managed user.
You will need to note down the "id" of the created event type from the response:
```
{
"status": "success",
"data": [
{
"id": 3672,
"lengthInMinutes": 60,
"title": "coffee tasting",
"slug": "coffee-tasting",
"schedulingType": "managed",
"assignAllTeamMembers": true,
...
}
]
}
```
</Step>
<Step>
The team created in the previous step has no members, so we need to create managed users and later their membership in the team. To create a managed user here is the [create managed user](https://cal.com/docs/api-reference/v2/platform-managed-users/create-a-managed-user) endpoint. Example request body:
```
{
"email": "charlie@gmail.com",
"name": "charlie",
"timeZone": "Europe/Rome"
}
```
After creating managed user you have to save its "id", "accessToken" and "refreshToken" in your database and note down the "id" for the next step. Here is response:
```
{
"status": "success",
"data": {
"user": {
"id": 1458,
"email": "charlie+clxyyy21o0003sbk7yw5z6tzg@gmail.com",
"username": "charlie-clxyyy21o0003sbk7yw5z6tzg-gmail-com",
"name": "charlie",
...
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiY2xpZW50SWQiOiJjbHh5eXkyMW8wMDAzc2JrN3l3NXo2dHpnIiwib3duZXJJZCI6MTQ1OCwiZXhwaXJlc0F0IjoxNzQ1NDkwODQwMDAwLCJpYXQiOjE3NDU0ODcyNTh9.FI9-Hi7O_j4QLn7SuNJlOnmk-0MHhZoXPCncsmgyCoQ",
"accessTokenExpiresAt": 1745490840000,
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoicmVmcmVzaF90b2tlbiIsImNsaWVudElkIjoiY2x4eXl5MjFvMDAwM3Niazd5dzV6NnR6ZyIsIm93bmVySWQiOjE0NTgsImV4cGlyZXNBdCI6MTc3Njk4MTYwMDAwMCwiaWF0IjoxNzQ1NDg3MjU4fQ.YYTH7WeshMR-BpbNeSYFUubjTlsAsOhdhrge-LngRiw",
"refreshTokenExpiresAt": 1776981600000
}
}
```
</Step>
<Step>
Now we will create a membership for this user within the team by making a POST request to the [create membership](https://cal.com/docs/api-reference/v2/orgs-teams-memberships/create-a-membership) endpoint. Example body:
```
{
"userId": 1458,
"accepted": true,
"role": "MEMBER"
}
```
Note that `"accepted"` has to be `"true"` and `"userId"` has to be the id of the managed user created in the previous step.
</Step>
</Steps>
Now, if we fetch managed user event types using the [get event types](https://cal.com/docs/api-reference/v2/event-types/get-all-event-types) endpoint and given our example
query params `eventSlug` and `username` (`?eventSlug=coffee-tasting&username=charlie-clxyyy21o0003sbk7yw5z6tzg-gmail-com`), the response will contain the "global" event type:
```
{
"status": "success",
"data": [
{
"id": 3673,
"ownerId": 1458,
"lengthInMinutes": 60,
"title": "coffee tasting",
"slug": "coffee-tasting"
...
}
]
}
```
Note, that when we create the "parent" managed event type in step 2 it has an id `3672` but the child managed event type belonging to managed user has `3673`, so they are separate entities
and the "child" managed event type is the same as having individually created event type just for that managed user.
Now, if we update the "parent" managed team event type via [update an organization team event type](https://cal.com/docs/api-reference/v2/orgs-teams-event-types/update-a-team-event-type) endpoint, and fetch
again managed user event type using the [get event types](https://cal.com/docs/api-reference/v2/event-types/get-all-event-types) endpoint with same query parameters `eventSlug` and `username` as above,
the child managed event type will also be updated automatically.
Now with this setup you can:
1. Each time you create a new managed user simply create membership within the team and have the event type created automatically.
2. If you want that already existing managed users have the event type created, you have to create team and then membership within the team just like we saw above.
3. You can create a team called "global" where you add all managed users so all managed users have the same event type or a subset of managed users have the same event type belonging to "subset-team"
if you want to have a subset of managed users have the same event type.
@@ -118,6 +118,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
tAttendees
),
organizer: userOwnerAsPeopleType,
hideOrganizerEmail: bookingToReschedule.eventType?.hideOrganizerEmail,
team: !!bookingToReschedule.eventType?.team
? {
name: bookingToReschedule.eventType.team.name,
@@ -35,6 +35,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
eventType: {
select: {
metadata: true,
hideOrganizerEmail: true,
team: {
select: {
id: true,
@@ -120,6 +121,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
tAttendees
),
organizer: userOwnerAsPeopleType,
hideOrganizerEmail: bookingToReschedule.eventType?.hideOrganizerEmail,
team: !!bookingToReschedule.eventType?.team
? {
name: bookingToReschedule.eventType.team.name,
+51 -3
View File
@@ -31,9 +31,14 @@ const testIcsStringContains = ({
// Sometimes the deeply equal stringMatching error appears. Don't want to add flakey tests
// expect(icsString).toEqual(expect.stringContaining(`SUMMARY:${event.title}`));
expect(icsString).toEqual(expect.stringContaining(`DTSTART:${DTSTART}`));
expect(icsString).toEqual(
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:${event.organizer.email}`)
);
if (event.hideOrganizerEmail) {
expect(icsString).toEqual(expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}`));
expect(icsString).not.toEqual(expect.stringContaining(`mailto:${event.organizer.email}`));
} else {
expect(icsString).toEqual(
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:${event.organizer.email}`)
);
}
expect(icsString).toEqual(expect.stringContaining(`DTEND:${DTEND}`));
expect(icsString).toEqual(expect.stringContaining(`STATUS:${status}`));
// Getting an error expected icsString to deeply equal stringMatching
@@ -158,4 +163,47 @@ describe("generateIcsString", () => {
expect(icsString).toEqual(expect.stringContaining(`LOCATION:${event.location}`));
});
});
describe("hidden organizer email", () => {
test("when hideOrganizerEmail is true, organizer email should be omitted", () => {
const event = buildCalendarEvent({
iCalSequence: 0,
attendees: [buildPerson()],
hideOrganizerEmail: true,
});
const status = "CONFIRMED";
const icsString = generateIcsString({
event: event,
status,
});
const assertedIcsString = assertHasIcsString(icsString);
expect(assertedIcsString).toEqual(expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}`));
expect(assertedIcsString).not.toEqual(
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:`)
);
});
test("when hideOrganizerEmail is false, organizer email should be included", () => {
const event = buildCalendarEvent({
iCalSequence: 0,
attendees: [buildPerson()],
hideOrganizerEmail: false,
});
const status = "CONFIRMED";
const icsString = generateIcsString({
event: event,
status,
});
const assertedIcsString = assertHasIcsString(icsString);
expect(assertedIcsString).toEqual(
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:${event.organizer.email}`)
);
});
});
});
+5 -1
View File
@@ -30,6 +30,7 @@ export type ICSCalendarEvent = Pick<
| "team"
| "type"
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
>;
const toICalDateArray = (date: string): DateArray => {
@@ -73,7 +74,10 @@ const generateIcsString = ({
productId: "calcom/ics",
title: event.title,
description: getRichDescription(event, t),
organizer: { name: event.organizer.name, email: event.organizer.email },
organizer: {
name: event.organizer.name,
...(event.hideOrganizerEmail ? {} : { email: event.organizer.email }),
},
...{ recurrenceRule },
attendees: [
...event.attendees.map((attendee: Person) => ({
+1 -1
View File
@@ -33,7 +33,7 @@ export function WhoInfo(props: { calEvent: CalendarEvent; t: TFunction }) {
<PersonInfo
name={props.calEvent.organizer.name}
role={t("organizer")}
email={props.calEvent.organizer.email}
email={props.calEvent.hideOrganizerEmail ? "" : props.calEvent.organizer.email}
/>
{props.calEvent.team?.members.map((member) => (
<PersonInfo key={member.name} name={member.name} role={t("team_member")} email={member?.email} />
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -12,7 +14,7 @@ export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("guests_added_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -6,7 +8,7 @@ export default class AttendeeAwaitingPaymentEmail extends AttendeeScheduledEmail
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.attendee.language.translate("complete_your_booking_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,3 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -14,7 +15,7 @@ export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("event_cancelled_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -6,7 +8,7 @@ export default class AttendeeCancelledSeatEmail extends AttendeeScheduledEmail {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("event_no_longer_attending_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,6 +1,7 @@
// TODO: We should find a way to keep App specific email templates within the App itself
import type { TFunction } from "i18next";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -29,7 +30,7 @@ export default class AttendeeDailyVideoDownloadRecordingEmail extends BaseEmail
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.t("download_recording_subject", {
title: this.calEvent.title,
@@ -1,5 +1,6 @@
import type { TFunction } from "i18next";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -40,7 +41,7 @@ export default class AttendeeDailyVideoDownloadTranscriptEmail extends BaseEmail
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.t("download_transcript_email_subject", {
title: this.calEvent.title,
@@ -1,3 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -8,7 +9,7 @@ export default class AttendeeDeclinedEmail extends AttendeeScheduledEmail {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("event_declined_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -12,7 +14,7 @@ export default class AttendeeLocationChangeEmail extends AttendeeScheduledEmail
}),
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.t("location_changed_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.team?.name || this.calEvent.organizer.name,
@@ -1,4 +1,5 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -15,7 +16,7 @@ export default class AttendeeRequestEmail extends AttendeeScheduledEmail {
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.calEvent.attendees[0].language.translate("booking_submitted_subject", {
title: this.calEvent.title,
@@ -1,3 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -18,7 +19,7 @@ export default class AttendeeRescheduledEmail extends AttendeeScheduledEmail {
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.attendee.language.translate("event_type_has_been_rescheduled_on_time_date", {
title: this.calEvent.title,
@@ -2,6 +2,7 @@ import type { TFunction } from "i18next";
import { default as cloneDeep } from "lodash/cloneDeep";
import { getRichDescription } from "@calcom/lib/CalEventParser";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { TimeFormat } from "@calcom/lib/timeFormat";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -43,7 +44,7 @@ export default class AttendeeScheduledEmail extends BaseEmail {
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.calEvent.title}`,
html: await this.getHtml(clonedCalEvent, this.attendee),
@@ -1,3 +1,4 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -18,7 +19,7 @@ export default class AttendeeUpdatedEmail extends AttendeeScheduledEmail {
...this.calEvent.attendees
.filter(({ email }) => email !== this.attendee.email)
.map(({ email }) => email),
this.calEvent.organizer.email,
getReplyToEmail(this.calEvent),
],
subject: `${this.attendee.language.translate("event_type_has_been_updated", {
title: this.calEvent.title,
@@ -1,3 +1,5 @@
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import { renderEmail } from "../";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -7,7 +9,7 @@ export default class NoShowFeeChargedEmail extends AttendeeScheduledEmail {
return {
to: `${this.attendee.name} <${this.attendee.email}>`,
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
replyTo: this.calEvent.organizer.email,
replyTo: getReplyToEmail(this.calEvent),
subject: `${this.attendee.language.translate("no_show_fee_charged_email_subject", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -1,4 +1,5 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { getReplyToEmail } from "@calcom/lib/getReplyToEmail";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
@@ -17,7 +18,7 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
}),
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
replyTo: [...this.calEvent.attendees.map(({ email }) => email)],
replyTo: [...this.calEvent.attendees.map(({ email }) => email), getReplyToEmail(this.calEvent)],
subject: `${this.calEvent.organizer.language.translate("event_type_has_been_rescheduled_on_time_date", {
title: this.calEvent.title,
date: this.getFormattedDate(),
@@ -47,10 +47,12 @@ export class CalendarEventBuilder {
id: number;
hideCalendarNotes?: boolean;
hideCalendarEventDetails?: boolean;
hideOrganizerEmail?: boolean;
schedulingType?: SchedulingType | null;
seatsPerTimeSlot?: number | null;
seatsShowAttendees?: boolean | null;
seatsShowAvailabilityCount?: boolean | null;
customReplyToEmail?: string | null;
}) {
this.event = {
...this.event,
@@ -59,11 +61,13 @@ export class CalendarEventBuilder {
eventTypeId: eventType.id,
hideCalendarNotes: eventType.hideCalendarNotes,
hideCalendarEventDetails: eventType.hideCalendarEventDetails,
hideOrganizerEmail: eventType.hideOrganizerEmail,
schedulingType: eventType.schedulingType,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
// if seats are not enabled we should default true
seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true,
customReplyToEmail: eventType.customReplyToEmail,
};
return this;
}
@@ -1,6 +1,8 @@
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { Button } from "@calcom/ui/components/button";
"use client";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { SkeletonText } from "@calcom/ui/components/skeleton";
function SkeletonLoader() {
return (
@@ -73,7 +73,9 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
bookingFields: true,
seatsShowAttendees: true,
metadata: true,
hideOrganizerEmail: true,
schedulingType: true,
customReplyToEmail: true,
hosts: {
select: {
user: true,
@@ -42,6 +42,9 @@ const getUserBooking = async (uid: string) => {
timeZone: true,
phoneNumber: true,
},
orderBy: {
id: "asc",
},
},
eventTypeId: true,
eventType: {
@@ -50,6 +53,7 @@ const getUserBooking = async (uid: string) => {
slug: true,
timeZone: true,
schedulingType: true,
hideOrganizerEmail: true,
},
},
seatsReferences: {
@@ -265,7 +265,9 @@ async function handler(input: CancelBookingInput) {
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
hideOrganizerEmail: bookingToDelete.eventType?.hideOrganizerEmail,
platformBookingUrl,
customReplyToEmail: bookingToDelete.eventType?.customReplyToEmail,
};
const dataForWebhooks = { evt, webhooks, eventTypeInfo };
@@ -1019,11 +1019,13 @@ async function handler(
id: eventType.id,
hideCalendarNotes: eventType.hideCalendarNotes,
hideCalendarEventDetails: eventType.hideCalendarEventDetails,
hideOrganizerEmail: eventType.hideOrganizerEmail,
schedulingType: eventType.schedulingType,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
// if seats are not enabled we should default true
seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
seatsShowAvailabilityCount: eventType.seatsPerTimeSlot ? eventType.seatsShowAvailabilityCount : true,
customReplyToEmail: eventType.customReplyToEmail,
})
.withOrganizer({
id: organizerUser.id,
@@ -70,6 +70,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
destinationCalendar: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
seatsPerTimeSlot: true,
recurringEvent: true,
seatsShowAttendees: true,
@@ -80,6 +81,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
assignAllTeamMembers: true,
isRRWeightsEnabled: true,
beforeEventBuffer: true,
customReplyToEmail: true,
afterEventBuffer: true,
parentId: true,
parent: {
@@ -251,6 +251,7 @@ const handleDeleteCredential = async ({
seatsPerTimeSlot: true,
seatsShowAttendees: true,
eventName: true,
hideOrganizerEmail: true,
team: {
select: {
id: true,
@@ -346,6 +347,7 @@ const handleDeleteCredential = async ({
cancellationReason: "Payment method removed by organizer",
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
team: !!booking.eventType?.team
? {
name: booking.eventType.team.name,
@@ -3,6 +3,7 @@ import { CreateANewTeamForm } from "@calcom/features/ee/teams/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { DialogContent } from "@calcom/ui/components/dialog";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
interface CreateTeamDialogProps {
open: boolean;
@@ -24,6 +25,7 @@ const CreateTeamDialog = (props: CreateTeamDialogProps) => {
onSuccess={async () => {
await utils.viewer.dsync.teamGroupMapping.get.invalidate();
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
onOpenChange(false);
}}
/>
@@ -1,14 +1,16 @@
"use client";
import { useState } from "react";
import { trackFormbricksAction } from "@calcom/lib/formbricks-client";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import OtherTeamListItem from "./OtherTeamListItem";
interface Props {
teams: RouterOutputs["viewer"]["organizations"]["listOtherTeams"];
teams: Awaited<ReturnType<typeof OrganizationRepository.findTeamsInOrgIamNotPartOf>>;
pending?: boolean;
}
@@ -1,24 +1,19 @@
import SkeletonLoaderTeamList from "@calcom/ee/teams/components/SkeletonloaderTeamList";
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Alert } from "@calcom/ui/components/alert";
import OtherTeamList from "./OtherTeamList";
export function OtherTeamsListing() {
type OtherTeamsListingProps = {
teams: Awaited<ReturnType<typeof OrganizationRepository.findTeamsInOrgIamNotPartOf>>;
};
export function OtherTeamsListing({ teams }: OtherTeamsListingProps) {
const { t } = useLocale();
const { data: teams, isPending, error } = trpc.viewer.organizations.listOtherTeams.useQuery();
if (isPending) {
return <SkeletonLoaderTeamList />;
}
return (
<>
{!!error && <Alert severity="error" title={error.message} />}
{teams && teams.length > 0 ? (
<OtherTeamList teams={teams} />
) : (

Some files were not shown because too many files have changed in this diff Show More