Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc1bc6746 | ||
|
|
49a2c60212 | ||
|
|
7dfbb42ced | ||
|
|
41fafb9e5e | ||
|
|
6815f2f9c7 | ||
|
|
91ab27e070 | ||
|
|
396ccb1cd2 | ||
|
|
4e8d5cebca | ||
|
|
80f2e61189 | ||
|
|
d844fd6ef7 | ||
|
|
513f54f4fe | ||
|
|
2e6d71f493 | ||
|
|
55488c39f4 | ||
|
|
c0e123a917 | ||
|
|
ec88f16f1f | ||
|
|
a957c15062 | ||
|
|
b32b31ccdc | ||
|
|
e29f6af540 | ||
|
|
c6cb3423fa | ||
|
|
45904c07bc | ||
|
|
0623887ff8 | ||
|
|
f31c45a153 | ||
|
|
f5217caef2 |
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
@@ -112,6 +112,8 @@ $ yarn run test:cov
|
||||
### Guards
|
||||
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
|
||||
2. In case a guard would return "false" for "canActivate" DO NOT cache the result in redis, because we don't want that someone is forbidden, updates whatever was the problem, and then has to wait for cache to expire. We only cache in redis guard results where "canAccess" is "true".
|
||||
3. If you use ApiAuthGuard but want that only specific auth method is allowed, for example, api key, then you also need to add `@ApiAuthGuardOnlyAllow(["API_KEY"])` under the `@UseGuards(ApiAuthGuard)`. Shortly, use `ApiAuthGuardOnlyAllow` to specify which auth methods are allowed by `ApiAuthGuard`. If `ApiAuthGuardOnlyAllow` is not used or nothing is passed to it or empty array it means that
|
||||
all auth methods are allowed.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.174",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.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
@@ -10,6 +10,16 @@ export class CreateOrganizationInput extends RefreshApiKeyInput {
|
||||
@ApiProperty({ description: "Name of the organization", example: "CalTeam" })
|
||||
readonly name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description:
|
||||
"Organization slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||
example: "cal-tel",
|
||||
})
|
||||
readonly slug?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
description: METADATA_DOCS,
|
||||
|
||||
+2
@@ -46,6 +46,7 @@ import {
|
||||
X_CAL_SECRET_KEY,
|
||||
} from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { ApiSuccessResponse, CreateOAuthClientInput } from "@calcom/platform-types";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@@ -176,6 +177,7 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
managedOrg = responseBody.data;
|
||||
expect(managedOrg?.id).toBeDefined();
|
||||
expect(managedOrg?.name).toEqual(body.name);
|
||||
expect(managedOrg?.slug).toEqual(slugify(body.name));
|
||||
expect(managedOrg?.metadata).toEqual(body.metadata);
|
||||
expect(managedOrg?.apiKey).toBeDefined();
|
||||
|
||||
|
||||
+6
@@ -16,6 +16,12 @@ export class ManagedOrganizationOutput {
|
||||
@ApiProperty()
|
||||
readonly name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
@ApiPropertyOptional()
|
||||
readonly slug?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
example: { key: "value" },
|
||||
|
||||
+6
@@ -10,6 +10,8 @@ import { ManagedOrganizationsOutputService } from "@/modules/organizations/organ
|
||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class ManagedOrganizationsService {
|
||||
constructor(
|
||||
@@ -36,6 +38,10 @@ export class ManagedOrganizationsService {
|
||||
|
||||
const { apiKeyDaysValid, apiKeyNeverExpires, ...organizationData } = organizationInput;
|
||||
|
||||
if (!organizationData.slug) {
|
||||
organizationData.slug = slugify(organizationData.name);
|
||||
}
|
||||
|
||||
const organization = await this.managedOrganizationsRepository.createManagedOrganization(
|
||||
managerOrganizationId,
|
||||
{
|
||||
|
||||
+28
@@ -36,6 +36,7 @@ describe("Organizations Team Endpoints", () => {
|
||||
let team2: Team;
|
||||
let teamCreatedViaApi: Team;
|
||||
let teamCreatedViaApi2: Team;
|
||||
let teamCreatedViaApi3: Team;
|
||||
|
||||
const userEmail = `organizations-teams-admin-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
@@ -245,11 +246,38 @@ describe("Organizations Team Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create the team of the org with automatically set slug", async () => {
|
||||
const teamName = `Organizations Teams Automatic Slug`;
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.send({
|
||||
name: teamName,
|
||||
bio: "This is our test team created via API",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<Team> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
teamCreatedViaApi3 = responseBody.data;
|
||||
expect(teamCreatedViaApi3.name).toEqual(teamName);
|
||||
expect(teamCreatedViaApi3.slug).toEqual("organizations-teams-automatic-slug");
|
||||
expect(teamCreatedViaApi3.bio).toEqual("This is our test team created via API");
|
||||
expect(teamCreatedViaApi3.parentId).toEqual(org.id);
|
||||
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
|
||||
user.id,
|
||||
teamCreatedViaApi3.id
|
||||
);
|
||||
expect(membership?.role ?? "").toEqual("OWNER");
|
||||
expect(membership?.accepted).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await teamsRepositoryFixture.delete(team2.id);
|
||||
await teamsRepositoryFixture.delete(teamCreatedViaApi2.id);
|
||||
await teamsRepositoryFixture.delete(teamCreatedViaApi3.id);
|
||||
await teamsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+10
@@ -5,6 +5,8 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsTeamsService {
|
||||
constructor(
|
||||
@@ -40,6 +42,10 @@ export class OrganizationsTeamsService {
|
||||
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto, user: UserWithProfile) {
|
||||
const { autoAcceptCreator, ...rest } = data;
|
||||
|
||||
if (!rest.slug) {
|
||||
rest.slug = slugify(rest.name);
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, rest);
|
||||
|
||||
if (user.role !== "ADMIN") {
|
||||
@@ -56,6 +62,10 @@ export class OrganizationsTeamsService {
|
||||
) {
|
||||
const { autoAcceptCreator, ...rest } = data;
|
||||
|
||||
if (!rest.slug) {
|
||||
rest.slug = slugify(rest.name);
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamRepository.createPlatformOrgTeam(
|
||||
organizationId,
|
||||
oAuthClientId,
|
||||
|
||||
@@ -7,6 +7,9 @@ const ensureUserLevelWhere = {
|
||||
eventTypeId: null,
|
||||
};
|
||||
|
||||
export const NO_SELECTED_CALENDAR_FOUND = "No SelectedCalendar found.";
|
||||
export const MULTIPLE_SELECTED_CALENDARS_FOUND = "Multiple SelecteCalendars found. Skipping deletion";
|
||||
|
||||
@Injectable()
|
||||
export class SelectedCalendarsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
@@ -113,11 +116,11 @@ export class SelectedCalendarsRepository {
|
||||
|
||||
// Make the behaviour same as .delete which throws error if no record is found
|
||||
if (records.length === 0) {
|
||||
throw new Error("No SelectedCalendar found.");
|
||||
throw new Error(NO_SELECTED_CALENDAR_FOUND);
|
||||
}
|
||||
|
||||
if (records.length > 1) {
|
||||
throw new Error("Multiple SelecteCalendars found. Skipping deletion");
|
||||
throw new Error(MULTIPLE_SELECTED_CALENDARS_FOUND);
|
||||
}
|
||||
|
||||
return await this.dbWrite.prisma.selectedCalendar.delete({
|
||||
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
SelectedCalendarsInputDto,
|
||||
SelectedCalendarsQueryParamsInputDto,
|
||||
} from "@/modules/selected-calendars/inputs/selected-calendars.input";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import {
|
||||
MULTIPLE_SELECTED_CALENDARS_FOUND,
|
||||
NO_SELECTED_CALENDAR_FOUND,
|
||||
SelectedCalendarsRepository,
|
||||
} from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { SelectedCalendarRepository } from "@calcom/platform-libraries";
|
||||
|
||||
@@ -111,13 +115,23 @@ export class SelectedCalendarsService {
|
||||
}
|
||||
}
|
||||
|
||||
const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar(
|
||||
user.id,
|
||||
integration,
|
||||
externalId,
|
||||
delegationCredentialId
|
||||
);
|
||||
try {
|
||||
const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar(
|
||||
user.id,
|
||||
integration,
|
||||
externalId,
|
||||
delegationCredentialId
|
||||
);
|
||||
|
||||
return removedCalendarEntry;
|
||||
return removedCalendarEntry;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === NO_SELECTED_CALENDAR_FOUND) {
|
||||
throw new NotFoundException(NO_SELECTED_CALENDAR_FOUND);
|
||||
} else if (error.message === MULTIPLE_SELECTED_CALENDARS_FOUND) {
|
||||
throw new BadRequestException(MULTIPLE_SELECTED_CALENDARS_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export class SlotsController_2024_09_04 {
|
||||
summary: "Find out when is an event type ready to be booked.",
|
||||
description: `
|
||||
There are 4 ways to get available slots for event type of an individual user:
|
||||
|
||||
|
||||
1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
|
||||
@@ -72,7 +72,7 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
And 3 ways to get available slots for team event type:
|
||||
|
||||
1. By team event type id. Example '/v2/slots?teamEventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
|
||||
|
||||
2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'
|
||||
|
||||
@@ -109,9 +109,9 @@ export class SlotsController_2024_09_04 {
|
||||
name: "usernames",
|
||||
required: false,
|
||||
description: `The usernames for which available slots should be checked separated by a comma.
|
||||
|
||||
|
||||
Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.
|
||||
|
||||
|
||||
Must contain at least 2 usernames.`,
|
||||
example: "alice,bob",
|
||||
})
|
||||
@@ -154,9 +154,9 @@ export class SlotsController_2024_09_04 {
|
||||
required: true,
|
||||
description: `
|
||||
Time until which available slots should be checked.
|
||||
|
||||
|
||||
Must be in UTC timezone as ISO 8601 datestring.
|
||||
|
||||
|
||||
You can pass date without hours which defaults to end of day or specify hours:
|
||||
2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.`,
|
||||
example: "2050-09-06",
|
||||
@@ -166,9 +166,9 @@ export class SlotsController_2024_09_04 {
|
||||
required: true,
|
||||
description: `
|
||||
Time starting from which available slots should be checked.
|
||||
|
||||
|
||||
Must be in UTC timezone as ISO 8601 datestring.
|
||||
|
||||
|
||||
You can pass date without hours which defaults to start of day or specify hours:
|
||||
2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.`,
|
||||
example: "2050-09-05",
|
||||
@@ -300,6 +300,9 @@ export class SlotsController_2024_09_04 {
|
||||
}
|
||||
|
||||
@Delete("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Delete a reserved slot",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@DocsResponse({
|
||||
status: 200,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { TeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
describe("Teams endpoint", () => {
|
||||
@@ -75,7 +76,7 @@ describe("Teams endpoint", () => {
|
||||
describe("User has membership in created team", () => {
|
||||
it("should create first team", async () => {
|
||||
const body: CreateTeamInput = {
|
||||
name: `teams-dog-${randomString()}`,
|
||||
name: `teams dog ${randomString()}`,
|
||||
metadata: {
|
||||
teamKey: "teamValue",
|
||||
},
|
||||
@@ -93,6 +94,7 @@ describe("Teams endpoint", () => {
|
||||
expect(responseData).toBeDefined();
|
||||
expect(responseData.id).toBeDefined();
|
||||
expect(responseData.name).toEqual(body.name);
|
||||
expect(responseData.slug).toEqual(slugify(body.name));
|
||||
expect(responseData.metadata).toEqual(body.metadata);
|
||||
team1 = responseData;
|
||||
});
|
||||
|
||||
@@ -11,7 +11,11 @@ export class CreateTeamInput {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" })
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Team slug in kebab-case - if not provided will be generated automatically based on name.",
|
||||
example: "caltel",
|
||||
})
|
||||
readonly slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -6,6 +6,8 @@ import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { BadRequestException, Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
private isTeamBillingEnabled = this.configService.get("stripe.isTeamBillingEnabled");
|
||||
@@ -19,6 +21,9 @@ export class TeamsService {
|
||||
|
||||
async createTeam(input: CreateTeamInput, ownerId: number) {
|
||||
const { autoAcceptCreator, ...teamData } = input;
|
||||
if (!teamData.slug) {
|
||||
teamData.slug = slugify(teamData.name);
|
||||
}
|
||||
|
||||
const existingTeam = await this.teamsMembershipsRepository.findTeamMembershipsByNameAndUser(
|
||||
input.name,
|
||||
|
||||
@@ -7697,7 +7697,7 @@
|
||||
"/v2/calendars/{calendar}/save": {
|
||||
"get": {
|
||||
"operationId": "CalendarsController_save",
|
||||
"summary": "Save an oAuth calendar credentials",
|
||||
"summary": "Save Google or Outlook calendar credentials",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
@@ -7741,7 +7741,7 @@
|
||||
"/v2/calendars/{calendar}/credentials": {
|
||||
"post": {
|
||||
"operationId": "CalendarsController_syncCredentials",
|
||||
"summary": "Sync credentials",
|
||||
"summary": "Save Apple calendar credentials",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "calendar",
|
||||
@@ -7764,6 +7764,16 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateCalendarCredentialsInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
@@ -9942,7 +9952,7 @@
|
||||
"get": {
|
||||
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
|
||||
"summary": "Find out when is an event type ready to be booked.",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n \n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?teamEventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -9958,7 +9968,7 @@
|
||||
"name": "start",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.",
|
||||
"description": "\n Time starting from which available slots should be checked.\n\n Must be in UTC timezone as ISO 8601 datestring.\n\n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.",
|
||||
"example": "2050-09-05",
|
||||
"schema": {}
|
||||
},
|
||||
@@ -9966,7 +9976,7 @@
|
||||
"name": "end",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.",
|
||||
"description": "\n Time until which available slots should be checked.\n\n Must be in UTC timezone as ISO 8601 datestring.\n\n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.",
|
||||
"example": "2050-09-06",
|
||||
"schema": {}
|
||||
},
|
||||
@@ -10014,7 +10024,7 @@
|
||||
"name": "usernames",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The usernames for which available slots should be checked separated by a comma.\n \n Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.\n \n Must contain at least 2 usernames.",
|
||||
"description": "The usernames for which available slots should be checked separated by a comma.\n\n Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.\n\n Must contain at least 2 usernames.",
|
||||
"example": "alice,bob",
|
||||
"schema": {}
|
||||
},
|
||||
@@ -10288,6 +10298,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "SlotsController_2024_09_04_deleteReservedSlot",
|
||||
"summary": "Delete a reserved slot",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -13337,6 +13348,7 @@
|
||||
},
|
||||
"enabledLayouts": {
|
||||
"type": "array",
|
||||
"description": "Array of valid layouts - month, week or column",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
-11
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
export const TeamsCTA = () => {
|
||||
const { t } = useLocale();
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
return !user.organizationId || user.organization.isOrgAdmin ? (
|
||||
return (
|
||||
<Button
|
||||
data-testid="new-team-btn"
|
||||
variant="fab"
|
||||
@@ -18,11 +15,5 @@ export const TeamsCTA = () => {
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
function Teams() {
|
||||
return <TeamsListing />;
|
||||
}
|
||||
|
||||
export default Teams;
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidateTeamsList() {
|
||||
revalidatePath("/teams");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TeamsListSkeleton } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <TeamsListSkeleton />;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import TeamsView, { TeamsCTA } from "~/teams/teams-view";
|
||||
import { ServerTeamsListing } from "./server-page";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -30,13 +30,11 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const { Main, CTA } = await ServerTeamsListing({ searchParams });
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
CTA={<TeamsCTA />}
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}>
|
||||
<TeamsView />
|
||||
<ShellMainAppDir CTA={CTA} heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
|
||||
{Main}
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { SearchParams } from "app/_types";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { TeamsCTA } from "./CTA";
|
||||
|
||||
export const ServerTeamsListing = async ({ searchParams }: { searchParams: SearchParams }) => {
|
||||
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
|
||||
|
||||
const [teamsCaller, meCaller] = await Promise.all([
|
||||
createRouterCaller(viewerTeamsRouter),
|
||||
createRouterCaller(meRouter),
|
||||
]);
|
||||
|
||||
let teamNameFromInvite,
|
||||
errorMsgFromInvite = null;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
teamNameFromInvite = await teamsCaller.inviteMemberByToken({
|
||||
token,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
});
|
||||
} catch (e) {
|
||||
errorMsgFromInvite = "Error while fetching teams";
|
||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const [user, teams] = await Promise.all([
|
||||
meCaller.get(),
|
||||
teamsCaller.list({
|
||||
includeOrgs: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
Main: (
|
||||
<TeamsListing
|
||||
teams={teams}
|
||||
user={user}
|
||||
teamNameFromInvite={teamNameFromInvite ?? null}
|
||||
errorMsgFromInvite={errorMsgFromInvite}
|
||||
/>
|
||||
),
|
||||
CTA: !user.organizationId || user.organization.isOrgAdmin ? <TeamsCTA /> : null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import { TeamsCTA } from "app/(use-page-wrapper)/(main-nav)/teams/CTA";
|
||||
|
||||
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export const TeamsListSkeleton = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={<TeamsCTA />}>
|
||||
<SkeletonLoaderTeamList />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppHomePageSkeleton } from "app/(use-page-wrapper)/apps/(homepage)/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <AppHomePageSkeleton />;
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
+11
-33
@@ -16,7 +16,6 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { IdentityProvider, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -255,7 +254,7 @@ const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnab
|
||||
const BackButtonInSidebar = ({ name }: { name: string }) => {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
href="/event-types"
|
||||
className="hover:bg-subtle todesktop:mt-10 [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group my-6 flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2 text-sm font-medium leading-4 transition"
|
||||
data-testid={`vertical-tab-${name}`}>
|
||||
<Icon
|
||||
@@ -273,8 +272,6 @@ interface SettingsSidebarContainerProps {
|
||||
className?: string;
|
||||
navigationIsOpenedOnMobile?: boolean;
|
||||
bannersHeight?: number;
|
||||
currentOrg: SettingsLayoutProps["currentOrg"];
|
||||
otherTeams: SettingsLayoutProps["otherTeams"];
|
||||
}
|
||||
|
||||
const TeamListCollapsible = () => {
|
||||
@@ -441,10 +438,9 @@ const SettingsSidebarContainer = ({
|
||||
className = "",
|
||||
navigationIsOpenedOnMobile,
|
||||
bannersHeight,
|
||||
currentOrg: currentOrgProp,
|
||||
otherTeams: otherTeamsProp,
|
||||
}: SettingsSidebarContainerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
const { t } = useLocale();
|
||||
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
|
||||
{
|
||||
@@ -453,19 +449,17 @@ const SettingsSidebarContainer = ({
|
||||
}[]
|
||||
>();
|
||||
const session = useSession();
|
||||
const { data: _currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org && !currentOrgProp,
|
||||
const { data } = trpc.viewer.delegationCredential.check.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
});
|
||||
|
||||
const tabsWithPermissions = useTabs({
|
||||
isDelegationCredentialEnabled: !!_currentOrg?.features?.delegationCredential,
|
||||
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
|
||||
});
|
||||
|
||||
const { data: _otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org && !otherTeamsProp,
|
||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
enabled: !!session.data?.user?.org,
|
||||
});
|
||||
const currentOrg = currentOrgProp ?? _currentOrg;
|
||||
const otherTeams = otherTeamsProp ?? _otherTeams;
|
||||
|
||||
// Same as above but for otherTeams
|
||||
useEffect(() => {
|
||||
if (otherTeams) {
|
||||
@@ -485,8 +479,7 @@ const SettingsSidebarContainer = ({
|
||||
}
|
||||
}, [searchParams?.get("id"), otherTeams]);
|
||||
|
||||
const isOrgAdminOrOwner =
|
||||
currentOrg && currentOrg?.user?.role && ["OWNER", "ADMIN"].includes(currentOrg?.user?.role);
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgBranding?.role);
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -569,7 +562,7 @@ const SettingsSidebarContainer = ({
|
||||
</div>
|
||||
</Link>
|
||||
<TeamListCollapsible />
|
||||
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
|
||||
{(!orgBranding?.id || isOrgAdminOrOwner) && (
|
||||
<VerticalTabItem
|
||||
name={t("add_a_team")}
|
||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||
@@ -733,17 +726,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
||||
|
||||
export type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
currentOrg: Awaited<ReturnType<typeof OrganizationRepository.findCurrentOrg>> | null;
|
||||
otherTeams: Awaited<ReturnType<typeof OrganizationRepository.findTeamsInOrgIamNotPartOf>> | null;
|
||||
containerClassName?: string;
|
||||
} & ComponentProps<typeof Shell>;
|
||||
|
||||
export default function SettingsLayoutAppDirClient({
|
||||
children,
|
||||
currentOrg,
|
||||
otherTeams,
|
||||
...rest
|
||||
}: SettingsLayoutProps) {
|
||||
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const state = useState(false);
|
||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||
@@ -773,8 +759,6 @@ export default function SettingsLayoutAppDirClient({
|
||||
{...rest}
|
||||
SidebarContainer={
|
||||
<SidebarContainerElement
|
||||
currentOrg={currentOrg}
|
||||
otherTeams={otherTeams}
|
||||
sideContainerOpen={sideContainerOpen}
|
||||
setSideContainerOpen={setSideContainerOpen}
|
||||
/>
|
||||
@@ -798,8 +782,6 @@ const SidebarContainerElement = ({
|
||||
sideContainerOpen,
|
||||
bannersHeight,
|
||||
setSideContainerOpen,
|
||||
currentOrg,
|
||||
otherTeams,
|
||||
}: SidebarContainerElementProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
@@ -815,8 +797,6 @@ const SidebarContainerElement = ({
|
||||
<SettingsSidebarContainer
|
||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||
bannersHeight={bannersHeight}
|
||||
currentOrg={currentOrg}
|
||||
otherTeams={otherTeams}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -826,6 +806,4 @@ type SidebarContainerElementProps = {
|
||||
sideContainerOpen: boolean;
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
currentOrg: SettingsLayoutProps["currentOrg"];
|
||||
otherTeams: SettingsLayoutProps["otherTeams"];
|
||||
};
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
|
||||
import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
|
||||
|
||||
type SettingsLayoutAppDirProps = Omit<SettingsLayoutProps, "currentOrg" | "otherTeams">;
|
||||
|
||||
export default async function SettingsLayoutAppDir(props: SettingsLayoutAppDirProps) {
|
||||
export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
const userId = session?.user?.id ?? -1;
|
||||
const orgId = session?.user?.org?.id ?? -1;
|
||||
let currentOrg = null;
|
||||
let otherTeams = null;
|
||||
|
||||
try {
|
||||
currentOrg = await OrganizationRepository.findCurrentOrg({ userId, orgId });
|
||||
} catch (err) {}
|
||||
|
||||
try {
|
||||
otherTeams = await OrganizationRepository.findTeamsInOrgIamNotPartOf({
|
||||
userId,
|
||||
parentId: orgId,
|
||||
});
|
||||
} catch (err) {}
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayoutAppDirClient {...props} currentOrg={currentOrg} otherTeams={otherTeams} />
|
||||
<SettingsLayoutAppDirClient {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { SkeletonLoader } from "./skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("org_admin_other_teams"),
|
||||
(t) => t("org_admin_other_teams_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/teams/other"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
const organizationId = session?.user?.org?.id;
|
||||
const otherTeams = organizationId
|
||||
? await OrganizationRepository.findTeamsInOrgIamNotPartOf({
|
||||
userId: session?.user.id,
|
||||
parentId: organizationId,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")}>
|
||||
<OtherTeamsListing teams={otherTeams} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import SkeletonLoaderTeamList from "@calcom/features/ee/teams/components/SkeletonloaderTeamList";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export function SkeletonLoader() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<SettingsHeader title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")}>
|
||||
<SkeletonLoaderTeamList />
|
||||
</SettingsHeader>
|
||||
);
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
|
||||
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/other-team-listing-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("org_admin_other_teams"),
|
||||
(t) => t("org_admin_other_teams_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/teams/other"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("org_admin_other_teams")} description={t("org_admin_other_teams_description")}>
|
||||
<LegacyPage />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
|
||||
import LegacyPage from "@calcom/features/ee/teams/pages/team-listing-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("teams"),
|
||||
(t) => t("create_manage_teams_collaborative"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/teams"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("teams")} description={t("create_manage_teams_collaborative")}>
|
||||
<LegacyPage />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -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">
|
||||
|
||||
@@ -39,6 +39,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
currency: true,
|
||||
bookingFields: true,
|
||||
allowReschedulingPastBookings: true,
|
||||
hideOrganizerEmail: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
disableGuests: true,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const getBooking = async (bookingId: number) => {
|
||||
teamId: true,
|
||||
parentId: true,
|
||||
canSendCalVideoTranscriptionEmails: true,
|
||||
customReplyToEmail: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
|
||||
@@ -34,6 +34,7 @@ export const getCalendarEvent = async (booking: getBookingResponse) => {
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
customReplyToEmail: booking.eventType?.customReplyToEmail,
|
||||
};
|
||||
|
||||
return Promise.resolve(evt);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.2.4",
|
||||
"version": "5.2.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user