feat: org team app install (#15704)

* feat: remove dropdown from app-store and redirect to new-app-install-flow

* removed extra code

* fix: account select loading issue

* removed getPaymentCredential (unused)

* fix: only redirect after the app has been added to all the event-types

* remove overflowIndicatorStyles

* refactor getUserAdminTeams

* send teamId instead of id

* seperate locations component

* for conferencing apps skip select account page

* refactor InstallAppButtonChild

* installing conferencing apps shows locations dropdown in configure step

* send location data to the handler

* send location data to the handler
* add the newly installed to the locations dropdown (prefillLocation)

* fix: type errors

* fix: handle es-lint errors

* fix: app is added again on submit

* only add app if not already added

* fix: type erros

* filter out managed events for now

* fix: show installed count badge

* remove 2 toast message

* feat: added tests for conferencing apps

* fix: loading indicator

* Update apps/web/pages/apps/installation/[[...step]].tsx

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

* Update apps/web/pages/apps/installation/[[...step]].tsx

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

* move `getUserAdminTeams` to `UserRepository`

* fix: zoom and jelly not redirecting to the new app install flow

* fix: typo

* add `installableOnTeams` prop

* split `configureStepCard` into smaller components

* fix: step count

* fix: show loading indicator until the new page is loaded

* fix: organizer_default_conferencing_app not visible in a team

* Introduce QueryClient to app card tests

* fix: typo

* add installableOnTeams prop

* fix: tests

* seated events shouldn't have multiple locations #15174

* fix: o365 callback

* fix: office365_video not redirecting to event-types step

* Revert "fix: o365 callback"

This reverts commit bba841035ea34f8c31201b64c77221b8d8c3e626.

* add TEAM_SELECT_STEP

* add apps to orgs and their sub-teams initial commit

* undo team select step

* small ui fix

* fix: wrong step numbers

* fix: dont allow app installation without cretendialId

* fix: don't show acme team as it cannot have any events

* refactor useAddAppMutation

* removed console.log

* added comment

* added comment

* move locationOptions from getServerSideProps a trpc query

* fix: failing tests

* Update conferencingApps.e2e.ts

* refactor useAddAppMutation

* refactor useAddAppMutation 2

* fix: test failing

* fix: unit test

* Revert "fix: unit test"

This reverts commit 6d74032211d094478c6d7cf9aedbce696dfb768d.

* fix: failing test

* Increase test timeout for conferencing app tests

* fix: write separate tests for each conferencing app to prevent hitting 6000 ms timeout

* improved tests naming

* fix: correct message and translation key #15657

* fix: write separate tests for each analytics app to prevent hitting 6000 ms timeout

* fix: analytics apps test

* attempt to fix failing tests

* fix typo

* refactor

* update: replace text-gray with text-stuble

- works with light mode too

* update: use userRepository.getUserAdminTeams

* Merge branch 'main' into feat/org-team-app-install-2

* fix: after merge conflict from app router migration

* remove consoles

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Somay Chauhan
2024-09-17 04:40:56 +00:00
committed by GitHub
co-authored by Joe Au-Yeung Omar López Joe Au-Yeung Udit Takkar
parent f86a5db843
commit d27ccd6f44
16 changed files with 658 additions and 474 deletions
@@ -8,4 +8,4 @@ export class CreateOrganizationAttributeOptionInput {
@IsString()
@IsNotEmpty()
readonly slug!: string;
}
}
@@ -3,34 +3,53 @@ import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationAttributesService {
constructor(private readonly organizationAttributesRepository: OrganizationAttributesRepository) {}
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
const attribute = await this.organizationAttributesRepository.createOrganizationAttribute(organizationId, data);
const attribute = await this.organizationAttributesRepository.createOrganizationAttribute(
organizationId,
data
);
return attribute;
}
async getOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.organizationAttributesRepository.getOrganizationAttribute(organizationId, attributeId);
const attribute = await this.organizationAttributesRepository.getOrganizationAttribute(
organizationId,
attributeId
);
return attribute;
}
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
const attributes = await this.organizationAttributesRepository.getOrganizationAttributes(organizationId, skip, take);
const attributes = await this.organizationAttributesRepository.getOrganizationAttributes(
organizationId,
skip,
take
);
return attributes;
}
async updateOrganizationAttribute(organizationId: number, attributeId: string, data: UpdateOrganizationAttributeInput) {
const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute(organizationId, attributeId, data);
return attribute;
}
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute(organizationId, attributeId);
async updateOrganizationAttribute(
organizationId: number,
attributeId: string,
data: UpdateOrganizationAttributeInput
) {
const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute(
organizationId,
attributeId,
data
);
return attribute;
}
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute(
organizationId,
attributeId
);
return attribute;
}
}
@@ -4,14 +4,27 @@ import React, { useState } from "react";
import { classNames } from "@calcom/lib";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Team, User } from "@calcom/prisma/client";
import { Avatar, StepCard } from "@calcom/ui";
import type { User } from "@calcom/prisma/client";
import { StepCard } from "@calcom/ui";
import { Avatar } from "@calcom/ui";
import type { TTeams } from "~/apps/installation/[[...step]]/step-view";
export type PersonalAccountProps = Pick<User, "id" | "avatarUrl" | "name"> & { alreadyInstalled: boolean };
type AccountStepCardProps = {
teams?: TTeams;
personalAccount: PersonalAccountProps;
onSelect: (id?: number) => void;
loading: boolean;
installableOnTeams: boolean;
};
type AccountSelectorProps = {
avatar?: string;
name: string;
alreadyInstalled: boolean;
onClick: () => void;
onClick?: () => void;
loading: boolean;
testId: string;
};
@@ -29,13 +42,13 @@ const AccountSelector: FC<AccountSelectorProps> = ({
return (
<div
className={classNames(
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1 transition",
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
(alreadyInstalled || loading) && "cursor-not-allowed",
selected && loading && "bg-muted animate-pulse"
)}
data-testid={testId}
onClick={() => {
if (!alreadyInstalled && !loading) {
if (!alreadyInstalled && !loading && onClick) {
setSelected(true);
onClick();
}
@@ -45,28 +58,14 @@ const AccountSelector: FC<AccountSelectorProps> = ({
imageSrc={getPlaceholderAvatar(avatar, name)} // if no image, use default avatar
size="sm"
/>
<div className="text-md pt-0.5 font-medium text-gray-500">
<div className="text-md text-subtle pt-0.5 font-medium">
{name}
{alreadyInstalled ? <span className="ml-1 text-sm text-gray-400">{t("already_installed")}</span> : ""}
{alreadyInstalled ? <span className="text-subtle ml-2 text-sm">({t("already_installed")})</span> : ""}
</div>
</div>
);
};
export type PersonalAccountProps = Pick<User, "id" | "avatarUrl" | "name"> & { alreadyInstalled: boolean };
export type TeamsProp = (Pick<Team, "id" | "name" | "logoUrl"> & {
alreadyInstalled: boolean;
})[];
type AccountStepCardProps = {
teams?: TeamsProp;
personalAccount: PersonalAccountProps;
onSelect: (id?: number) => void;
loading: boolean;
installableOnTeams: boolean;
};
export const AccountsStepCard: FC<AccountStepCardProps> = ({
teams,
personalAccount,
@@ -77,7 +76,7 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({
const { t } = useLocale();
return (
<StepCard>
<div className="text-sm font-medium text-gray-400">{t("install_app_on")}</div>
<div className="text-subtle text-sm font-medium">{t("install_app_on")}</div>
<div className={classNames("mt-2 flex flex-col gap-2 ")}>
<AccountSelector
testId="install-app-button-personal"
@@ -12,12 +12,12 @@ import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolv
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { Button, Form, Icon } from "@calcom/ui";
import { Button, Form, Icon, Avatar } from "@calcom/ui";
import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper";
import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings";
import type { TEventType, TEventTypesForm } from "~/apps/installation/[[...step]]/step-view";
import type { TEventType, TEventTypesForm, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view";
export type TFormType = {
id: number;
@@ -35,7 +35,7 @@ export type ConfigureStepCardProps = {
loading?: boolean;
isConferencing: boolean;
formPortalRef: React.RefObject<HTMLDivElement>;
eventTypes: TEventType[] | undefined;
eventTypeGroups: TEventTypeGroup[];
setConfigureStep: Dispatch<SetStateAction<boolean>>;
handleSetUpLater: () => void;
};
@@ -56,6 +56,7 @@ type EventTypeAppSettingsFormProps = Pick<
locations?: LocationObject[];
}) => void;
};
type TUpdatedEventTypesStatus = { id: number; updated: boolean }[][];
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
function EventTypeAppSettingsForm(props, ref) {
@@ -116,82 +117,139 @@ const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSetti
}
);
export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
loading,
formPortalRef,
eventTypes,
setConfigureStep,
handleSetUpLater,
const EventTypeGroup = ({
groupIndex,
eventTypeGroups,
setUpdatedEventTypesStatus,
submitRefs,
...props
}: ConfigureStepCardProps & {
groupIndex: number;
setUpdatedEventTypesStatus: Dispatch<SetStateAction<TUpdatedEventTypesStatus>>;
submitRefs: Array<React.RefObject<HTMLButtonElement>>;
}) => {
const { t } = useLocale();
const { control, getValues } = useFormContext<TEventTypesForm>();
const { control } = useFormContext<TEventTypesForm>();
const { fields, update } = useFieldArray({
control,
name: "eventTypes",
name: `eventTypeGroups.${groupIndex}.eventTypes`,
keyName: "fieldId",
});
return (
<div className="ml-2 flex flex-col space-y-6">
{fields.map(
(field, index) =>
field.selected && (
<EventTypeAppSettingsForm
key={field.fieldId}
eventType={field}
loading={props.loading}
handleDelete={() => {
const eventTypeDb = eventTypeGroups[groupIndex].eventTypes?.find(
(eventType) => eventType.id == field.id
);
update(index, {
...field,
selected: false,
metadata: eventTypeDb?.metadata,
bookingFields: eventTypeDb?.bookingFields,
...(eventTypeDb?.locations && { locations: eventTypeDb.locations }),
});
const submitRefs = useRef<Array<React.RefObject<HTMLButtonElement>>>([]);
submitRefs.current = fields.map(
(_ref, index) => (submitRefs.current[index] = React.createRef<HTMLButtonElement>())
setUpdatedEventTypesStatus((prev) => {
const res = [...prev];
res[groupIndex] = res[groupIndex].filter((item) => !(item.id === field.id));
if (!res.some((item) => item.length > 0)) {
props.setConfigureStep(false);
}
return res;
});
}}
onSubmit={(data) => {
update(index, { ...field, ...data });
setUpdatedEventTypesStatus((prev) => {
const res = [...prev];
res[groupIndex] = res[groupIndex].map((item) =>
item.id === field.id ? { ...item, updated: true } : item
);
return res;
});
}}
ref={submitRefs[index]}
{...props}
/>
)
)}
</div>
);
};
export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
const { loading, formPortalRef, handleSetUpLater } = props;
const { t } = useLocale();
const { control, watch } = useFormContext<TEventTypesForm>();
const { fields } = useFieldArray({
control,
name: "eventTypeGroups",
keyName: "fieldId",
});
const eventTypeGroups = watch("eventTypeGroups");
const submitRefs = useRef<Array<Array<React.RefObject<HTMLButtonElement>>>>([]);
submitRefs.current = eventTypeGroups.reduce(
(arr: Array<Array<React.RefObject<HTMLButtonElement>>>, field) => {
const res = field.eventTypes
.filter((eventType) => eventType.selected)
.map((_ref) => React.createRef<HTMLButtonElement>());
return [...arr, res];
},
[]
);
const mainForSubmitRef = useRef<HTMLButtonElement>(null);
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState(
fields.filter((field) => field.selected).map((field) => ({ id: field.id, updated: false }))
);
const [submit, setSubmit] = useState(false);
const allUpdated = updatedEventTypesStatus.every((item) => item.updated);
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState<TUpdatedEventTypesStatus>(
eventTypeGroups.reduce((arr: Array<{ id: number; updated: boolean }[]>, field) => {
const selectedEventTypes = field.eventTypes
.filter((eventType) => eventType.selected)
.map((eventType) => ({ id: eventType.id as number, updated: false }));
useEffect(() => {
setUpdatedEventTypesStatus((prev) =>
prev.filter((state) => fields.some((field) => field.id === state.id && field.selected))
);
if (!fields.some((field) => field.selected)) {
setConfigureStep(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields]);
return [...arr, selectedEventTypes];
}, [])
);
const [submit, setSubmit] = useState(false);
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
useEffect(() => {
if (submit && allUpdated && mainForSubmitRef.current) {
mainForSubmitRef.current?.click();
setSubmit(false);
}
}, [submit, allUpdated, getValues, mainForSubmitRef]);
}, [submit, allUpdated, mainForSubmitRef]);
return (
formPortalRef?.current &&
createPortal(
<div className="mt-8">
<div className="flex flex-col space-y-6">
{fields.map((field, index) => {
return (
field.selected && (
<EventTypeAppSettingsForm
key={field.fieldId}
eventType={field}
loading={loading}
handleDelete={() => {
const eventMetadataDb = eventTypes?.find(
(eventType) => eventType.id == field.id
)?.metadata;
update(index, { ...field, selected: false, metadata: eventMetadataDb });
}}
onSubmit={(data) => {
update(index, { ...field, ...data });
setUpdatedEventTypesStatus((prev) =>
prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item))
);
}}
ref={submitRefs.current[index]}
{...props}
{fields.map((group, groupIndex) => (
<div key={group.fieldId}>
{eventTypeGroups[groupIndex].eventTypes.some((eventType) => eventType.selected === true) && (
<div className="mb-2 mt-4 flex items-center">
<Avatar
alt=""
imageSrc={group.image} // if no image, use default avatar
size="md"
className="inline-flex justify-center"
/>
)
);
})}
</div>
<p className="text-subtle block">{group.slug}</p>
</div>
)}
<EventTypeGroup
groupIndex={groupIndex}
setUpdatedEventTypesStatus={setUpdatedEventTypesStatus}
submitRefs={submitRefs.current[groupIndex]}
{...props}
/>
</div>
))}
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
Save
</button>
@@ -200,7 +258,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
type="button"
data-testid="configure-step-save"
onClick={() => {
submitRefs.current.reverse().map((ref) => ref.current?.click());
submitRefs.current.map((group) => group?.map((ref) => ref.current?.click()));
setSubmit(true);
}}
loading={loading}>
@@ -5,9 +5,9 @@ import { useFieldArray, useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { ScrollableArea, Badge, Button } from "@calcom/ui";
import { ScrollableArea, Badge, Button, Avatar } from "@calcom/ui";
import type { TEventType, TEventTypesForm } from "~/apps/installation/[[...step]]/step-view";
import type { TEventType, TEventTypesForm, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view";
type EventTypesCardProps = {
userName: string;
@@ -15,61 +15,7 @@ type EventTypesCardProps = {
handleSetUpLater: () => void;
};
export const EventTypesStepCard: FC<EventTypesCardProps> = ({
setConfigureStep,
userName,
handleSetUpLater,
}) => {
const { t } = useLocale();
const { control } = useFormContext<TEventTypesForm>();
const { fields, update } = useFieldArray({
control,
name: "eventTypes",
keyName: "fieldId",
});
return (
<div>
<div className="sm:border-subtle bg-default mt-10 border dark:bg-black sm:rounded-md">
<ScrollableArea className="rounded-md">
<ul className="border-subtle max-h-97 !static w-full divide-y">
{fields.map((field, index) => (
<EventTypeCard
handleSelect={() => update(index, { ...field, selected: !field.selected })}
userName={userName}
key={field.fieldId}
{...field}
/>
))}
</ul>
</ScrollableArea>
</div>
<Button
className="text-md mt-6 w-full justify-center"
data-testid="save-event-types"
onClick={() => {
setConfigureStep(true);
}}
disabled={!fields.some((field) => field.selected === true)}>
{t("save")}
</Button>
<div className="flex w-full flex-row justify-center">
<Button
color="minimal"
data-testid="set-up-later"
onClick={(event) => {
event.preventDefault();
handleSetUpLater();
}}
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
{t("set_up_later")}
</Button>
</div>
</div>
);
};
type EventTypeGroupProps = { groupIndex: number; userName: string } & TEventTypeGroup;
type EventTypeCardProps = TEventType & { userName: string; handleSelect: () => void };
@@ -95,7 +41,7 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
return (
<div
data-testid={`select-event-type-${id}`}
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3 transition"
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
onClick={() => handleSelect()}>
<input
id={`${id}`}
@@ -129,3 +75,98 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
</div>
);
};
const EventTypeGroup: FC<EventTypeGroupProps> = ({ groupIndex, userName, ...props }) => {
const { control } = useFormContext<TEventTypesForm>();
const { fields, update } = useFieldArray({
control,
name: `eventTypeGroups.${groupIndex}.eventTypes`,
keyName: "fieldId",
});
return (
<div className="mt-10">
<div className="mb-2 flex items-center">
<Avatar
alt=""
imageSrc={props.image} // if no image, use default avatar
size="md"
className="mt-1 inline-flex justify-center"
/>
<p className="block pl-2 text-sm">{props.slug}</p>
</div>
<div className="sm:border-subtle bg-default border dark:bg-black sm:rounded-md">
<ScrollableArea className="rounded-md">
<ul className="border-subtle max-h-97 !static w-full divide-y">
{fields.length > 0 ? (
fields.map((field, index) => (
<EventTypeCard
key={`${field.fieldId}`}
handleSelect={() => {
update(index, { ...field, selected: !field.selected });
}}
userName={userName}
{...field}
/>
))
) : (
<div className="text-subtle bg-muted w-full p-2 text-center text-sm">Team has no Events</div>
)}
</ul>
</ScrollableArea>
</div>
</div>
);
};
export const EventTypesStepCard: FC<EventTypesCardProps> = ({
setConfigureStep,
userName,
handleSetUpLater,
}) => {
const { t } = useLocale();
const { control, watch } = useFormContext<TEventTypesForm>();
const { fields } = useFieldArray({
control,
name: "eventTypeGroups",
keyName: "fieldId",
});
const eventTypeGroups = watch("eventTypeGroups") || [];
return (
<div>
{fields.map(
(field, index) =>
!field.isOrganisation && (
<EventTypeGroup key={field.fieldId} groupIndex={index} userName={userName} {...field} />
)
)}
<Button
className="text-md mt-6 w-full justify-center"
data-testid="save-event-types"
onClick={() => {
setConfigureStep(true);
}}
disabled={
!eventTypeGroups.some((field) => field.eventTypes.some((eventType) => eventType.selected === true))
}>
{t("save")}
</Button>
<div className="flex w-full flex-row justify-center">
<Button
color="minimal"
data-testid="set-up-later"
onClick={(event) => {
event.preventDefault();
handleSetUpLater();
}}
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
{t("set_up_later")}
</Button>
</div>
</div>
);
};
+6 -6
View File
@@ -2,7 +2,6 @@ import type { GetServerSidePropsContext } from "next";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { AppCategories } from "@calcom/prisma/enums";
@@ -15,13 +14,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const session = await getServerSession({ req });
let appStore, userAdminTeams: UserAdminTeams;
let appStore, userAdminTeamsIds: number[];
if (session?.user?.id) {
userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams);
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeamsIds);
} else {
appStore = await getAppRegistry();
userAdminTeams = [];
userAdminTeamsIds = [];
}
const categoryQuery = appStore.map(({ categories }) => ({
@@ -45,7 +45,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return b.count - a.count;
}),
appStore,
userAdminTeams,
userAdminTeams: userAdminTeamsIds,
trpcState: ssr.dehydrate(),
},
};
@@ -1,3 +1,4 @@
import type { Prisma } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { z } from "zod";
@@ -10,134 +11,171 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { STEPS } from "~/apps/installation/[[...step]]/constants";
import type { OnboardingPageProps, TEventType } from "~/apps/installation/[[...step]]/step-view";
// Redirect Error map to give context on edge cases, this is for the devs, never shown to users
const ERROR_MESSAGES = {
appNotFound: "App not found",
userNotAuthed: "User is not logged in",
userNotFound: "User from session not found",
appNotExtendsEventType: "App does not extend EventTypes",
userNotInTeam: "User is not in provided team",
} as const;
import type { OnboardingPageProps, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view";
const getUser = async (userId: number) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
avatarUrl: true,
name: true,
username: true,
teams: {
where: {
accepted: true,
team: {
members: {
some: {
userId,
role: {
in: ["ADMIN", "OWNER"],
},
},
},
},
},
select: {
team: {
select: {
id: true,
name: true,
logoUrl: true,
parent: {
select: {
logoUrl: true,
name: true,
},
},
},
},
},
},
},
});
const userAdminTeams = await UserRepository.getUserAdminTeams(userId);
if (!user) {
throw new Error(ERROR_MESSAGES.userNotFound);
if (!userAdminTeams?.id) {
return null;
}
const teams = user.teams.map(({ team }) => ({
let teams = userAdminTeams.teams.map(({ team }) => ({
...team,
logoUrl: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
}));
const orgTeam = teams.find((team) => team.isOrganization === true);
if (orgTeam?.id) {
teams = teams.filter((team) => team?.parent?.id !== orgTeam.id);
}
return {
...user,
...userAdminTeams,
teams,
};
};
const getOrgSubTeams = async (parentId: number) => {
const teams = await prisma.team.findMany({
where: {
parentId,
},
select: {
id: true,
name: true,
logoUrl: true,
isOrganization: true,
parent: {
select: {
logoUrl: true,
name: true,
id: true,
},
},
},
});
return teams.map((team) => ({
...team,
logoUrl: team.parent
? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name)
: getPlaceholderAvatar(team.logoUrl, team.name),
}));
};
const getAppBySlug = async (appSlug: string) => {
const app = await prisma.app.findUnique({
where: { slug: appSlug, enabled: true },
select: { slug: true, keys: true, enabled: true, dirName: true },
});
if (!app) throw new Error(ERROR_MESSAGES.appNotFound);
return app;
};
const getEventTypes = async (userId: number, teamId?: number) => {
const eventTypes = (
await prisma.eventType.findMany({
const getEventTypes = async (userId: number, teamIds?: number[]) => {
const eventTypeSelect: Prisma.EventTypeSelect = {
id: true,
description: true,
durationLimits: true,
metadata: true,
length: true,
title: true,
position: true,
recurringEvent: true,
requiresConfirmation: true,
team: { select: { slug: true } },
schedulingType: true,
teamId: true,
users: { select: { username: true } },
seatsPerTimeSlot: true,
slug: true,
locations: true,
userId: true,
destinationCalendar: true,
bookingFields: true,
};
let eventTypeGroups: TEventTypeGroup[] | null = [];
if (teamIds && teamIds.length > 0) {
const teams = await prisma.team.findMany({
where: {
id: {
in: teamIds,
},
isOrganization: false,
},
select: {
id: true,
description: true,
durationLimits: true,
metadata: true,
length: true,
title: true,
position: true,
recurringEvent: true,
requiresConfirmation: true,
team: { select: { slug: true } },
schedulingType: true,
teamId: true,
users: { select: { username: true } },
seatsPerTimeSlot: true,
name: true,
logoUrl: true,
slug: true,
locations: true,
userId: true,
destinationCalendar: true,
bookingFields: true,
isOrganization: true,
eventTypes: {
select: eventTypeSelect,
},
},
/**
* filter out managed events for now
* @todo: can install apps to managed event types
*/
where: teamId ? { teamId } : { userId, parent: null, teamId: null },
})
).sort((eventTypeA, eventTypeB) => {
return eventTypeB.position - eventTypeA.position;
});
});
eventTypeGroups = teams.map((team) => ({
teamId: team.id,
slug: team.slug,
name: team.name,
isOrganisation: team.isOrganization,
image: getPlaceholderAvatar(team.logoUrl, team.name),
eventTypes: team.eventTypes
.map((item) => ({
...item,
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
selected: false,
locations: item.locations as unknown as LocationObject[],
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
}))
.sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position),
}));
} else {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
select: {
id: true,
username: true,
name: true,
avatarUrl: true,
eventTypes: {
where: {
teamId: null,
},
select: eventTypeSelect,
},
},
});
if (eventTypes.length === 0) {
return [];
if (user) {
eventTypeGroups.push({
userId: user.id,
slug: user.username,
name: user.name,
image: getPlaceholderAvatar(user.avatarUrl, user.name),
eventTypes: user.eventTypes
.map((item) => ({
...item,
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${
item.slug
}`,
selected: false,
locations: item.locations as unknown as LocationObject[],
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
}))
.sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position),
});
}
}
return eventTypes.map((item) => ({
...item,
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
selected: false,
locations: item.locations as unknown as LocationObject[],
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
}));
return eventTypeGroups;
};
const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]) => {
@@ -161,136 +199,128 @@ const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: n
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
try {
let eventTypes: TEventType[] | null = null;
const { req, query, params } = context;
const stepsEnum = z.enum(STEPS);
const parsedAppSlug = z.coerce.string().parse(query?.slug);
const parsedStepParam = z.coerce.string().parse(params?.step);
const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId);
const _ = stepsEnum.parse(parsedStepParam);
const session = await getServerSession({ req });
const locale = await getLocale(context.req);
const app = await getAppBySlug(parsedAppSlug);
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
const extendsEventType = appMetadata?.extendsFeature === "EventType";
const { req, res, query, params } = context;
let eventTypeGroups: TEventTypeGroup[] | null = null;
let isOrg = false;
const stepsEnum = z.enum(STEPS);
const parsedAppSlug = z.coerce.string().parse(query?.slug);
const parsedStepParam = z.coerce.string().parse(params?.step);
const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId);
const _ = stepsEnum.parse(parsedStepParam);
const session = await getServerSession({ req, res });
if (!session?.user?.id) return { redirect: { permanent: false, destination: "/auth/login" } };
const locale = await getLocale(context.req);
const app = await getAppBySlug(parsedAppSlug);
if (!app) return { redirect: { permanent: false, destination: "/apps" } };
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
const extendsEventType = appMetadata?.extendsFeature === "EventType";
const isConferencing = isConferencingApp(appMetadata.categories);
const showEventTypesStep = extendsEventType || isConferencing;
console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep);
const isConferencing = isConferencingApp(appMetadata.categories);
const showEventTypesStep = extendsEventType || isConferencing;
if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed);
const user = await getUser(session.user.id);
if (!user) return { redirect: { permanent: false, destination: "/apps" } };
const user = await getUser(session.user.id);
let userTeams = user.teams;
const hasTeams = Boolean(userTeams.length);
const userTeams = user.teams;
const hasTeams = Boolean(userTeams.length);
const appInstalls = await getAppInstallsBySlug(
parsedAppSlug,
user.id,
userTeams.map(({ id }) => id)
);
if (parsedTeamIdParam) {
const isUserMemberOfTeam = userTeams.some((team) => team.id === parsedTeamIdParam);
if (!isUserMemberOfTeam) {
throw new Error(ERROR_MESSAGES.userNotInTeam);
}
}
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
if (!showEventTypesStep) {
return {
redirect: {
permanent: false,
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
},
};
}
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
if (isConferencing) {
const destinationCalendar = await prisma.destinationCalendar.findFirst({
where: {
userId: user.id,
eventTypeId: null,
},
});
for (let index = 0; index < eventTypes.length; index++) {
let eventType = eventTypes[index];
if (!eventType.destinationCalendar) {
eventType = { ...eventType, destinationCalendar };
}
eventTypes[index] = eventType;
}
}
if (eventTypes.length === 0) {
return {
redirect: {
permanent: false,
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
},
};
}
}
const personalAccount = {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
};
const teamsWithIsAppInstalled = hasTeams
? userTeams.map((team) => ({
...team,
alreadyInstalled: appInstalls.some(
(install) => Boolean(install.teamId) && install.teamId === team.id
),
}))
: [];
let credentialId = null;
if (parsedTeamIdParam) {
credentialId =
appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null;
} else {
credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null;
}
return {
props: {
...(await serverSideTranslations(locale, ["common"])),
app,
appMetadata,
showEventTypesStep,
step: parsedStepParam,
teams: teamsWithIsAppInstalled,
personalAccount,
eventTypes,
teamId: parsedTeamIdParam ?? null,
userName: user.username,
credentialId,
isConferencing,
// conferencing apps dont support team install
installableOnTeams: !isConferencing,
} as OnboardingPageProps,
};
} catch (err) {
if (err instanceof z.ZodError) {
if (parsedTeamIdParam) {
const currentTeam = userTeams.find((team) => team.id === parsedTeamIdParam);
if (!currentTeam?.id) {
return { redirect: { permanent: false, destination: "/apps" } };
}
if (err instanceof Error) {
switch (err.message) {
case ERROR_MESSAGES.userNotAuthed:
return { redirect: { permanent: false, destination: "/auth/login" } };
case ERROR_MESSAGES.userNotFound:
return { redirect: { permanent: false, destination: "/auth/login" } };
default:
return { redirect: { permanent: false, destination: "/apps" } };
}
} else {
return { redirect: { permanent: false, destination: "/apps" } };
if (currentTeam.isOrganization) {
const subTeams = await getOrgSubTeams(parsedTeamIdParam);
userTeams = [...userTeams, ...subTeams];
isOrg = true;
}
}
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
if (!showEventTypesStep) {
return {
redirect: {
permanent: false,
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
},
};
}
if (isOrg) {
const teamIds = userTeams.map((item) => item.id);
eventTypeGroups = await getEventTypes(user.id, teamIds);
} else if (parsedTeamIdParam) {
eventTypeGroups = await getEventTypes(user.id, [parsedTeamIdParam]);
} else {
eventTypeGroups = await getEventTypes(user.id);
}
if (isConferencing && eventTypeGroups) {
const destinationCalendar = await prisma.destinationCalendar.findFirst({
where: {
userId: user.id,
eventTypeId: null,
},
});
eventTypeGroups.forEach((group) => {
group.eventTypes = group.eventTypes.map((eventType) => {
if (!eventType.destinationCalendar) {
return { ...eventType, destinationCalendar };
}
return eventType;
});
});
}
}
const appInstalls = await getAppInstallsBySlug(
parsedAppSlug,
user.id,
userTeams.map(({ id }) => id)
);
const personalAccount = {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
};
const teamsWithIsAppInstalled = hasTeams
? userTeams.map((team) => ({
...team,
alreadyInstalled: appInstalls.some(
(install) => Boolean(install.teamId) && install.teamId === team.id
),
}))
: [];
let credentialId = null;
if (parsedTeamIdParam) {
credentialId = appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null;
} else {
credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null;
}
// dont allow app installation without cretendialId
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP && !credentialId) {
return { redirect: { permanent: false, destination: "/apps" } };
}
return {
props: {
...(await serverSideTranslations(locale, ["common"])),
app,
appMetadata,
showEventTypesStep,
step: parsedStepParam,
teams: teamsWithIsAppInstalled,
personalAccount,
eventTypeGroups,
teamId: parsedTeamIdParam ?? null,
userName: user.username,
credentialId,
isConferencing,
isOrg,
// conferencing apps dont support team install
installableOnTeams: !isConferencing,
} as OnboardingPageProps,
};
};
@@ -16,6 +16,7 @@ import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Team } from "@calcom/prisma/client";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
@@ -24,7 +25,7 @@ import { Form, Steps, showToast } from "@calcom/ui";
import { HttpError } from "@lib/core/http/error";
import type { PersonalAccountProps, TeamsProp } from "@components/apps/installation/AccountsStepCard";
import type { PersonalAccountProps } from "@components/apps/installation/AccountsStepCard";
import { AccountsStepCard } from "@components/apps/installation/AccountsStepCard";
import { ConfigureStepCard } from "@components/apps/installation/ConfigureStepCard";
import { EventTypesStepCard } from "@components/apps/installation/EventTypesStepCard";
@@ -42,10 +43,20 @@ export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
bookingFields?: LocationFormValues["bookingFields"];
};
export type TEventTypesForm = {
export type TEventTypeGroup = {
teamId?: number;
userId?: number | null;
slug?: string | null;
name?: string | null;
image: string;
isOrganisation?: boolean;
eventTypes: TEventType[];
};
export type TEventTypesForm = {
eventTypeGroups: TEventTypeGroup[];
};
type StepType = (typeof STEPS)[number];
type StepObj = Record<
@@ -57,17 +68,22 @@ type StepObj = Record<
}
>;
export type TTeams = (Pick<Team, "id" | "name" | "logoUrl" | "isOrganization"> & {
alreadyInstalled: boolean;
})[];
export type OnboardingPageProps = {
appMetadata: AppMeta;
step: StepType;
teams?: TeamsProp;
teams?: TTeams;
personalAccount: PersonalAccountProps;
eventTypes?: TEventType[];
eventTypeGroups?: TEventTypeGroup[];
userName: string;
credentialId?: number;
showEventTypesStep: boolean;
isConferencing: boolean;
installableOnTeams: boolean;
isOrg: boolean;
};
type TUpdateObject = {
@@ -82,7 +98,7 @@ const OnboardingPage = ({
teams,
personalAccount,
appMetadata,
eventTypes,
eventTypeGroups,
userName,
credentialId,
showEventTypesStep,
@@ -133,7 +149,7 @@ const OnboardingPage = ({
const formMethods = useForm<TEventTypesForm>({
defaultValues: {
eventTypes,
eventTypeGroups,
},
});
const mutation = useAddAppMutation(null, {
@@ -147,9 +163,9 @@ const OnboardingPage = ({
});
useEffect(() => {
eventTypes && formMethods.setValue("eventTypes", eventTypes);
eventTypeGroups && formMethods.setValue("eventTypeGroups", eventTypeGroups);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypes]);
}, [eventTypeGroups]);
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async (data) => {
@@ -222,34 +238,38 @@ const OnboardingPage = ({
form={formMethods}
id="outer-event-type-form"
handleSubmit={async (values) => {
const mutationPromises = values?.eventTypes
.filter((eventType) => eventType.selected)
.map((value: TEventType) => {
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (
checkForMultiplePaymentApps(value.metadata as z.infer<typeof EventTypeMetaDataSchema>)
)
throw new Error(t("event_setup_multiple_payment_apps_error"));
if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) {
throw new Error(t("seats_and_no_show_fee_error"));
}
let updateObject: TUpdateObject = { id: value.id };
if (isConferencing) {
updateObject = {
...updateObject,
locations: value.locations,
bookingFields: value.bookingFields ? value.bookingFields : undefined,
};
} else {
updateObject = {
...updateObject,
metadata: value.metadata,
};
}
let mutationPromises: ReturnType<typeof updateMutation.mutateAsync>[] = [];
for (const group of values.eventTypeGroups) {
const promises = group.eventTypes
.filter((eventType) => eventType.selected)
.map((value: TEventType) => {
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (
checkForMultiplePaymentApps(value.metadata as z.infer<typeof EventTypeMetaDataSchema>)
)
throw new Error(t("event_setup_multiple_payment_apps_error"));
if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) {
throw new Error(t("seats_and_no_show_fee_error"));
}
let updateObject: TUpdateObject = { id: value.id };
if (isConferencing) {
updateObject = {
...updateObject,
locations: value.locations,
bookingFields: value.bookingFields ? value.bookingFields : undefined,
};
} else {
updateObject = {
...updateObject,
metadata: value.metadata,
};
}
return updateMutation.mutateAsync(updateObject);
});
return updateMutation.mutateAsync(updateObject);
});
mutationPromises = [...mutationPromises, ...promises];
}
try {
await Promise.all(mutationPromises);
router.push("/event-types");
@@ -272,28 +292,30 @@ const OnboardingPage = ({
/>
)}
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
eventTypes &&
Boolean(eventTypes?.length) && (
eventTypeGroups &&
Boolean(eventTypeGroups?.length) && (
<EventTypesStepCard
setConfigureStep={setConfigureStep}
userName={userName}
handleSetUpLater={handleSetUpLater}
/>
)}
{currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && (
<ConfigureStepCard
slug={appMetadata.slug}
categories={appMetadata.categories}
credentialId={credentialId}
userName={userName}
loading={updateMutation.isPending}
formPortalRef={formPortalRef}
setConfigureStep={setConfigureStep}
eventTypes={eventTypes}
handleSetUpLater={handleSetUpLater}
isConferencing={isConferencing}
/>
)}
{currentStep === AppOnboardingSteps.CONFIGURE_STEP &&
formPortalRef.current &&
eventTypeGroups && (
<ConfigureStepCard
slug={appMetadata.slug}
categories={appMetadata.categories}
credentialId={credentialId}
userName={userName}
loading={updateMutation.isPending}
formPortalRef={formPortalRef}
setConfigureStep={setConfigureStep}
eventTypeGroups={eventTypeGroups}
handleSetUpLater={handleSetUpLater}
isConferencing={isConferencing}
/>
)}
</Form>
</div>
</div>
@@ -138,7 +138,7 @@ test.describe("check non-oAuth link-based conferencing apps using the new flow",
const user = await users.create();
await user.apiLogin();
const eventTypes = await user.getUserEventsAsOwner();
const eventTypeIds = eventTypes.map((item) => item.id).filter((item, index) => index < 2);
const eventTypeIds = eventTypes.map((item) => item.id).filter((item, index) => index < 1);
await appsPage.installConferencingAppNewFlow(app, eventTypeIds);
await appsPage.verifyConferencingAppNew(app, eventTypeIds);
});
+2 -2
View File
@@ -73,8 +73,7 @@ export function createAppsFixture(page: Page) {
},
installConferencingAppNewFlow: async (app: TApp, eventTypeIds: number[]) => {
await page.goto("apps/categories/conferencing");
await page.getByTestId(`app-store-app-card-${app.slug}`).click();
await page.goto(`apps/${app.slug}`);
await page.getByTestId("install-app-button").click();
await page.waitForURL(`apps/installation/event-types?slug=${app.slug}`);
@@ -82,6 +81,7 @@ export function createAppsFixture(page: Page) {
await page.click(`[data-testid="select-event-type-${id}"]`);
}
await page.click(`[data-testid="save-event-types"]`);
page.waitForLoadState("networkidle");
for (let eindex = 0; eindex < eventTypeIds.length; eindex++) {
if (!app.organizerInputPlaceholder) continue;
@@ -265,7 +265,7 @@ test.describe("Stripe integration skip true", () => {
});
});
test.describe("Stripe integration with the new app install flow skip flase", () => {
test.describe("Stripe integration with the new app install flow skip false", () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
@@ -29,20 +29,7 @@ const createOAuthAppCredential = async (
if (state?.teamId) {
// Check that the user belongs to the team
const team = await prisma.team.findFirst({
where: {
id: state.teamId,
members: {
some: {
userId: req.session?.user.id,
accepted: true,
},
},
},
select: { id: true, members: { select: { userId: true } } },
});
if (!team) throw new Error("User does not belong to the team");
await throwIfNotHaveAdminAccessToTeam({ teamId: state?.teamId ?? null, userId });
return await prisma.credential.create({
data: {
@@ -54,8 +41,6 @@ const createOAuthAppCredential = async (
});
}
await throwIfNotHaveAdminAccessToTeam({ teamId: state?.teamId ?? null, userId });
return await prisma.credential.create({
data: {
type: appData.type,
@@ -11,7 +11,8 @@ export const throwIfNotHaveAdminAccessToTeam = async ({
if (!teamId) {
return;
}
const teamsUserHasAdminAccessFor = await UserRepository.getUserAdminTeams(userId);
const userAdminTeams = await UserRepository.getUserAdminTeams(userId);
const teamsUserHasAdminAccessFor = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((id) => id === teamId);
if (!hasAdminAccessToTeam) {
@@ -67,8 +67,8 @@ function useAddAppMutation(_type: App["type"] | null, options?: UseAddAppMutatio
const state: IntegrationOAuthCallbackState = {
onErrorReturnTo,
fromApp: true,
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
...(teamId && { teamId }),
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
...(returnTo && { returnTo }),
...(defaultInstall && { defaultInstall }),
};
+52 -24
View File
@@ -516,7 +516,58 @@ export class UserRepository {
},
});
}
static async getUserAdminTeams(userId: number) {
return prisma.user.findFirst({
where: {
id: userId,
},
select: {
id: true,
avatarUrl: true,
name: true,
username: true,
teams: {
where: {
accepted: true,
OR: [
{
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
{
team: {
parent: {
members: {
some: {
id: userId,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
},
},
},
},
],
},
select: {
team: {
select: {
id: true,
name: true,
logoUrl: true,
isOrganization: true,
parent: {
select: {
logoUrl: true,
name: true,
id: true,
},
},
},
},
},
},
},
});
}
static async isAdminOfTeamOrParentOrg({ userId, teamId }: { userId: number; teamId: number }) {
const membershipQuery = {
members: {
@@ -543,29 +594,6 @@ export class UserRepository {
return !!teams.length;
}
static async getUserAdminTeams(userId: number): Promise<number[]> {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
select: {
teams: {
where: {
accepted: true,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
select: { teamId: true },
},
},
});
const teamIds = [];
for (const team of user?.teams || []) {
teamIds.push(team.teamId);
}
return teamIds;
}
static async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) {
return await prisma.user.findUnique({
where: {
@@ -15,6 +15,7 @@ type AppCredentialsByTypeOptions = {
export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentialsByTypeOptions) => {
const { user } = ctx;
const userAdminTeams = await UserRepository.getUserAdminTeams(ctx.user.id);
const userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
const credentials = await prisma.credential.findMany({
where: {
@@ -22,7 +23,7 @@ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentials
{ userId: user.id },
{
teamId: {
in: userAdminTeams,
in: userAdminTeamsIds,
},
},
],
@@ -34,6 +35,6 @@ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentials
// return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id);
return {
credentials,
userAdminTeams,
userAdminTeams: userAdminTeamsIds,
};
};