feat: v2 api endpoints and hooks to list private and public event types of user (#14192)

* refactor: extract getEventTypeByViewer for private user dashboard event types

* refactor: extract getEventTypesPublic for public event types to book

* refactor: group event-types lib functions in single folder

* refactor: rename getEventTypeByViewer to getEventTypesByViewer

* feat: add event type helpers from lib to platform libraries

* feat: v2 endpoint for private event types

* driveby:fix: fixed date in docs instead of being re-generated

* feature: endpoint to fetch public endpoints

* fix: getting private event types

* tests: fetch private event types

* tests: fetch private event types

* feat: hooks for public and private events

* fix: yarn test

* tests: IMO better mocking

* refactor: track dynamic prop in react query

* fix: distinct query key for public events

* fix: username in query key and example app demo

* fix: swagger for endpoint get public event input

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2024-03-26 08:35:17 +00:00
1 parent da7c5ec407
commit 0223db1f7c
24 files changed
+680 -410

No files matched your search

@@ -18,6 +18,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { EventTypesByViewer, EventTypesPublic } from "@calcom/platform-libraries";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("Event types Endpoints", () => {
@@ -60,6 +61,8 @@ describe("Event types Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com";
const name = "bob";
const username = name;
let eventType: EventType;
let user: User;
@@ -88,6 +91,8 @@ describe("Event types Endpoints", () => {
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
name,
username,
});
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
@@ -141,6 +146,44 @@ describe("Event types Endpoints", () => {
expect(responseBody.data.userId).toEqual(user.id);
});
it(`/GET/`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types`)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypesByViewer> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups[0]).toBeDefined();
expect(responseBody.data.eventTypeGroups[0].profile).toBeDefined();
expect(responseBody.data.eventTypeGroups?.[0]?.profile?.name).toEqual(name);
expect(responseBody.data.eventTypeGroups?.[0]?.eventTypes?.[0]?.id).toEqual(eventType.id);
expect(responseBody.data.profiles?.[0]?.name).toEqual(name);
});
it(`/GET/:username/public`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${username}/public`)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypesPublic> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].id).toEqual(eventType.id);
});
it(`/GET/:id not existing`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/event-types/1000`)
@@ -9,20 +9,23 @@ import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, Post, Body, NotFoundException } from "@nestjs/common";
import { EventType } from "@prisma/client";
import { EventTypesByViewer } from "@calcom/lib";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { EventType as AtomEventType } from "@calcom/platform-libraries";
import type { EventType as AtomEventType, EventTypesPublic } from "@calcom/platform-libraries";
import { getEventTypesByViewer } from "@calcom/platform-libraries";
import { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
@Controller({
path: "event-types",
version: "2",
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@UseGuards(PermissionsGuard)
export class EventTypesController {
constructor(private readonly eventTypesService: EventTypesService) {}
@Post("/")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
async createEventType(
@Body() body: CreateEventTypeInput,
@GetUser() user: UserWithProfile
@@ -37,6 +40,7 @@ export class EventTypesController {
@Get("/:eventTypeId")
@Permissions([EVENT_TYPE_READ])
@UseGuards(AccessTokenGuard)
async getEventType(
@Param("eventTypeId") eventTypeId: string,
@ForAtom() forAtom: boolean,
@@ -55,4 +59,34 @@ export class EventTypesController {
data: eventType,
};
}
@Get("/")
@Permissions([EVENT_TYPE_READ])
@UseGuards(AccessTokenGuard)
async getEventTypes(@GetUser() user: UserWithProfile): Promise<ApiSuccessResponse<EventTypesByViewer>> {
const eventTypes = await getEventTypesByViewer({
id: user.id,
profile: {
upId: `usr-${user.id}`,
},
});
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Get("/:username/public")
@Permissions([EVENT_TYPE_READ])
async getPublicEventTypes(
@Param("username") username: string
): Promise<ApiSuccessResponse<EventTypesPublic>> {
const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username);
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
}
@@ -4,10 +4,11 @@ import { EventTypesService } from "@/ee/event-types/services/event-types.service
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule],
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule],
providers: [EventTypesRepository, EventTypesService],
controllers: [EventTypesController],
exports: [EventTypesService, EventTypesRepository],
@@ -2,14 +2,17 @@ import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants";
import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
@Injectable()
export class EventTypesService {
constructor(
private readonly eventTypesRepository: EventTypesRepository,
private readonly membershipsRepository: MembershipsRepository
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository
) {}
async createUserEventType(userId: number, body: CreateEventTypeInput) {
@@ -30,6 +33,15 @@ export class EventTypesService {
return this.eventTypesRepository.getUserEventTypeForAtom(user, isUserOrganizationAdmin, eventTypeId);
}
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
const user = await this.usersRepository.findByUsername(username);
if (!user) {
throw new NotFoundException(`User with username "${username}" not found`);
}
return await getEventTypesPublic(user.id);
}
async createUserDefaultEventTypes(userId: number) {
const thirtyMinutes = DEFAULT_EVENT_TYPES.thirtyMinutes;
const thirtyMinutesVideo = DEFAULT_EVENT_TYPES.thirtyMinutesVideo;
@@ -44,7 +44,7 @@ export class PlatformOAuthClientDto {
@IsNumber()
organizationId!: number;
@ApiProperty({ example: new Date(), type: Date })
@ApiProperty({ example: "2024-03-23T08:33:21.851Z", type: Date })
@IsDate()
createdAt!: Date;
}
@@ -83,6 +83,14 @@ export class UsersRepository {
});
}
async findByUsername(username: string) {
return this.dbRead.prisma.user.findFirst({
where: {
username,
},
});
}
async update(userId: number, updateData: UpdateManagedPlatformUserInput) {
this.formatInput(updateData);
+44 -1
View File
@@ -506,6 +506,22 @@
}
}
}
},
"get": {
"operationId": "EventTypesController_getEventTypes",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/event-types/{eventTypeId}": {
@@ -535,6 +551,33 @@
}
}
},
"/api/v2/event-types/{username}/public": {
"get": {
"operationId": "EventTypesController_getPublicEventTypes",
"parameters": [
{
"name": "username",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/platform/gcal/oauth/auth-url": {
"get": {
"operationId": "GcalController_redirect",
@@ -1206,7 +1249,7 @@
"createdAt": {
"format": "date-time",
"type": "string",
"example": "2024-03-21T16:58:29.130Z"
"example": "2024-03-23T08:33:21.851Z"
}
},
"required": [
@@ -7,16 +7,15 @@ import { handleUserRedirection } from "@calcom/features/booking-redirect/handle-
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { DEFAULT_DARK_BRAND_COLOR, DEFAULT_LIGHT_BRAND_COLOR } from "@calcom/lib/constants";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { getEventTypesPublic } from "@calcom/lib/event-types/getEventTypesPublic";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { UserProfile } from "@calcom/types/UserProfile";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
@@ -71,58 +70,6 @@ export type UserPageProps = {
>)[];
} & EmbedProps;
export const getEventTypesWithHiddenFromDB = async (userId: number) => {
const eventTypes = await prisma.eventType.findMany({
where: {
AND: [
{
teamId: null,
},
{
OR: [
{
userId,
},
{
users: {
some: {
id: userId,
},
},
},
],
},
],
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
select: {
...baseEventTypeSelect,
metadata: true,
},
});
// map and filter metadata, exclude eventType entirely when faulty metadata is found.
// report error to exception so we don't lose the error.
return eventTypes.reduce<typeof eventTypes>((eventTypes, eventType) => {
const parsedMetadata = EventTypeMetaDataSchema.safeParse(eventType.metadata);
if (!parsedMetadata.success) {
log.error(parsedMetadata.error);
return eventTypes;
}
eventTypes.push({
...eventType,
metadata: parsedMetadata.data,
});
return eventTypes;
}, []);
};
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
@@ -220,18 +167,12 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
organization: user.profile.organization,
};
const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(user.id);
const dataFetchEnd = Date.now();
if (context.query.log === "1") {
context.res.setHeader("X-Data-Fetch-Time", `${dataFetchEnd - dataFetchStart}ms`);
}
const eventTypesRaw = eventTypesWithHidden.filter((evt) => !evt.hidden);
const eventTypes = eventTypesRaw.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}));
const eventTypes = await getEventTypesPublic(user.id);
// if profile only has one public event-type, redirect to it
if (eventTypes.length === 1 && context.query.redirect !== "false" && !outOfOffice) {
+3
View File
@@ -27,6 +27,9 @@ vi.mock("@calcom/atoms/monorepo", () => ({
},
}));
vi.mock("@calcom/lib/event-types/getEventTypesByViewer", () => ({}));
vi.mock("@calcom/lib/event-types/getEventTypesPublic", () => ({}));
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
@@ -14,8 +14,8 @@ import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-u
import { TRPCError } from "@trpc/server";
import { WEBSITE_URL } from "./constants";
import { getBookerBaseUrl } from "./getBookerUrl/server";
import { WEBSITE_URL } from "../constants";
import { getBookerBaseUrl } from "../getBookerUrl/server";
interface getEventTypeByIdProps {
eventTypeId: number;
@@ -0,0 +1,348 @@
// eslint-disable-next-line no-restricted-imports
import { orderBy } from "lodash";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { getOrgAvatarUrl, getTeamAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
const log = logger.getSubLogger({ prefix: ["viewer.eventTypes.getByViewer"] });
type User = {
id: number;
profile: {
upId: string;
};
};
type Filters = {
teamIds?: number[];
upIds?: string[];
};
export type EventTypesByViewer = Awaited<ReturnType<typeof getEventTypesByViewer>>;
export const getEventTypesByViewer = async (user: User, filters?: Filters, forRoutingForms?: boolean) => {
const userProfile = user.profile;
const profile = await ProfileRepository.findByUpId(userProfile.upId);
const parentOrgHasLockedEventTypes =
profile?.organization?.organizationSettings?.lockEventTypeCreationForUsers;
const isFilterSet = filters && hasFilter(filters);
const isUpIdInFilter = filters?.upIds?.includes(userProfile.upId);
let shouldListUserEvents = !isFilterSet || isUpIdInFilter;
// FIX: Handles the case when an upId != lightProfile - pretend like there is no filter.
// Results in {"eventTypeGroups":[],"profiles":[]} - this crashes all dependencies.
if (isFilterSet && filters?.upIds && !isUpIdInFilter) {
shouldListUserEvents = true;
}
const [profileMemberships, profileEventTypes] = await Promise.all([
MembershipRepository.findAllByUpIdIncludeTeamWithMembersAndEventTypes(
{
upId: userProfile.upId,
},
{
where: {
accepted: true,
},
}
),
shouldListUserEvents
? EventTypeRepository.findAllByUpId(
{
upId: userProfile.upId,
userId: user.id,
},
{
where: {
teamId: null,
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
}
)
: [],
]);
if (!profile) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const memberships = profileMemberships.map((membership) => ({
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
}));
log.debug(
safeStringify({
profileMemberships,
profileEventTypes,
})
);
type UserEventTypes = (typeof profileEventTypes)[number];
const mapEventType = async (eventType: UserEventTypes) => ({
...eventType,
safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined,
users: await Promise.all(
(!!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users).map(
async (u) =>
await UserRepository.enrichUserWithItsProfile({
user: u,
})
)
),
metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : null,
children: await Promise.all(
(eventType.children || []).map(async (c) => ({
...c,
users: await Promise.all(
c.users.map(
async (u) =>
await UserRepository.enrichUserWithItsProfile({
user: u,
})
)
),
}))
),
});
const userEventTypes = (await Promise.all(profileEventTypes.map(mapEventType))).filter((eventType) => {
const isAChildEvent = eventType.parentId;
// A child event only has one user
const childEventAssignee = eventType.users[0];
if (isAChildEvent && childEventAssignee.id != user.id) {
return false;
}
return true;
});
type EventTypeGroup = {
teamId?: number | null;
parentId?: number | null;
bookerUrl: string;
membershipRole?: MembershipRole | null;
profile: {
slug: (typeof profile)["username"] | null;
name: (typeof profile)["name"];
image: string;
eventTypesLockedByOrg?: boolean;
};
metadata: {
membershipCount: number;
readOnly: boolean;
};
eventTypes: typeof userEventTypes;
};
let eventTypeGroups: EventTypeGroup[] = [];
const unmanagedEventTypes = userEventTypes.filter(
(evType) => evType.schedulingType !== SchedulingType.MANAGED
);
log.debug(safeStringify({ profileMemberships, profileEventTypes, profile }));
log.debug(
"Filter Settings",
safeStringify({
isFilterSet,
isProfileIdInFilter: isUpIdInFilter,
})
);
if (shouldListUserEvents) {
const bookerUrl = await getBookerBaseUrl(profile.organizationId ?? null);
eventTypeGroups.push({
teamId: null,
bookerUrl,
membershipRole: null,
profile: {
slug: profile.username,
name: profile.name,
image: getUserAvatarUrl({
username: profile.username,
avatarUrl: profile.avatarUrl,
profile: profile,
}),
eventTypesLockedByOrg: parentOrgHasLockedEventTypes,
},
eventTypes: orderBy(unmanagedEventTypes, ["position", "id"], ["desc", "asc"]),
metadata: {
membershipCount: 1,
readOnly: false,
},
});
}
const teamMemberships = profileMemberships.map((membership) => ({
teamId: membership.team.id,
membershipRole: membership.role,
}));
const filterTeamsEventTypesBasedOnInput = async (eventType: Awaited<ReturnType<typeof mapEventType>>) => {
if (!filters || !hasFilter(filters)) {
return true;
}
return filters?.teamIds?.includes(eventType?.teamId || 0) ?? false;
};
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
await Promise.all(
memberships
.filter((mmship) => {
if (mmship.team.isOrganization) {
return false;
} else {
if (!filters || !hasFilter(filters)) {
return true;
}
return filters?.teamIds?.includes(mmship?.team?.id || 0) ?? false;
}
})
.map(async (membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
const team = {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
};
let slug;
if (forRoutingForms) {
// For Routing form we want to ensure that after migration of team to an org, the URL remains same for the team
// Once we solve this https://github.com/calcom/cal.com/issues/12399, we can remove this conditional change in slug
slug = `team/${team.slug}`;
} else {
// In an Org, a team can be accessed without /team prefix as well as with /team prefix
slug = team.slug ? (!team.parentId ? `team/${team.slug}` : `${team.slug}`) : null;
}
const eventTypes = await Promise.all(team.eventTypes.map(mapEventType));
return {
teamId: team.id,
parentId: team.parentId,
bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? null),
membershipRole:
orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: membership.role,
profile: {
image: team.parentId
? getOrgAvatarUrl({
slug: team.parent?.slug || null,
logoUrl: team.parent?.logoUrl,
requestedSlug: team.slug,
})
: getTeamAvatarUrl({
slug: team.slug,
logoUrl: team.logoUrl,
requestedSlug: team.metadata?.requestedSlug ?? null,
organizationId: team.parentId,
}),
name: team.name,
slug,
},
metadata: {
membershipCount: team.members.length,
readOnly:
membership.role ===
(team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
},
eventTypes: eventTypes
.filter(filterTeamsEventTypesBasedOnInput)
.filter((evType) => {
const res = evType.userId === null || evType.userId === user.id;
return res;
})
.filter((evType) =>
membership.role === MembershipRole.MEMBER
? evType.schedulingType !== SchedulingType.MANAGED
: true
),
};
})
)
);
const denormalizedPayload = {
eventTypeGroups,
// so we can show a dropdown when the user has teams
profiles: eventTypeGroups.map((group) => ({
...group.profile,
...group.metadata,
teamId: group.teamId,
membershipRole: group.membershipRole,
})),
};
return normalizePayload(denormalizedPayload);
/**
* Reduces the size of payload
*/
function normalizePayload(payload: typeof denormalizedPayload) {
const allUsersAcrossAllEventTypes = new Map<
number,
EventTypeGroup["eventTypes"][number]["users"][number]
>();
const eventTypeGroups = payload.eventTypeGroups.map((group) => {
return {
...group,
eventTypes: group.eventTypes.map((eventType) => {
const { users, ...rest } = eventType;
return {
...rest,
// Send userIds per event and keep the actual users object outside
userIds: users.map((user) => {
allUsersAcrossAllEventTypes.set(user.id, user);
return user.id;
}),
};
}),
};
});
return {
...payload,
allUsersAcrossAllEventTypes,
eventTypeGroups,
};
}
};
export function compareMembership(mship1: MembershipRole, mship2: MembershipRole) {
const mshipToNumber = (mship: MembershipRole) =>
Object.keys(MembershipRole).findIndex((mmship) => mmship === mship);
return mshipToNumber(mship1) > mshipToNumber(mship2);
}
@@ -0,0 +1,74 @@
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { markdownToSafeHTML } from "../markdownToSafeHTML";
const log = logger.getSubLogger({ prefix: ["getEventTypesPublic"] });
export type EventTypesPublic = Awaited<ReturnType<typeof getEventTypesPublic>>;
export async function getEventTypesPublic(userId: number) {
const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(userId);
const eventTypesRaw = eventTypesWithHidden.filter((evt) => !evt.hidden);
return eventTypesRaw.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
descriptionAsSafeHTML: markdownToSafeHTML(eventType.description),
}));
}
const getEventTypesWithHiddenFromDB = async (userId: number) => {
const eventTypes = await prisma.eventType.findMany({
where: {
AND: [
{
teamId: null,
},
{
OR: [
{
userId,
},
{
users: {
some: {
id: userId,
},
},
},
],
},
],
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
select: {
...baseEventTypeSelect,
metadata: true,
},
});
// map and filter metadata, exclude eventType entirely when faulty metadata is found.
// report error to exception so we don't lose the error.
return eventTypes.reduce<typeof eventTypes>((eventTypes, eventType) => {
const parsedMetadata = EventTypeMetaDataSchema.safeParse(eventType.metadata);
if (!parsedMetadata.success) {
log.error(parsedMetadata.error);
return eventTypes;
}
eventTypes.push({
...eventType,
metadata: parsedMetadata.data,
});
return eventTypes;
}, []);
};
+2
View File
@@ -5,3 +5,5 @@ export * from "./isBookingLimits";
export * from "./isDurationLimits";
export * from "./validateIntervalLimitOrder";
export * from "./schedules";
export * from "./event-types/getEventTypesByViewer";
export * from "./event-types/getEventTypesPublic";
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { BASE_URL, API_VERSION, V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { EventTypesByViewer } from "@calcom/platform-libraries";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../../lib/http";
export const QUERY_KEY = "get-private-events";
export const useEventTypesPrivate = () => {
const endpoint = new URL(BASE_URL);
endpoint.pathname = `api/${API_VERSION}/${V2_ENDPOINTS.eventTypes}`;
return useQuery({
queryKey: [QUERY_KEY],
queryFn: () => {
return http?.get<ApiResponse<EventTypesByViewer>>(endpoint.toString()).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<EventTypesByViewer>).data;
}
throw new Error(res.data.error.message);
});
},
});
};
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { BASE_URL, API_VERSION, V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { EventTypesPublic } from "@calcom/platform-libraries";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../../lib/http";
export const QUERY_KEY = "get-public-events";
export const useEventTypesPublic = (username: string) => {
const endpoint = new URL(BASE_URL);
endpoint.pathname = `api/${API_VERSION}/${V2_ENDPOINTS.eventTypes}/${username}/public`;
return useQuery({
queryKey: [QUERY_KEY, username],
queryFn: () => {
return http?.get<ApiResponse<EventTypesPublic>>(endpoint.toString()).then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return (res.data as ApiSuccessResponse<EventTypesPublic>).data;
}
throw new Error(res.data.error.message);
});
},
});
};
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ConnectedDestinationCalendars } from "@calcom/platform-libraries";
import type { ApiResponse } from "@calcom/platform-types";
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../lib/http";
@@ -13,7 +13,7 @@ export const useConnectedCalendars = (props: { enabled?: boolean }) => {
queryFn: () => {
return http.get<ApiResponse<ConnectedDestinationCalendars>>("/ee/calendars").then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data?.data;
return (res.data as ApiSuccessResponse<ConnectedDestinationCalendars>)?.data;
}
throw new Error(res.data.error.message);
});
+1
View File
@@ -5,3 +5,4 @@ export { BookerPlatformWrapper as Booker } from "./booker";
export { useIsPlatform } from "./hooks/useIsPlatform";
export { useAtomsContext } from "./hooks/useAtomsContext";
export { useConnectedCalendars } from "./hooks/useConnectedCalendars";
export { useEventTypesPublic } from "./hooks/event-types/useEventTypesPublic";
@@ -2,19 +2,39 @@ import { Navbar } from "@/components/Navbar";
import { Inter } from "next/font/google";
import { useState } from "react";
import { Booker } from "@calcom/atoms";
import { Booker, useEventTypesPublic } from "@calcom/atoms";
const inter = Inter({ subsets: ["latin"] });
export default function Bookings(props: { calUsername: string; calEmail: string }) {
const [bookingTitle, setBookingTitle] = useState<string | null>(null);
const [eventTypeSlug, setEventTypeSlug] = useState<string | null>(null);
const { isLoading: isLoadingEvents, data: eventTypes } = useEventTypesPublic(props.calUsername);
console.log(isLoadingEvents, eventTypes);
return (
<main
className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
<Navbar username={props.calUsername} />
<div>
<h1 className="my-4 text-2xl font-semibold">{props.calUsername} Public Booking Page</h1>
{!bookingTitle && (
{isLoadingEvents && !eventTypeSlug && <p>Loading...</p>}
{!isLoadingEvents && !eventTypeSlug && Boolean(eventTypes?.length) && (
<div className="flex flex-row gap-4">
{eventTypes?.map((event: { id: number; slug: string; title: string }) => (
<button
key={event.id}
className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
onClick={() => setEventTypeSlug(event.slug)}>
{event.slug}
</button>
))}
</div>
)}
{!bookingTitle && eventTypeSlug && (
<Booker
eventSlug="sixty-minutes-video"
username={props.calUsername ?? ""}
+7 -2
View File
@@ -11,7 +11,13 @@ export type UpdateScheduleOutputType = Awaited<
typeof import("@calcom/trpc/server/routers/viewer/availability/schedule/update.handler").updateHandler
>
>;
export { getEventTypeById } from "@calcom/lib/getEventTypeById";
export { getEventTypeById } from "@calcom/lib/event-types/getEventTypeById";
export { getEventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
export { getEventTypesPublic } from "@calcom/lib/event-types/getEventTypesPublic";
export type { EventType } from "@calcom/lib/event-types/getEventTypeById";
export type { EventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
export type { EventTypesPublic } from "@calcom/lib/event-types/getEventTypesPublic";
export type PublicEventType = Awaited<ReturnType<typeof getPublicEvent>>;
export { getPublicEvent };
@@ -26,7 +32,6 @@ export { getAvailableSlots };
export type AvailableSlotsType = Awaited<ReturnType<typeof getAvailableSlots>>;
export { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleNewRecurringBooking";
export type { EventType } from "@calcom/lib/getEventTypeById";
export { getConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
export type { ConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
export { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
+5
View File
@@ -1,19 +1,24 @@
import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional, IsString } from "class-validator";
export class GetPublicEventInput {
@IsString()
@Transform(({ value }: { value: string }) => value.toLowerCase())
@ApiProperty({ required: true })
username!: string;
@IsString()
@ApiProperty({ required: true })
eventSlug!: string;
@Transform(({ value }: { value: string }) => value === "true")
@IsBoolean()
@IsOptional()
@ApiProperty({ required: false })
isTeamEvent?: boolean;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
org?: string;
@@ -1,4 +1,4 @@
import getEventTypeById from "@calcom/lib/getEventTypeById";
import getEventTypeById from "@calcom/lib/event-types/getEventTypeById";
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../trpc";
@@ -1,29 +1,10 @@
// eslint-disable-next-line no-restricted-imports
import { orderBy } from "lodash";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { getOrgAvatarUrl, getTeamAvatarUrl, getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { getEventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
import type { PrismaClient } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TEventTypeInputSchema } from "./getByViewer.schema";
const log = logger.getSubLogger({ prefix: ["viewer.eventTypes.getByViewer"] });
type GetByViewerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
@@ -32,319 +13,14 @@ type GetByViewerOptions = {
input: TEventTypeInputSchema;
};
export const compareMembership = (mship1: MembershipRole, mship2: MembershipRole) => {
const mshipToNumber = (mship: MembershipRole) =>
Object.keys(MembershipRole).findIndex((mmship) => mmship === mship);
return mshipToNumber(mship1) > mshipToNumber(mship2);
};
export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => {
await checkRateLimitAndThrowError({
identifier: `eventTypes:getByViewer:${ctx.user.id}`,
rateLimitingType: "common",
});
const lightProfile = ctx.user.profile;
const profile = await ProfileRepository.findByUpId(lightProfile.upId);
const parentOrgHasLockedEventTypes =
profile?.organization?.organizationSettings?.lockEventTypeCreationForUsers;
const isFilterSet = input?.filters && hasFilter(input.filters);
const isUpIdInFilter = input?.filters?.upIds?.includes(lightProfile.upId);
const user = ctx.user;
const filters = input?.filters;
const forRoutingForms = input?.forRoutingForms;
let shouldListUserEvents = !isFilterSet || isUpIdInFilter;
// FIX: Handles the case when an upId != lightProfile - pretend like there is no filter.
// Results in {"eventTypeGroups":[],"profiles":[]} - this crashes all dependencies.
if (isFilterSet && input?.filters?.upIds && !isUpIdInFilter) {
shouldListUserEvents = true;
}
const [profileMemberships, profileEventTypes] = await Promise.all([
MembershipRepository.findAllByUpIdIncludeTeamWithMembersAndEventTypes(
{
upId: lightProfile.upId,
},
{
where: {
accepted: true,
},
}
),
shouldListUserEvents
? EventTypeRepository.findAllByUpId(
{
upId: lightProfile.upId,
userId: ctx.user.id,
},
{
where: {
teamId: null,
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
}
)
: [],
]);
if (!profile) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const memberships = profileMemberships.map((membership) => ({
...membership,
team: {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
},
}));
log.debug(
safeStringify({
profileMemberships,
profileEventTypes,
})
);
type UserEventTypes = (typeof profileEventTypes)[number];
const mapEventType = async (eventType: UserEventTypes) => ({
...eventType,
safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined,
users: await Promise.all(
(!!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users).map(
async (u) =>
await UserRepository.enrichUserWithItsProfile({
user: u,
})
)
),
metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : null,
children: await Promise.all(
(eventType.children || []).map(async (c) => ({
...c,
users: await Promise.all(
c.users.map(
async (u) =>
await UserRepository.enrichUserWithItsProfile({
user: u,
})
)
),
}))
),
});
const userEventTypes = (await Promise.all(profileEventTypes.map(mapEventType))).filter((eventType) => {
const isAChildEvent = eventType.parentId;
// A child event only has one user
const childEventAssignee = eventType.users[0];
if (isAChildEvent && childEventAssignee.id != ctx.user.id) {
return false;
}
return true;
});
type EventTypeGroup = {
teamId?: number | null;
parentId?: number | null;
bookerUrl: string;
membershipRole?: MembershipRole | null;
profile: {
slug: (typeof profile)["username"] | null;
name: (typeof profile)["name"];
image: string;
eventTypesLockedByOrg?: boolean;
};
metadata: {
membershipCount: number;
readOnly: boolean;
};
eventTypes: typeof userEventTypes;
};
let eventTypeGroups: EventTypeGroup[] = [];
const unmanagedEventTypes = userEventTypes.filter(
(evType) => evType.schedulingType !== SchedulingType.MANAGED
);
log.debug(safeStringify({ profileMemberships, profileEventTypes, profile }));
log.debug(
"Filter Settings",
safeStringify({
isFilterSet,
isProfileIdInFilter: isUpIdInFilter,
})
);
if (shouldListUserEvents) {
const bookerUrl = await getBookerBaseUrl(profile.organizationId ?? null);
eventTypeGroups.push({
teamId: null,
bookerUrl,
membershipRole: null,
profile: {
slug: profile.username,
name: profile.name,
image: getUserAvatarUrl({
username: profile.username,
avatarUrl: profile.avatarUrl,
profile: profile,
}),
eventTypesLockedByOrg: parentOrgHasLockedEventTypes,
},
eventTypes: orderBy(unmanagedEventTypes, ["position", "id"], ["desc", "asc"]),
metadata: {
membershipCount: 1,
readOnly: false,
},
});
}
const teamMemberships = profileMemberships.map((membership) => ({
teamId: membership.team.id,
membershipRole: membership.role,
}));
const filterTeamsEventTypesBasedOnInput = async (eventType: Awaited<ReturnType<typeof mapEventType>>) => {
if (!input?.filters || !hasFilter(input?.filters)) {
return true;
}
return input?.filters?.teamIds?.includes(eventType?.teamId || 0) ?? false;
};
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
await Promise.all(
memberships
.filter((mmship) => {
if (mmship.team.isOrganization) {
return false;
} else {
if (!input?.filters || !hasFilter(input?.filters)) {
return true;
}
return input?.filters?.teamIds?.includes(mmship?.team?.id || 0) ?? false;
}
})
.map(async (membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
const team = {
...membership.team,
metadata: teamMetadataSchema.parse(membership.team.metadata),
};
let slug;
if (input?.forRoutingForms) {
// For Routing form we want to ensure that after migration of team to an org, the URL remains same for the team
// Once we solve this https://github.com/calcom/cal.com/issues/12399, we can remove this conditional change in slug
slug = `team/${team.slug}`;
} else {
// In an Org, a team can be accessed without /team prefix as well as with /team prefix
slug = team.slug ? (!team.parentId ? `team/${team.slug}` : `${team.slug}`) : null;
}
const eventTypes = await Promise.all(team.eventTypes.map(mapEventType));
return {
teamId: team.id,
parentId: team.parentId,
bookerUrl: getBookerBaseUrlSync(team.parent?.slug ?? null),
membershipRole:
orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: membership.role,
profile: {
image: team.parentId
? getOrgAvatarUrl({
slug: team.parent?.slug || null,
logoUrl: team.parent?.logoUrl,
requestedSlug: team.slug,
})
: getTeamAvatarUrl({
slug: team.slug,
logoUrl: team.logoUrl,
requestedSlug: team.metadata?.requestedSlug ?? null,
organizationId: team.parentId,
}),
name: team.name,
slug,
},
metadata: {
membershipCount: team.members.length,
readOnly:
membership.role ===
(team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
},
eventTypes: eventTypes
.filter(filterTeamsEventTypesBasedOnInput)
.filter((evType) => {
const res = evType.userId === null || evType.userId === ctx.user.id;
return res;
})
.filter((evType) =>
membership.role === MembershipRole.MEMBER
? evType.schedulingType !== SchedulingType.MANAGED
: true
),
};
})
)
);
const denormalizedPayload = {
eventTypeGroups,
// so we can show a dropdown when the user has teams
profiles: eventTypeGroups.map((group) => ({
...group.profile,
...group.metadata,
teamId: group.teamId,
membershipRole: group.membershipRole,
})),
};
return normalizePayload(denormalizedPayload);
/**
* Reduces the size of payload
*/
function normalizePayload(payload: typeof denormalizedPayload) {
const allUsersAcrossAllEventTypes = new Map<
number,
EventTypeGroup["eventTypes"][number]["users"][number]
>();
const eventTypeGroups = payload.eventTypeGroups.map((group) => {
return {
...group,
eventTypes: group.eventTypes.map((eventType) => {
const { users, ...rest } = eventType;
return {
...rest,
// Send userIds per event and keep the actual users object outside
userIds: users.map((user) => {
allUsersAcrossAllEventTypes.set(user.id, user);
return user.id;
}),
};
}),
};
});
return {
...payload,
allUsersAcrossAllEventTypes,
eventTypeGroups,
};
}
return await getEventTypesByViewer(user, filters, forRoutingForms);
};
@@ -1,3 +1,4 @@
import { compareMembership } from "@calcom/lib/event-types/getEventTypesByViewer";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import { prisma } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client";
@@ -6,8 +7,6 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { compareMembership } from "../eventTypes/getByViewer.handler";
type GetByViewerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
+3
View File
@@ -65,6 +65,9 @@ vi.mock("@calcom/atoms/monorepo", () => ({
},
}));
vi.mock("@calcom/lib/event-types/getEventTypesByViewer", () => ({}));
vi.mock("@calcom/lib/event-types/getEventTypesPublic", () => ({}));
expect.extend({
tabToBeDisabled(received) {
const isDisabled = received.classList.contains("pointer-events-none");