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>
This commit is contained in:
Joe Au-Yeung 2023-08-01 23:54:28 -04:00 committed by GitHub
parent a9344115d2
commit 617e665004
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 310 additions and 140 deletions

View File

@ -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>

View File

@ -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"

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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>;

View File

@ -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={

View File

@ -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"

View File

@ -103,6 +103,7 @@ export type FormValues = {
phone?: string;
hostDefault?: string;
credentialId?: number;
teamName?: string;
}[];
customInputs: CustomInputParsed[];
schedule: number | null;

View File

@ -27,6 +27,7 @@ export const metadata = {
},
key: { apikey: randomString(12) },
dirName: "huddle01video",
concurrentMeetings: true,
} as AppMeta;
export default metadata;

View File

@ -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",
},
});

View File

@ -24,6 +24,7 @@ export const metadata = {
},
},
dirName: "jitsivideo",
concurrentMeetings: true,
} as AppMeta;
export default metadata;

View File

@ -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",
},
});

View File

@ -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) => {

View File

@ -21,5 +21,6 @@
"label": "MS Teams"
}
},
"dirName": "office365video"
"dirName": "office365video",
"concurrentMeetings": true
}

View File

@ -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;
}

View File

@ -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;

View File

@ -21,5 +21,6 @@
},
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic"
"__template": "basic",
"concurrentMeetings": true
}

View File

@ -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.

View File

@ -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,

View File

@ -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 },
});

View File

@ -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"),

View File

@ -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(),
})
);

View File

@ -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");

View File

@ -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;

View File

@ -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>;

View File

@ -179,6 +179,7 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
seatsShowAttendees: true,
team: {
select: {
id: true,
name: true,
},
},

View File

@ -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"> & {

View File

@ -163,6 +163,7 @@ export interface CalendarEvent {
members: TeamMember[];
};
location?: string | null;
conferenceCredentialId?: number;
conferenceData?: ConferenceData;
additionalInformation?: AdditionalInformation;
uid?: string | null;

View File

@ -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"