feat: Enable Conferencing Apps for Team Events [CAL-1925] (#10383)
Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>pull/10536/head
parent
a9344115d2
commit
617e665004
|
@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { z } from "zod";
|
||||
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { Badge, ListItemText, Avatar } from "@calcom/ui";
|
||||
|
@ -96,7 +97,7 @@ export default function AppListCard(props: AppListCardProps) {
|
|||
className="mr-2"
|
||||
alt={credentialOwner.name || "Nameless"}
|
||||
size="xs"
|
||||
imageSrc={credentialOwner.avatar}
|
||||
imageSrc={getPlaceholderAvatar(credentialOwner.avatar, credentialOwner?.name as string)}
|
||||
/>
|
||||
{credentialOwner.name}
|
||||
</div>
|
||||
|
|
|
@ -4,6 +4,7 @@ import React, { useState } from "react";
|
|||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton, AppDependencyComponent } from "@calcom/app-store/components";
|
||||
import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
|
||||
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
|
||||
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
|
@ -53,6 +54,7 @@ const Component = ({
|
|||
descriptionItems,
|
||||
isTemplate,
|
||||
dependencies,
|
||||
concurrentMeetings,
|
||||
}: Parameters<typeof App>[0]) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
|
||||
|
@ -189,6 +191,7 @@ const Component = ({
|
|||
appCategories={categories}
|
||||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
multiInstall
|
||||
{...props}
|
||||
/>
|
||||
|
@ -227,6 +230,7 @@ const Component = ({
|
|||
userAdminTeams={appDbQuery.data?.userAdminTeams}
|
||||
addAppMutationInput={{ type, variant, slug }}
|
||||
credentials={appDbQuery.data?.credentials}
|
||||
concurrentMeetings={concurrentMeetings}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -381,6 +385,7 @@ export default function App(props: {
|
|||
isTemplate?: boolean;
|
||||
disableInstall?: boolean;
|
||||
dependencies?: string[];
|
||||
concurrentMeetings?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Shell smallHeading isPublic hideHeadingOnMobile heading={<ShellHeading />} backPath="/apps" withoutSeo>
|
||||
|
@ -406,6 +411,7 @@ const InstallAppButtonChild = ({
|
|||
appCategories,
|
||||
multiInstall,
|
||||
credentials,
|
||||
concurrentMeetings,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
|
@ -413,6 +419,7 @@ const InstallAppButtonChild = ({
|
|||
appCategories: string[];
|
||||
multiInstall?: boolean;
|
||||
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
|
||||
concurrentMeetings?: boolean;
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
|
@ -426,10 +433,7 @@ const InstallAppButtonChild = ({
|
|||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!userAdminTeams?.length ||
|
||||
appCategories.some((category) => ["calendar", "conferencing"].includes(category))
|
||||
) {
|
||||
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
|
|
|
@ -245,7 +245,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
if (eventLocationType?.organizerInputType) {
|
||||
newLocation = details[Object.keys(details)[0]];
|
||||
}
|
||||
setLocationMutation.mutate({ bookingId: booking.id, newLocation });
|
||||
setLocationMutation.mutate({ bookingId: booking.id, newLocation, details });
|
||||
};
|
||||
|
||||
// Getting accepted recurring dates to show
|
||||
|
@ -283,6 +283,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
|
|
|
@ -104,6 +104,7 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||
phone: z.string().optional().nullable(),
|
||||
locationAddress: z.string().optional(),
|
||||
credentialId: z.number().optional(),
|
||||
teamName: z.string().optional(),
|
||||
locationLink: z
|
||||
.string()
|
||||
.optional()
|
||||
|
@ -298,8 +299,19 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||
}
|
||||
|
||||
if (values.credentialId) {
|
||||
details = { ...details, credentialId: values.credentialId };
|
||||
details = {
|
||||
...details,
|
||||
credentialId: values.credentialId,
|
||||
};
|
||||
}
|
||||
|
||||
if (values.teamName) {
|
||||
details = {
|
||||
...details,
|
||||
teamName: values.teamName,
|
||||
};
|
||||
}
|
||||
|
||||
saveLocation(newLocation, details);
|
||||
setShowLocationModal(false);
|
||||
setSelectedLocation?.(undefined);
|
||||
|
@ -344,6 +356,11 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
|
|||
onChange={(val) => {
|
||||
if (val) {
|
||||
locationFormMethods.setValue("locationType", val.value);
|
||||
if (val.credential) {
|
||||
locationFormMethods.setValue("credentialId", val.credential.id);
|
||||
locationFormMethods.setValue("teamName", val.credential.team?.name);
|
||||
}
|
||||
|
||||
locationFormMethods.unregister([
|
||||
"locationLink",
|
||||
"locationAddress",
|
||||
|
|
|
@ -306,7 +306,9 @@ export const EventSetupTab = (
|
|||
)}
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="ms-1 line-clamp-1 text-sm">{eventLabel}</span>
|
||||
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
||||
location.teamName ? `(${location.teamName})` : ""
|
||||
}`}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { GroupBase, Props, SingleValue } from "react-select";
|
|||
import { components } from "react-select";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
import { Select } from "@calcom/ui";
|
||||
|
@ -12,6 +13,7 @@ export type LocationOption = {
|
|||
icon?: string;
|
||||
disabled?: boolean;
|
||||
address?: string;
|
||||
credential?: CredentialDataWithTeamName;
|
||||
};
|
||||
|
||||
export type SingleValueLocationOption = SingleValue<LocationOption>;
|
||||
|
|
|
@ -77,6 +77,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
|
|||
descriptionItems={source.data?.items as string[] | undefined}
|
||||
isTemplate={data.isTemplate}
|
||||
dependencies={data.dependencies}
|
||||
concurrentMeetings={data.concurrentMeetings}
|
||||
// tos="https://zoom.us/terms"
|
||||
// privacy="https://zoom.us/privacy"
|
||||
body={
|
||||
|
|
|
@ -169,7 +169,7 @@ const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListP
|
|||
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && variant === "conferencing" && (
|
||||
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
|
|
|
@ -103,6 +103,7 @@ export type FormValues = {
|
|||
phone?: string;
|
||||
hostDefault?: string;
|
||||
credentialId?: number;
|
||||
teamName?: string;
|
||||
}[];
|
||||
customInputs: CustomInputParsed[];
|
||||
schedule: number | null;
|
||||
|
|
|
@ -27,6 +27,7 @@ export const metadata = {
|
|||
},
|
||||
key: { apikey: randomString(12) },
|
||||
dirName: "huddle01video",
|
||||
concurrentMeetings: true,
|
||||
} as AppMeta;
|
||||
|
||||
export default metadata;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
|
@ -13,12 +14,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
if (!req.session?.user?.id) {
|
||||
return res.status(401).json({ message: "You must be logged in to do this" });
|
||||
}
|
||||
const { teamId } = req.query;
|
||||
|
||||
await throwIfNotHaveAdminAccessToTeam({ teamId: Number(teamId) ?? null, userId: req.session.user.id });
|
||||
const installForObject = teamId ? { teamId: Number(teamId) } : { userId: req.session.user.id };
|
||||
|
||||
const appType = "huddle01_video";
|
||||
try {
|
||||
const alreadyInstalled = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: appType,
|
||||
userId: req.session.user.id,
|
||||
...installForObject,
|
||||
},
|
||||
});
|
||||
if (alreadyInstalled) {
|
||||
|
@ -28,7 +34,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
data: {
|
||||
type: appType,
|
||||
key: {},
|
||||
userId: req.session.user.id,
|
||||
...installForObject,
|
||||
appId: "huddle01",
|
||||
},
|
||||
});
|
||||
|
|
|
@ -24,6 +24,7 @@ export const metadata = {
|
|||
},
|
||||
},
|
||||
dirName: "jitsivideo",
|
||||
concurrentMeetings: true,
|
||||
} as AppMeta;
|
||||
|
||||
export default metadata;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
|
||||
|
@ -13,12 +14,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
if (!req.session?.user?.id) {
|
||||
return res.status(401).json({ message: "You must be logged in to do this" });
|
||||
}
|
||||
const { teamId } = req.query;
|
||||
|
||||
await throwIfNotHaveAdminAccessToTeam({ teamId: Number(teamId) ?? null, userId: req.session.user.id });
|
||||
|
||||
const installForObject = teamId ? { teamId: Number(teamId) } : { userId: req.session.user.id };
|
||||
const appType = "jitsi_video";
|
||||
try {
|
||||
const alreadyInstalled = await prisma.credential.findFirst({
|
||||
where: {
|
||||
type: appType,
|
||||
userId: req.session.user.id,
|
||||
...installForObject,
|
||||
},
|
||||
});
|
||||
if (alreadyInstalled) {
|
||||
|
@ -28,7 +34,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
data: {
|
||||
type: appType,
|
||||
key: {},
|
||||
userId: req.session.user.id,
|
||||
...installForObject,
|
||||
appId: "jitsi",
|
||||
},
|
||||
});
|
||||
|
|
|
@ -344,9 +344,11 @@ export const getLocationValueForDB = (
|
|||
eventLocations: LocationObject[]
|
||||
) => {
|
||||
let bookingLocation = bookingLocationTypeOrValue;
|
||||
let conferenceCredentialId = undefined;
|
||||
eventLocations.forEach((location) => {
|
||||
if (location.type === bookingLocationTypeOrValue) {
|
||||
const eventLocationType = getEventLocationType(bookingLocationTypeOrValue);
|
||||
conferenceCredentialId = location.credentialId;
|
||||
if (!eventLocationType) {
|
||||
return;
|
||||
}
|
||||
|
@ -359,7 +361,7 @@ export const getLocationValueForDB = (
|
|||
bookingLocation = location[eventLocationType.defaultValueVariable] || bookingLocation;
|
||||
}
|
||||
});
|
||||
return bookingLocation;
|
||||
return { bookingLocation, conferenceCredentialId };
|
||||
};
|
||||
|
||||
export const getEventLocationValue = (eventLocations: LocationObject[], bookingLocation: LocationObject) => {
|
||||
|
|
|
@ -21,5 +21,6 @@
|
|||
"label": "MS Teams"
|
||||
}
|
||||
},
|
||||
"dirName": "office365video"
|
||||
"dirName": "office365video",
|
||||
"concurrentMeetings": true
|
||||
}
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
|
||||
import { defaultLocations } from "./locations";
|
||||
|
||||
export async function getLocationGroupedOptions(
|
||||
userOrTeamId: { userId: number } | { teamId: number },
|
||||
t: TFunction
|
||||
) {
|
||||
const apps: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
icon?: string;
|
||||
slug?: string;
|
||||
credential?: CredentialDataWithTeamName;
|
||||
}[]
|
||||
> = {};
|
||||
|
||||
let idToSearchObject = {};
|
||||
|
||||
if ("teamId" in userOrTeamId) {
|
||||
const teamId = userOrTeamId.teamId;
|
||||
// See if the team event belongs to an org
|
||||
const org = await prisma.team.findFirst({
|
||||
where: {
|
||||
children: {
|
||||
some: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (org) {
|
||||
idToSearchObject = {
|
||||
teamId: {
|
||||
in: [teamId, org.id],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
idToSearchObject = { teamId };
|
||||
}
|
||||
} else {
|
||||
idToSearchObject = { userId: userOrTeamId.userId };
|
||||
}
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
...idToSearchObject,
|
||||
app: {
|
||||
categories: {
|
||||
hasSome: [AppCategories.conferencing, AppCategories.video],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
invalid: true,
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const integrations = await getEnabledApps(credentials, true);
|
||||
|
||||
integrations.forEach((app) => {
|
||||
if (app.locationOption) {
|
||||
// All apps that are labeled as a locationOption are video apps. Extract the secondary category if available
|
||||
let category =
|
||||
app.categories.length >= 2
|
||||
? app.categories.find(
|
||||
(category) =>
|
||||
!([AppCategories.video, AppCategories.conferencing] as string[]).includes(category)
|
||||
)
|
||||
: app.category;
|
||||
if (!category) category = AppCategories.conferencing;
|
||||
|
||||
for (const credential of app.credentials) {
|
||||
const label = `${app.locationOption.label} ${
|
||||
credential.team?.name ? `(${credential.team.name})` : ""
|
||||
}`;
|
||||
const option = { ...app.locationOption, label, icon: app.logo, slug: app.slug, credential };
|
||||
if (apps[category]) {
|
||||
apps[category] = [...apps[category], option];
|
||||
} else {
|
||||
apps[category] = [option];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
defaultLocations.forEach((l) => {
|
||||
const category = l.category;
|
||||
if (apps[category]) {
|
||||
apps[category] = [
|
||||
...apps[category],
|
||||
{
|
||||
label: l.label,
|
||||
value: l.type,
|
||||
icon: l.iconUrl,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
apps[category] = [
|
||||
{
|
||||
label: l.label,
|
||||
value: l.type,
|
||||
icon: l.iconUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
const locations = [];
|
||||
|
||||
// Translating labels and pushing into array
|
||||
for (const category in apps) {
|
||||
const tmp = {
|
||||
label: t(category),
|
||||
options: apps[category].map((l) => ({
|
||||
...l,
|
||||
label: t(l.label),
|
||||
})),
|
||||
};
|
||||
|
||||
locations.push(tmp);
|
||||
}
|
||||
|
||||
return locations;
|
||||
}
|
|
@ -1,13 +1,9 @@
|
|||
import type { Credential } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
// If you import this file on any app it should produce circular dependency
|
||||
// import appStore from "./index";
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { defaultLocations } from "@calcom/app-store/locations";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { App, AppMeta } from "@calcom/types/App";
|
||||
|
||||
export * from "./_utils/getEventTypeAppData";
|
||||
|
@ -39,77 +35,19 @@ const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
|
|||
|
||||
export type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
|
||||
|
||||
export type CredentialDataWithTeamName = CredentialData & {
|
||||
team?: {
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export const ALL_APPS = Object.values(ALL_APPS_MAP);
|
||||
|
||||
export function getLocationGroupedOptions(integrations: ReturnType<typeof getApps>, t: TFunction) {
|
||||
const apps: Record<
|
||||
string,
|
||||
{ label: string; value: string; disabled?: boolean; icon?: string; slug?: string }[]
|
||||
> = {};
|
||||
integrations.forEach((app) => {
|
||||
if (app.locationOption) {
|
||||
// All apps that are labeled as a locationOption are video apps. Extract the secondary category if available
|
||||
let category =
|
||||
app.categories.length >= 2
|
||||
? app.categories.find(
|
||||
(category) =>
|
||||
!([AppCategories.video, AppCategories.conferencing] as string[]).includes(category)
|
||||
)
|
||||
: app.category;
|
||||
if (!category) category = AppCategories.conferencing;
|
||||
const option = { ...app.locationOption, icon: app.logo, slug: app.slug };
|
||||
if (apps[category]) {
|
||||
apps[category] = [...apps[category], option];
|
||||
} else {
|
||||
apps[category] = [option];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
defaultLocations.forEach((l) => {
|
||||
const category = l.category;
|
||||
if (apps[category]) {
|
||||
apps[category] = [
|
||||
...apps[category],
|
||||
{
|
||||
label: l.label,
|
||||
value: l.type,
|
||||
icon: l.iconUrl,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
apps[category] = [
|
||||
{
|
||||
label: l.label,
|
||||
value: l.type,
|
||||
icon: l.iconUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
const locations = [];
|
||||
|
||||
// Translating labels and pushing into array
|
||||
for (const category in apps) {
|
||||
const tmp = {
|
||||
label: t(category),
|
||||
options: apps[category].map((l) => ({
|
||||
...l,
|
||||
label: t(l.label),
|
||||
})),
|
||||
};
|
||||
|
||||
locations.push(tmp);
|
||||
}
|
||||
|
||||
return locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* This should get all available apps to the user based on his saved
|
||||
* credentials, this should also get globally available apps.
|
||||
*/
|
||||
function getApps(credentials: CredentialData[], filterOnCredentials?: boolean) {
|
||||
function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?: boolean) {
|
||||
const apps = ALL_APPS.reduce((reducedArray, appMeta) => {
|
||||
const appCredentials = credentials.filter((credential) => credential.type === appMeta.type);
|
||||
|
||||
|
@ -125,9 +63,12 @@ function getApps(credentials: CredentialData[], filterOnCredentials?: boolean) {
|
|||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
key: appMeta.key!,
|
||||
userId: 0,
|
||||
teamId: 0,
|
||||
teamId: null,
|
||||
appId: appMeta.slug,
|
||||
invalid: false,
|
||||
team: {
|
||||
name: "Global",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -154,7 +95,7 @@ function getApps(credentials: CredentialData[], filterOnCredentials?: boolean) {
|
|||
});
|
||||
|
||||
return reducedArray;
|
||||
}, [] as (App & { credential: Credential; credentials: Credential[]; locationOption: LocationOption | null })[]);
|
||||
}, [] as (App & { credential: CredentialDataWithTeamName; credentials: CredentialDataWithTeamName[]; locationOption: LocationOption | null })[]);
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
@ -191,4 +132,19 @@ export function getAppFromLocationValue(type: string): AppMeta | undefined {
|
|||
return ALL_APPS.find((app) => app?.appData?.location?.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param appCategories - from app metadata
|
||||
* @param concurrentMeetings - from app metadata
|
||||
* @returns - true if app supports team install
|
||||
*/
|
||||
export function doesAppSupportTeamInstall(
|
||||
appCategories: string[],
|
||||
concurrentMeetings: boolean | undefined = undefined
|
||||
) {
|
||||
return !appCategories.some(
|
||||
(category) => category === "calendar" || (category === "conferencing" && !concurrentMeetings)
|
||||
);
|
||||
}
|
||||
|
||||
export default getApps;
|
||||
|
|
|
@ -21,5 +21,6 @@
|
|||
},
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "basic"
|
||||
"__template": "basic",
|
||||
"concurrentMeetings": true
|
||||
}
|
||||
|
|
|
@ -396,13 +396,15 @@ export default class EventManager {
|
|||
/** @fixme potential bug since Google Meet are saved as `integrations:google:meet` and there are no `google:meet` type in our DB */
|
||||
const integrationName = event.location.replace("integrations:", "");
|
||||
|
||||
let videoCredential = this.videoCredentials
|
||||
// Whenever a new video connection is added, latest credentials are added with the highest ID.
|
||||
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
|
||||
.sort((a, b) => {
|
||||
return b.id - a.id;
|
||||
})
|
||||
.find((credential: CredentialPayload) => credential.type.includes(integrationName));
|
||||
let videoCredential = event.conferenceCredentialId
|
||||
? this.videoCredentials.find((credential) => credential.id === event.conferenceCredentialId)
|
||||
: this.videoCredentials
|
||||
// Whenever a new video connection is added, latest credentials are added with the highest ID.
|
||||
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
|
||||
.sort((a, b) => {
|
||||
return b.id - a.id;
|
||||
})
|
||||
.find((credential: CredentialPayload) => credential.type.includes(integrationName));
|
||||
|
||||
/**
|
||||
* This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video.
|
||||
|
|
|
@ -890,8 +890,11 @@ async function handler(
|
|||
|
||||
// For static link based video apps, it would have the static URL value instead of it's type(e.g. integrations:campfire_video)
|
||||
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
|
||||
const bookingLocation = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
|
||||
? organizerOrFirstDynamicGroupMemberDefaultLocationUrl
|
||||
const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
|
||||
? {
|
||||
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
|
||||
conferenceCredentialId: undefined,
|
||||
}
|
||||
: getLocationValueForDB(locationBodyString, eventType.locations);
|
||||
|
||||
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
|
||||
|
@ -964,6 +967,7 @@ async function handler(
|
|||
userFieldsResponses: calEventUserFieldsResponses,
|
||||
attendees: attendeesList,
|
||||
location: bookingLocation, // Will be processed by the EventManager later.
|
||||
conferenceCredentialId,
|
||||
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
|
||||
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
|
||||
hideCalendarNotes: eventType.hideCalendarNotes,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import getApps from "@calcom/app-store/utils";
|
||||
import type { CredentialData } from "@calcom/app-store/utils";
|
||||
import type { CredentialDataWithTeamName } from "@calcom/app-store/utils";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import type { Prisma } from ".prisma/client";
|
||||
|
@ -10,7 +10,7 @@ import type { Prisma } from ".prisma/client";
|
|||
* @param filterOnCredentials - Only include apps where credentials are present
|
||||
* @returns A list of enabled app metadata & credentials tied to them
|
||||
*/
|
||||
const getEnabledApps = async (credentials: CredentialData[], filterOnCredentials?: boolean) => {
|
||||
const getEnabledApps = async (credentials: CredentialDataWithTeamName[], filterOnCredentials?: boolean) => {
|
||||
const filterOnIds = {
|
||||
credentials: {
|
||||
some: {
|
||||
|
@ -33,7 +33,8 @@ const getEnabledApps = async (credentials: CredentialData[], filterOnCredentials
|
|||
|
||||
const enabledApps = await prisma.app.findMany({
|
||||
where: {
|
||||
OR: [{ enabled: true, ...(filterOnIds.credentials.some.OR.length && filterOnIds) }],
|
||||
enabled: true,
|
||||
...(filterOnIds.credentials.some.OR.length && filterOnIds),
|
||||
},
|
||||
select: { slug: true, enabled: true },
|
||||
});
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import type { PrismaClient } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { getLocationGroupedOptions } from "@calcom/app-store/server";
|
||||
import type { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getEventTypeAppData, getLocationGroupedOptions } from "@calcom/app-store/utils";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { parseBookingLimit, parseDurationLimit, parseRecurringEvent } from "@calcom/lib";
|
||||
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { SchedulingType, MembershipRole } from "@calcom/prisma/enums";
|
||||
import { SchedulingType, MembershipRole, AppCategories } from "@calcom/prisma/enums";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
@ -229,6 +229,9 @@ export default async function getEventTypeById({
|
|||
userId,
|
||||
app: {
|
||||
enabled: true,
|
||||
categories: {
|
||||
hasSome: [AppCategories.conferencing, AppCategories.video],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
|
@ -326,9 +329,20 @@ export default async function getEventTypeById({
|
|||
);
|
||||
|
||||
const currentUser = eventType.users.find((u) => u.id === userId);
|
||||
|
||||
const t = await getTranslation(currentUser?.locale ?? "en", "common");
|
||||
const integrations = await getEnabledApps(credentials, true);
|
||||
const locationOptions = getLocationGroupedOptions(integrations, t);
|
||||
|
||||
if (!currentUser?.id && !eventType.teamId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Could not find user or team",
|
||||
});
|
||||
}
|
||||
|
||||
const locationOptions = await getLocationGroupedOptions(
|
||||
eventType.teamId ? { teamId: eventType.teamId } : { userId },
|
||||
t
|
||||
);
|
||||
if (eventType.schedulingType === SchedulingType.MANAGED) {
|
||||
locationOptions.splice(0, 0, {
|
||||
label: t("default"),
|
||||
|
|
|
@ -150,6 +150,7 @@ export const eventTypeLocations = z.array(
|
|||
displayLocationPublicly: z.boolean().optional(),
|
||||
hostPhoneNumber: z.string().optional(),
|
||||
credentialId: z.number().optional(),
|
||||
teamName: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import { getLocationGroupedOptions } from "@calcom/app-store/utils";
|
||||
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
|
||||
import { getLocationGroupedOptions } from "@calcom/app-store/server";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TLocationOptionsInputSchema } from "./locationOptions.schema";
|
||||
|
@ -15,31 +12,11 @@ type LocationOptionsOptions = {
|
|||
};
|
||||
|
||||
export const locationOptionsHandler = async ({ ctx, input }: LocationOptionsOptions) => {
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
app: {
|
||||
categories: {
|
||||
hasSome: [AppCategories.conferencing, AppCategories.video],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
invalid: true,
|
||||
},
|
||||
});
|
||||
|
||||
const integrations = await getEnabledApps(credentials, true);
|
||||
const { teamId } = input;
|
||||
|
||||
const t = await getTranslation(ctx.user.locale ?? "en", "common");
|
||||
|
||||
const locationOptions = getLocationGroupedOptions(integrations, t);
|
||||
const locationOptions = await getLocationGroupedOptions(teamId ? { teamId } : { userId: ctx.user.id }, t);
|
||||
// If it is a team event then move the "use host location" option to top
|
||||
if (input.teamId) {
|
||||
const conferencingIndex = locationOptions.findIndex((option) => option.label === "Conferencing");
|
||||
|
|
|
@ -6,6 +6,7 @@ import logger from "@calcom/lib/logger";
|
|||
import { getTranslation } from "@calcom/lib/server";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
|
@ -21,7 +22,7 @@ type EditLocationOptions = {
|
|||
};
|
||||
|
||||
export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) => {
|
||||
const { bookingId, newLocation: location } = input;
|
||||
const { bookingId, newLocation: location, details } = input;
|
||||
const { booking } = ctx;
|
||||
|
||||
try {
|
||||
|
@ -37,6 +38,16 @@ export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) =
|
|||
},
|
||||
});
|
||||
|
||||
let conferenceCredential: CredentialPayload | null = null;
|
||||
|
||||
if (details?.credentialId) {
|
||||
conferenceCredential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
id: details.credentialId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
|
||||
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
|
@ -69,12 +80,19 @@ export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) =
|
|||
uid: booking.uid,
|
||||
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
||||
location,
|
||||
conferenceCredentialId: details?.credentialId,
|
||||
destinationCalendar: booking?.destinationCalendar || booking?.user?.destinationCalendar,
|
||||
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
|
||||
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
|
||||
};
|
||||
|
||||
const eventManager = new EventManager(ctx.user);
|
||||
const eventManager = new EventManager({
|
||||
...ctx.user,
|
||||
credentials: [
|
||||
...(ctx.user.credentials ? ctx.user.credentials : []),
|
||||
...(conferenceCredential ? [conferenceCredential] : []),
|
||||
],
|
||||
});
|
||||
|
||||
const updatedResult = await eventManager.updateLocation(evt, booking);
|
||||
const results = updatedResult.results;
|
||||
|
|
|
@ -6,6 +6,7 @@ import { commonBookingSchema } from "./types";
|
|||
|
||||
export const ZEditLocationInputSchema = commonBookingSchema.extend({
|
||||
newLocation: z.string().transform((val) => val || DailyLocationType),
|
||||
details: z.object({ credentialId: z.number().optional() }).optional(),
|
||||
});
|
||||
|
||||
export type TEditLocationInputSchema = z.infer<typeof ZEditLocationInputSchema>;
|
||||
|
|
|
@ -179,6 +179,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
|
|||
seatsShowAttendees: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -144,6 +144,8 @@ export interface App {
|
|||
__template?: string;
|
||||
/** Slug of an app needed to be installed before the current app can be added */
|
||||
dependencies?: string[];
|
||||
/** Enables video apps to be used for team events. Non Video/Conferencing apps don't honor this as they support team installation always. */
|
||||
concurrentMeetings?: boolean;
|
||||
}
|
||||
|
||||
export type AppFrontendPayload = Omit<App, "key"> & {
|
||||
|
|
|
@ -163,6 +163,7 @@ export interface CalendarEvent {
|
|||
members: TeamMember[];
|
||||
};
|
||||
location?: string | null;
|
||||
conferenceCredentialId?: number;
|
||||
conferenceData?: ConferenceData;
|
||||
additionalInformation?: AdditionalInformation;
|
||||
uid?: string | null;
|
||||
|
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
|||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
|
||||
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
|
||||
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
@ -37,11 +38,10 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
|||
|
||||
const allowedMultipleInstalls = app.categories && app.categories.indexOf("calendar") > -1;
|
||||
const appAdded = (credentials && credentials.length) || 0;
|
||||
const enabledOnTeams = !app.categories.some(
|
||||
(category) => category === "calendar" || category === "conferencing"
|
||||
);
|
||||
|
||||
const appInstalled = enabledOnTeams && userAdminTeams ? userAdminTeams.length === appAdded : appAdded > 0;
|
||||
const enabledOnTeams = doesAppSupportTeamInstall(app.categories, app.concurrentMeetings);
|
||||
|
||||
const appInstalled = enabledOnTeams && userAdminTeams ? userAdminTeams.length < appAdded : appAdded > 0;
|
||||
|
||||
const [searchTextIndex, setSearchTextIndex] = useState<number | undefined>(undefined);
|
||||
|
||||
|
@ -119,6 +119,7 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
|||
{...props}
|
||||
addAppMutationInput={{ type: app.type, variant: app.variant, slug: app.slug }}
|
||||
appCategories={app.categories}
|
||||
concurrentMeetings={app.concurrentMeetings}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
@ -144,6 +145,7 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
|||
addAppMutationInput={{ type: app.type, variant: app.variant, slug: app.slug }}
|
||||
appCategories={app.categories}
|
||||
credentials={credentials}
|
||||
concurrentMeetings={app.concurrentMeetings}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -176,12 +178,14 @@ const InstallAppButtonChild = ({
|
|||
addAppMutationInput,
|
||||
appCategories,
|
||||
credentials,
|
||||
concurrentMeetings,
|
||||
...props
|
||||
}: {
|
||||
userAdminTeams?: UserAdminTeams;
|
||||
addAppMutationInput: { type: App["type"]; variant: string; slug: string };
|
||||
appCategories: string[];
|
||||
credentials?: Credential[];
|
||||
concurrentMeetings?: boolean;
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
@ -198,10 +202,7 @@ const InstallAppButtonChild = ({
|
|||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!userAdminTeams?.length ||
|
||||
appCategories.some((category) => category === "calendar" || category === "conferencing")
|
||||
) {
|
||||
if (!userAdminTeams?.length || !doesAppSupportTeamInstall(appCategories, concurrentMeetings)) {
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
|
|
Loading…
Reference in New Issue