feat: Implement orgs/teams logoUrls (#13699)

* feat: Implement orgs/teams logoUrls

* Use getAvatarUrl in team/[slug]

* Extract uploadLogo to standalone file for reuse

* Allow removing the team/organisation logo

* fix: API build fail due to server-only, removed
This commit is contained in:
Alex van Andel
2024-02-16 10:35:53 -03:00
committed by GitHub
parent 477e102388
commit c08365f09c
14 changed files with 81 additions and 37 deletions
@@ -425,7 +425,7 @@ export const EventTypeList = ({
<MemoizedItem type={type} group={group} readOnly={readOnly} />
<div className="mt-4 hidden sm:mt-0 sm:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
{type.team && !isManagedEventType && (
{!!type.teamId && !isManagedEventType && (
<UserAvatarGroup
className="relative right-3"
size="sm"
+8 -11
View File
@@ -13,7 +13,7 @@ import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getOrgAvatarUrl, getTeamAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import useTheme from "@calcom/lib/hooks/useTheme";
@@ -157,6 +157,11 @@ function TeamPage({
</div>
);
const profileImageSrc =
isValidOrgDomain || team.metadata?.isOrganization
? getOrgAvatarUrl({ slug: currentOrgDomain, logoUrl: team.logoUrl })
: getTeamAvatarUrl({ slug: team.slug, logoUrl: team.logoUrl, organizationId: team.parent?.id });
return (
<>
<HeadSeo
@@ -166,22 +171,14 @@ function TeamPage({
title: markdownStrippedBio,
profile: {
name: `${team.name}`,
image: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`,
image: profileImageSrc,
},
}}
/>
<main className="dark:bg-darkgray-50 bg-subtle mx-auto max-w-3xl rounded-md px-4 pb-12 pt-12">
<div className="mx-auto mb-8 max-w-3xl text-center">
<div className="relative">
<Avatar
alt={teamName}
imageSrc={
isValidOrgDomain
? `/org/${currentOrgDomain}/avatar.png`
: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`
}
size="lg"
/>
<Avatar alt={teamName} imageSrc={profileImageSrc} size="lg" />
</div>
<p className="font-cal text-emphasis mb-2 text-2xl tracking-wider" data-testid="team-name">
{team.parent && `${team.parent.name} `}
@@ -325,7 +325,7 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
<Controller
control={form.control}
name="logo"
render={({ field: { value } }) => {
render={({ field: { value, onChange } }) => {
const showRemoveLogoButton = !!value;
return (
@@ -340,9 +340,7 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
target="avatar"
id="avatar-upload"
buttonMsg={t("upload_logo")}
handleAvatarChange={(newLogo) => {
form.setValue("logo", newLogo, { shouldDirty: true });
}}
handleAvatarChange={onChange}
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
imageSrc={value ?? undefined}
/>
@@ -350,7 +348,7 @@ const TeamProfileForm = ({ team }: TeamProfileFormProps) => {
<Button
color="secondary"
onClick={() => {
form.setValue("logo", null, { shouldDirty: true });
onChange(null);
}}>
{t("remove")}
</Button>
+2 -2
View File
@@ -29,7 +29,7 @@ export function getTeamAvatarUrl(
team: Pick<Team, "slug"> & {
organizationId?: number | null;
logoUrl?: string | null;
requestedSlug: string | null;
requestedSlug?: string | null;
}
) {
if (team.logoUrl) {
@@ -42,7 +42,7 @@ export function getTeamAvatarUrl(
export const getOrgAvatarUrl = (
org: Pick<Team, "slug"> & {
logoUrl?: string | null;
requestedSlug: string | null;
requestedSlug?: string | null;
}
) => {
if (org.logoUrl) {
@@ -87,6 +87,7 @@ export async function getTeamWithMembers(args: {
name: true,
slug: true,
...(!!includeTeamLogo ? { logo: true } : {}),
logoUrl: true,
bio: true,
hideBranding: true,
hideBookATeamMember: true,
@@ -232,6 +233,7 @@ export async function getTeamWithMembers(args: {
return {
...teamWithoutInviteTokens,
...(teamWithoutInviteTokens.logoUrl ? { logo: teamWithoutInviteTokens.logoUrl } : {}),
/** To prevent breaking we only return non-email attached token here, if we have one */
inviteToken: inviteTokens.find(
(token) =>
@@ -99,12 +99,6 @@ export class EventTypeRepository {
const profileId = lookupTarget.type === LookupTarget.User ? null : lookupTarget.id;
const select = {
...eventTypeSelect,
// TODO: As required by getByViewHandler - Make it configurable
team: {
select: {
id: true,
},
},
hashedLink: true,
users: { select: userSelect },
children: {
@@ -103,11 +103,6 @@ export class MembershipRepository {
eventTypes: {
select: {
...eventTypeSelect,
team: {
select: {
id: true,
},
},
hashedLink: true,
users: { select: userSelect },
children: {
+33
View File
@@ -0,0 +1,33 @@
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
export const uploadLogo = async ({
teamId,
logo: data,
}: {
teamId: number;
logo: string;
}): Promise<string> => {
const objectKey = uuidv4();
await prisma.avatar.upsert({
where: {
teamId_userId: {
teamId,
userId: 0,
},
},
create: {
teamId,
data,
objectKey,
},
update: {
data,
objectKey,
},
});
return `/api/avatar/${objectKey}.png`;
};
@@ -207,7 +207,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) =>
if (!input?.filters || !hasFilter(input?.filters)) {
return true;
}
return input?.filters?.teamIds?.includes(eventType?.team?.id || 0) ?? false;
return input?.filters?.teamIds?.includes(eventType?.teamId || 0) ?? false;
};
eventTypeGroups = ([] as EventTypeGroup[]).concat(
eventTypeGroups,
@@ -262,6 +262,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) =>
})
: getTeamAvatarUrl({
slug: team.slug,
logoUrl: team.logoUrl,
requestedSlug: team.metadata?.requestedSlug ?? null,
organizationId: team.parentId,
}),
@@ -3,6 +3,7 @@ import type { Prisma } from "@prisma/client";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { uploadLogo } from "@calcom/lib/server/uploadLogo";
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
@@ -60,7 +61,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const data: Prisma.TeamUpdateArgs["data"] = {
name: input.name,
logo: input.logo,
calVideoLogo: input.calVideoLogo,
bio: input.bio,
hideBranding: input.hideBranding,
@@ -74,6 +74,16 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
metadata: mergeMetadata({ ...input.metadata }),
};
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
data.logo = input.logo;
data.logoUrl = await uploadLogo({
logo: input.logo,
teamId: currentOrgId,
});
} else if (typeof input.logo !== "undefined" && !input.logo) {
data.logo = data.logoUrl = null;
}
if (input.slug) {
if (
IS_TEAM_BILLING_ENABLED &&
@@ -1,5 +1,6 @@
import { z } from "zod";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const ZUpdateInputSchema = z.object({
@@ -13,9 +14,9 @@ export const ZUpdateInputSchema = z.object({
bio: z.string().optional(),
logo: z
.string()
.transform(async (val) => await resizeBase64Image(val))
.optional()
.nullable()
.transform((v) => v || null),
.nullable(),
calVideoLogo: z
.string()
.optional()
@@ -37,10 +37,11 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
const metadata = teamMetadataSchema.parse(mmship.team.metadata);
return !metadata?.isOrganization;
})
.map(({ team: { inviteTokens, ..._team }, ...membership }) => ({
.map(({ team: { inviteTokens, logo, logoUrl, ..._team }, ...membership }) => ({
role: membership.role,
accepted: membership.accepted,
..._team,
logo: logoUrl || logo,
metadata: teamMetadataSchema.parse(_team.metadata),
/** To prevent breaking we only return non-email attached token here, if we have one */
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${_team.id}`),
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { uploadLogo } from "@calcom/lib/server/uploadLogo";
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
import { prisma } from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -46,7 +47,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const data: Prisma.TeamUpdateArgs["data"] = {
name: input.name,
logo: input.logo,
bio: input.bio,
hideBranding: input.hideBranding,
isPrivate: input.isPrivate,
@@ -56,6 +56,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
theme: input.theme,
};
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
data.logo = input.logo;
data.logoUrl = await uploadLogo({ teamId: input.id, logo: input.logo });
} else if (typeof input.logo !== "undefined" && !input.logo) {
data.logo = data.logoUrl = null;
}
if (
input.slug &&
IS_TEAM_BILLING_ENABLED &&
@@ -1,12 +1,17 @@
import { z } from "zod";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import slugify from "@calcom/lib/slugify";
export const ZUpdateInputSchema = z.object({
id: z.number(),
bio: z.string().optional(),
name: z.string().optional(),
logo: z.string().nullable().optional(),
logo: z
.string()
.transform(async (val) => await resizeBase64Image(val))
.nullable()
.optional(),
slug: z
.string()
.transform((val) => slugify(val.trim()))