feat: banner and avatars for Booker atom (#17821)

* init

* added bannerUrl to output

* cleanup

* fixup

* fix response DTOs

* remove logs

* send back avatar url for user

* enable passing avatar urls for managed user

* dont include urls for platform

* remove logs

* add team banner

* update docs

* nit

* PR feedback

* fix type error

* add prop to pass in bannerUrl

* update DTOs

* update tests

* fix tests

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2024-12-09 14:35:57 +00:00
1 parent ae1b844bea
commit 9334b67f2c
18 files changed
+173 -25

No files matched your search

@@ -159,6 +159,7 @@ describe("OAuth Client Users Endpoints", () => {
email: userEmail,
timeZone: "incorrect-time-zone",
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
await request(app.getHttpServer())
@@ -191,6 +192,7 @@ describe("OAuth Client Users Endpoints", () => {
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
@@ -211,6 +213,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
@@ -265,6 +268,7 @@ describe("OAuth Client Users Endpoints", () => {
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
@@ -535,6 +539,7 @@ describe("OAuth Client Users Endpoints", () => {
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
@@ -213,6 +213,7 @@ export class OAuthClientUsersController {
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
locale: user.locale as Locales,
avatarUrl: user.avatarUrl,
};
}
}
@@ -1,7 +1,7 @@
import { Locales } from "@/lib/enums/locales";
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsEnum, IsOptional } from "class-validator";
import { IsEnum, IsOptional, IsUrl } from "class-validator";
export class ManagedUserOutput {
@ApiProperty({ example: 1 })
@@ -36,4 +36,14 @@ export class ManagedUserOutput {
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
locale?: Locales;
@IsUrl()
@IsOptional()
@ApiPropertyOptional({
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
})
avatarUrl?: string | null;
}
@@ -66,9 +66,11 @@ export class OAuthClientUsersService {
const updatedUser = await this.userRepository.update(user.id, {
name: body.name,
locale: body.locale,
avatarUrl: body.avatarUrl,
});
user.locale = updatedUser.locale;
user.name = updatedUser.name;
user.avatarUrl = updatedUser.avatarUrl;
}
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
@@ -137,6 +137,8 @@ describe("Organizations Team Endpoints", () => {
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
slug: "team-created-via-api",
bio: "This is our test team created via API",
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
@@ -144,6 +146,8 @@ describe("Organizations Team Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API");
expect(teamCreatedViaApi.slug).toEqual("team-created-via-api");
expect(teamCreatedViaApi.bio).toEqual("This is our test team created via API");
expect(teamCreatedViaApi.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
user.id,
@@ -171,6 +175,9 @@ describe("Organizations Team Endpoints", () => {
.patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.send({
name: "Team created via API Updated",
weekStart: "Monday",
logoUrl: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
bannerUrl: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
} satisfies CreateOrgTeamDto)
.expect(200)
.then((response) => {
@@ -178,6 +185,13 @@ describe("Organizations Team Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API Updated");
expect(teamCreatedViaApi.weekStart).toEqual("Monday");
expect(teamCreatedViaApi.logoUrl).toEqual(
"https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png"
);
expect(teamCreatedViaApi.bannerUrl).toEqual(
"https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png"
);
expect(teamCreatedViaApi.parentId).toEqual(org.id);
});
});
@@ -1,16 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class CreateOrgTeamDto {
@IsString()
@Length(1)
@ApiProperty({ description: "Name of the team", example: "CalTeam", required: true })
readonly name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
required: false,
})
readonly logoUrl?: string;
@IsOptional()
@@ -59,6 +68,12 @@ export class CreateOrgTeamDto {
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
required: false,
})
readonly bannerUrl?: string;
@IsOptional()
@@ -67,10 +82,23 @@ export class CreateOrgTeamDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
required: false,
default: "Europe/London",
})
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
required: false,
default: "Sunday",
})
readonly weekStart?: string = "Sunday";
@IsOptional()
@@ -1,16 +1,25 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class UpdateOrgTeamDto {
@IsString()
@Length(1)
@ApiPropertyOptional({ description: "Name of the team", example: "CalTeam", required: false })
readonly name?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
required: false,
})
readonly logoUrl?: string;
@IsOptional()
@@ -59,6 +68,12 @@ export class UpdateOrgTeamDto {
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
required: false,
})
readonly bannerUrl?: string;
@IsOptional()
@@ -67,10 +82,23 @@ export class UpdateOrgTeamDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
required: false,
default: "Europe/London",
})
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
required: false,
default: "Sunday",
})
readonly weekStart?: string = "Sunday";
@IsOptional()
@@ -24,7 +24,13 @@ export class OrganizationsEventTypesRepository {
slug: eventTypeSlug,
},
},
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
include: {
users: true,
schedule: true,
hosts: true,
destinationCalendar: true,
team: { select: { bannerUrl: true } },
},
});
}
@@ -33,7 +39,13 @@ export class OrganizationsEventTypesRepository {
where: {
teamId,
},
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
include: {
users: true,
schedule: true,
hosts: true,
destinationCalendar: true,
team: { select: { bannerUrl: true } },
},
});
}
@@ -3,7 +3,7 @@ import { OrganizationsEventTypesRepository } from "@/modules/organizations/repos
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, Host, DestinationCalendar } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import { SchedulingType, Team } from "@prisma/client";
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
@@ -12,6 +12,7 @@ type EventTypeRelations = {
schedule: Schedule | null;
hosts: Host[];
destinationCalendar?: DestinationCalendar | null;
team?: Pick<Team, "bannerUrl"> | null;
};
export type DatabaseTeamEventType = EventType & EventTypeRelations;
@@ -66,6 +67,7 @@ type Input = Pick<
| "eventName"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
| "team"
>;
@Injectable()
@@ -95,6 +97,7 @@ export class OutputOrganizationsEventTypesService {
ownerId: userId,
parentEventTypeId: parentId,
schedulingType: databaseEventType.schedulingType,
bannerUrl: databaseEventType?.team?.bannerUrl,
assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
};
}
@@ -129,9 +132,14 @@ export class OutputOrganizationsEventTypesService {
name: databaseUser?.name || "",
mandatory: databaseHost.isFixed,
priority: getPriorityLabel(databaseHost.priority || 2),
avatarUrl: databaseUser?.avatarUrl,
});
} else {
transformedHosts.push({ userId: databaseHost.userId, name: databaseUser?.name || "" });
transformedHosts.push({
userId: databaseHost.userId,
name: databaseUser?.name || "",
avatarUrl: databaseUser?.avatarUrl,
});
}
}
@@ -1,7 +1,7 @@
import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl } from "class-validator";
export type WeekDay = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
export type TimeFormat = 12 | 24;
@@ -42,4 +42,14 @@ export class CreateManagedUserInput {
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
locale?: Locales;
@IsUrl()
@IsOptional()
@ApiPropertyOptional({
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
})
avatarUrl?: string;
}
@@ -1,8 +1,8 @@
import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
export class UpdateManagedUserInput {
@IsString()
@@ -40,4 +40,14 @@ export class UpdateManagedUserInput {
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
locale?: Locales;
@IsUrl()
@IsOptional()
@ApiPropertyOptional({
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
})
avatarUrl?: string;
}
+1 -1
View File
@@ -335,7 +335,7 @@ const BookerComponent = ({
<BookerSection
area="meta"
className="max-w-screen flex w-full flex-col md:w-[var(--booker-meta-width)]">
{!hideEventTypeDetails && orgBannerUrl && !isPlatform && (
{!hideEventTypeDetails && orgBannerUrl && (
<img
loading="eager"
className="-mb-9 h-16 object-cover object-top ltr:rounded-tl-md rtl:rounded-tr-md sm:h-auto"
@@ -127,14 +127,12 @@ export const EventMeta = ({
)}
{!isPending && !!event && (
<m.div {...fadeInUp} layout transition={{ ...fadeInUp.transition, delay: 0.3 }}>
{!isPlatform && (
<EventMembers
schedulingType={event.schedulingType}
users={event.users}
profile={event.profile}
entity={event.entity}
/>
)}
<EventMembers
schedulingType={event.schedulingType}
users={event.users}
profile={event.profile}
entity={event.entity}
/>
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>{event?.title}</EventTitle>
{(event.description || translatedDescription) && (
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
@@ -1,3 +1,4 @@
import { useIsPlatform } from "@calcom/atoms/monorepo";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import type { BookerEvent } from "@calcom/features/bookings/types";
@@ -22,6 +23,7 @@ export const EventMembers = ({ schedulingType, users, profile, entity }: EventMe
const username = useBookerStore((state) => state.username);
const isDynamic = !!(username && username.indexOf("+") > -1);
const isEmbed = useIsEmbed();
const isPlatform = useIsPlatform();
const showMembers = schedulingType !== SchedulingType.ROUND_ROBIN;
const shownUsers = showMembers ? users : [];
@@ -56,9 +58,11 @@ export const EventMembers = ({ schedulingType, users, profile, entity }: EventMe
items={[
...orgOrTeamAvatarItem,
...shownUsers.map((user) => ({
href: `${getBookerBaseUrlSync(user.profile?.organization?.slug ?? null)}/${
user.profile?.username
}?redirect=false`,
href: isPlatform
? null
: `${getBookerBaseUrlSync(user.profile?.organization?.slug ?? null)}/${
user.profile?.username
}?redirect=false`,
alt: user.name || "",
title: user.name || "",
image: getUserAvatarUrl(user),
@@ -77,6 +77,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
locationUrl?: string;
view?: VIEW_TYPE;
metadata?: Record<string, string>;
bannerUrl?: string;
};
type VIEW_TYPE = keyof typeof BookerLayouts;
@@ -95,7 +96,7 @@ export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomPro
export const BookerPlatformWrapper = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
) => {
const { view = "MONTH_VIEW" } = props;
const { view = "MONTH_VIEW", bannerUrl } = props;
const layout = BookerLayouts[view];
const { clientId } = useAtomsContext();
@@ -482,6 +483,7 @@ export const BookerPlatformWrapper = (
bookerForm={bookerForm}
event={event}
schedule={schedule}
orgBannerUrl={bannerUrl ?? event.data?.bannerUrl}
bookerLayout={bookerLayout}
verifyCode={undefined}
isPlatform
@@ -29,7 +29,7 @@ import {
import type { BookerPlatformWrapperAtomProps } from "../../booker/BookerPlatformWrapper";
export function transformApiEventTypeForAtom(
eventType: Omit<EventTypeOutput_2024_06_14, "ownerId">,
eventType: Omit<EventTypeOutput_2024_06_14, "ownerId"> & { bannerUrl?: string },
entity: BookerPlatformWrapperAtomProps["entity"] | undefined,
defaultFormValues: BookerPlatformWrapperAtomProps["defaultFormValues"] | undefined
) {
@@ -132,7 +132,7 @@ export function transformApiTeamEventTypeForAtom(
isDynamic: false,
profile: {
username: "team",
name: "team",
name: "Team",
weekStart: "Sunday",
image: "",
brandColor: null,
@@ -171,6 +171,7 @@ export function transformApiTeamEventTypeForAtom(
},
})),
users: hosts.map((host) => ({
...host,
metadata: undefined,
bookerUrl: getBookerBaseUrlSync(null),
profile: {
@@ -54,6 +54,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
body: JSON.stringify({
email: emailOne,
name: "John Jones",
avatarUrl:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=3023&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
);
@@ -83,6 +85,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
body: JSON.stringify({
email: emailTwo,
name: "Jane Doe",
avatarUrl:
"https://plus.unsplash.com/premium_photo-1668319915476-5cc7717e00f1?q=80&w=3164&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
}),
}
);
@@ -139,6 +143,7 @@ async function createTeam(orgId: number, name: string) {
},
body: JSON.stringify({
name,
bannerUrl: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
}),
}
);
@@ -362,6 +362,11 @@ export class TeamEventTypeResponseHost extends TeamEventTypeHostInput {
@IsString()
@DocsProperty({ example: "John Doe" })
name!: string;
@IsString()
@IsOptional()
@DocsProperty({ example: "https://cal.com/api/avatar/d95949bc-ccb1-400f-acf6-045c51a16856.png" })
avatarUrl?: string | null;
}
export class EventTypeOutput_2024_06_14 extends BaseEventTypeOutput_2024_06_14 {
@@ -413,4 +418,9 @@ export class TeamEventTypeOutput_2024_06_14 extends BaseEventTypeOutput_2024_06_
@IsBoolean()
@DocsProperty()
hideCalendarEventDetails?: boolean;
@IsString()
@IsOptional()
@DocsProperty()
bannerUrl?: string;
}