From e98bebb9b2bed79cbdd4fa8d881d12c8f3b343c4 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Sat, 15 Jul 2023 04:36:57 +0530 Subject: [PATCH] feat: Zapier For Teams (#9851) Co-authored-by: alannnc Co-authored-by: zomars --- apps/web/pages/api/integrations/[...args].ts | 1 + apps/web/public/static/locales/en/common.json | 1 + .../api/subscriptions/addSubscription.ts | 7 +- .../api/subscriptions/deleteSubscription.ts | 12 +- .../zapier/api/subscriptions/listBookings.ts | 16 ++- .../app-store/zapier/api/subscriptions/me.ts | 11 +- .../app-store/zapier/pages/setup/index.tsx | 127 +++++++++++++----- .../ee/api-keys/lib/findValidApiKey.ts | 10 +- packages/features/webhooks/lib/getWebhooks.ts | 1 + .../migration.sql | 5 + packages/prisma/schema.prisma | 3 + .../viewer/apiKeys/_auth-middleware.ts | 26 ++++ .../routers/viewer/apiKeys/create.handler.ts | 9 +- .../routers/viewer/apiKeys/create.schema.ts | 1 + .../viewer/apiKeys/findKeyOfType.handler.ts | 20 +-- .../viewer/apiKeys/findKeyOfType.schema.ts | 1 + .../server/routers/viewer/teams/_router.tsx | 18 +++ .../viewer/teams/listOwnedTeams.handler.ts | 40 ++++++ 18 files changed, 247 insertions(+), 62 deletions(-) create mode 100644 packages/prisma/migrations/20230629083927_add_team_id_in_api_key/migration.sql create mode 100644 packages/trpc/server/routers/viewer/apiKeys/_auth-middleware.ts create mode 100644 packages/trpc/server/routers/viewer/teams/listOwnedTeams.handler.ts diff --git a/apps/web/pages/api/integrations/[...args].ts b/apps/web/pages/api/integrations/[...args].ts index 94fdfadc02..b0cb1f7b86 100644 --- a/apps/web/pages/api/integrations/[...args].ts +++ b/apps/web/pages/api/integrations/[...args].ts @@ -56,6 +56,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handlerMap = (await import("@calcom/app-store/apps.server.generated")).apiHandlers; const handlerKey = deriveAppDictKeyFromType(appName, handlerMap); const handlers = await handlerMap[handlerKey as keyof typeof handlerMap]; + if (!handlers) throw new HttpError({ statusCode: 404, message: `No handlers found for ${handlerKey}` }); const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler; let redirectUrl = "/apps/installed"; if (typeof handler === "undefined") diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index dfdbe15ce4..09c57ee27b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -17,6 +17,7 @@ "verify_email_banner_body": "Verify your email address to guarantee the best email and calendar deliverability", "verify_email_email_header": "Verify your email address", "verify_email_email_button": "Verify email", + "copy_somewhere_safe": "Save this API key somewhere safe. You will not be able to view it again.", "verify_email_email_body": "Please verify your email address by clicking the button below.", "verify_email_email_link_text": "Here's the link in case you don't like clicking buttons:", "email_sent": "Email sent successfully", diff --git a/packages/app-store/zapier/api/subscriptions/addSubscription.ts b/packages/app-store/zapier/api/subscriptions/addSubscription.ts index 685b1db2fd..56bbe16a4d 100644 --- a/packages/app-store/zapier/api/subscriptions/addSubscription.ts +++ b/packages/app-store/zapier/api/subscriptions/addSubscription.ts @@ -1,3 +1,4 @@ +import type { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import { v4 } from "uuid"; @@ -27,6 +28,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { data: { id: v4(), userId: validKey.userId, + teamId: validKey.teamId, eventTriggers: [triggerEvent], subscriberUrl, active: true, @@ -36,9 +38,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (triggerEvent === WebhookTriggerEvents.MEETING_ENDED) { //schedule job for already existing bookings + const where: Prisma.BookingWhereInput = {}; + if (validKey.teamId) where.eventType = { teamId: validKey.teamId }; + else where.userId = validKey.userId; const bookings = await prisma.booking.findMany({ where: { - userId: validKey.userId, + ...where, startTime: { gte: new Date(), }, diff --git a/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts index 0d6cc73c1a..b0887ac21b 100644 --- a/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts +++ b/packages/app-store/zapier/api/subscriptions/deleteSubscription.ts @@ -1,3 +1,4 @@ +import type { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import z from "zod"; @@ -23,17 +24,24 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (!validKey) { return res.status(401).json({ message: "API key not valid" }); } - const webhook = await prisma.webhook.findFirst({ where: { id, + userId: validKey.userId, + teamId: validKey.teamId, }, }); + if (!webhook) { + return res.status(401).json({ message: "Not authorized to delete this webhook" }); + } if (webhook?.eventTriggers.includes(WebhookTriggerEvents.MEETING_ENDED)) { + const where: Prisma.BookingWhereInput = {}; + if (validKey.teamId) where.eventType = { teamId: validKey.teamId }; + else where.userId = validKey.userId; const bookingsWithScheduledJobs = await prisma.booking.findMany({ where: { - userId: validKey.userId, + ...where, scheduledJobs: { isEmpty: false, }, diff --git a/packages/app-store/zapier/api/subscriptions/listBookings.ts b/packages/app-store/zapier/api/subscriptions/listBookings.ts index e81724dac1..b3e5db8ae9 100644 --- a/packages/app-store/zapier/api/subscriptions/listBookings.ts +++ b/packages/app-store/zapier/api/subscriptions/listBookings.ts @@ -1,3 +1,4 @@ +import type { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; import { getHumanReadableLocationValue } from "@calcom/core/location"; @@ -20,11 +21,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } try { + const where: Prisma.BookingWhereInput = {}; + if (validKey.teamId) where.eventType = { teamId: validKey.teamId }; + else where.userId = validKey.userId; const bookings = await prisma.booking.findMany({ take: 3, - where: { - userId: validKey.userId, - }, + where, orderBy: { id: "desc", }, @@ -56,6 +58,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { currency: true, length: true, bookingFields: true, + team: true, }, }, attendees: { @@ -68,6 +71,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, }); + if (bookings.length === 0) { + const requested = validKey.teamId ? "teamId: " + validKey.teamId : "userId: " + validKey.userId; + return res.status(404).json({ + message: `There are no bookings to retrieve, please create a booking first. Requested: \`${requested}\``, + }); + } + const t = await getTranslation(bookings[0].user?.locale ?? "en", "common"); const updatedBookings = bookings.map((booking) => { diff --git a/packages/app-store/zapier/api/subscriptions/me.ts b/packages/app-store/zapier/api/subscriptions/me.ts index f2bb119b9b..e84d0396d2 100644 --- a/packages/app-store/zapier/api/subscriptions/me.ts +++ b/packages/app-store/zapier/api/subscriptions/me.ts @@ -18,6 +18,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method === "GET") { try { + if (validKey.teamId) { + const team = await prisma.team.findFirst({ + where: { + id: validKey.teamId, + }, + }); + return res.status(201).json({ username: team?.name }); + } + const user = await prisma.user.findFirst({ where: { id: validKey.userId, @@ -26,7 +35,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) username: true, }, }); - res.status(201).json(user); + return res.status(201).json(user); } catch (error) { console.error(error); return res.status(500).json({ message: "Unable to get User." }); diff --git a/packages/app-store/zapier/pages/setup/index.tsx b/packages/app-store/zapier/pages/setup/index.tsx index a1b91debea..ee52873c7a 100644 --- a/packages/app-store/zapier/pages/setup/index.tsx +++ b/packages/app-store/zapier/pages/setup/index.tsx @@ -5,7 +5,7 @@ import { Toaster } from "react-hot-toast"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { Button, showToast, Tooltip } from "@calcom/ui"; +import { Button, Tooltip, showToast } from "@calcom/ui"; import { Clipboard } from "@calcom/ui/components/icon"; export interface IZapierSetupProps { @@ -15,13 +15,20 @@ export interface IZapierSetupProps { const ZAPIER = "zapier"; export default function ZapierSetup(props: IZapierSetupProps) { - const [newApiKey, setNewApiKey] = useState(""); + const [newApiKeys, setNewApiKeys] = useState>({}); const { t } = useLocale(); const utils = trpc.useContext(); const integrations = trpc.viewer.integrations.useQuery({ variant: "automation" }); const oldApiKey = trpc.viewer.apiKeys.findKeyOfType.useQuery({ appId: ZAPIER }); - - const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation(); + const teamsList = trpc.viewer.teams.listOwnedTeams.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + const teams = teamsList.data?.map((team) => ({ id: team.id, name: team.name })); + const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation({ + onSuccess: () => { + utils.viewer.apiKeys.findKeyOfType.invalidate(); + }, + }); const zapierCredentials: { userCredentialIds: number[] } | undefined = integrations.data?.items.find( (item: { type: string }) => item.type === "zapier_automation" ); @@ -29,15 +36,28 @@ export default function ZapierSetup(props: IZapierSetupProps) { const showContent = integrations.data && integrations.isSuccess && credentialId; const isCalDev = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.dev"; - async function createApiKey() { - const event = { note: "Zapier", expiresAt: null, appId: ZAPIER }; + async function createApiKey(teamId?: number) { + const event = { note: "Zapier", expiresAt: null, appId: ZAPIER, teamId }; const apiKey = await utils.client.viewer.apiKeys.create.mutate(event); + if (oldApiKey.data) { - deleteApiKey.mutate({ - id: oldApiKey.data.id, - }); + const oldKey = teamId + ? oldApiKey.data.find((key) => key.teamId === teamId) + : oldApiKey.data.find((key) => !key.teamId); + + if (oldKey) { + deleteApiKey.mutate({ + id: oldKey.id, + }); + } } - setNewApiKey(apiKey); + + return apiKey; + } + + async function generateApiKey(teamId?: number) { + const apiKey = await createApiKey(teamId); + setNewApiKeys({ ...newApiKeys, [teamId || ""]: apiKey }); } if (integrations.isLoading) { @@ -54,36 +74,43 @@ export default function ZapierSetup(props: IZapierSetupProps) {
{t("setting_up_zapier")}
- {!newApiKey ? ( - <> -
{t("generate_api_key")}:
- - - ) : ( - <> -
{t("your_unique_api_key")}
-
- - {newApiKey} - - - - -
-
{t("copy_safe_api_key")}
- - )} + ) : ( + + )} + {teams.map((team) => { + return ( +
+
{team.name}:
+ {!newApiKeys[team.id] ? ( + + ) : ( + + )} +
+ ); + })} + + )} +
    {isCalDev && ( @@ -122,3 +149,29 @@ export default function ZapierSetup(props: IZapierSetupProps) {
); } + +const CopyApiKey = ({ apiKey }: { apiKey: string }) => { + const { t } = useLocale(); + return ( +
+
+ + {apiKey} + + + + +
+
{t("copy_somewhere_safe")}
+
+ ); +}; diff --git a/packages/features/ee/api-keys/lib/findValidApiKey.ts b/packages/features/ee/api-keys/lib/findValidApiKey.ts index aa5035c08a..e77eb52295 100644 --- a/packages/features/ee/api-keys/lib/findValidApiKey.ts +++ b/packages/features/ee/api-keys/lib/findValidApiKey.ts @@ -6,14 +6,8 @@ const findValidApiKey = async (apiKey: string, appId?: string) => { const validKey = await prisma.apiKey.findFirst({ where: { - AND: [ - { - hashedKey, - }, - { - appId, - }, - ], + hashedKey, + appId, OR: [ { expiresAt: { diff --git a/packages/features/webhooks/lib/getWebhooks.ts b/packages/features/webhooks/lib/getWebhooks.ts index 0f8f9b27d2..c933b30343 100644 --- a/packages/features/webhooks/lib/getWebhooks.ts +++ b/packages/features/webhooks/lib/getWebhooks.ts @@ -20,6 +20,7 @@ const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient = OR: [ { userId, + teamId: null, }, { eventTypeId, diff --git a/packages/prisma/migrations/20230629083927_add_team_id_in_api_key/migration.sql b/packages/prisma/migrations/20230629083927_add_team_id_in_api_key/migration.sql new file mode 100644 index 0000000000..8a7323d1cd --- /dev/null +++ b/packages/prisma/migrations/20230629083927_add_team_id_in_api_key/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "ApiKey" ADD COLUMN "teamId" INTEGER; + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 605f7bea89..54de956da5 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -276,6 +276,7 @@ model Team { timeZone String @default("Europe/London") weekStart String @default("Sunday") routingForms App_RoutingForms_Form[] + apiKeys ApiKey[] credentials Credential[] @@unique([slug, parentId]) @@ -564,12 +565,14 @@ model Impersonations { model ApiKey { id String @id @unique @default(cuid()) userId Int + teamId Int? note String? createdAt DateTime @default(now()) expiresAt DateTime? lastUsedAt DateTime? hashedKey String @unique() user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) appId String? diff --git a/packages/trpc/server/routers/viewer/apiKeys/_auth-middleware.ts b/packages/trpc/server/routers/viewer/apiKeys/_auth-middleware.ts new file mode 100644 index 0000000000..65742c7cff --- /dev/null +++ b/packages/trpc/server/routers/viewer/apiKeys/_auth-middleware.ts @@ -0,0 +1,26 @@ +import type { Prisma } from "@prisma/client"; + +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +export async function checkPermissions(args: { + userId: number; + teamId?: number; + role: Prisma.MembershipWhereInput["role"]; +}) { + const { teamId, userId, role } = args; + if (!teamId) return; + const team = await prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + userId, + role, + }, + }, + }, + }); + if (!team) throw new TRPCError({ code: "UNAUTHORIZED" }); +} diff --git a/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts index ae5b33b61b..0c69750df4 100644 --- a/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts +++ b/packages/trpc/server/routers/viewer/apiKeys/create.handler.ts @@ -2,8 +2,10 @@ import { v4 } from "uuid"; import { generateUniqueAPIKey } from "@calcom/ee/api-keys/lib/apiKeys"; import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "../../../trpc"; +import { checkPermissions } from "./_auth-middleware"; import type { TCreateInputSchema } from "./create.schema"; type CreateHandlerOptions = { @@ -17,12 +19,17 @@ export const createHandler = async ({ ctx, input }: CreateHandlerOptions) => { const [hashedApiKey, apiKey] = generateUniqueAPIKey(); // Here we snap never expires before deleting it so it's not passed to prisma create call. - const { neverExpires, ...rest } = input; + const { neverExpires, teamId, ...rest } = input; + const userId = ctx.user.id; + + /** Only admin or owner can create apiKeys of team (if teamId is passed) */ + await checkPermissions({ userId, teamId, role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] } }); await prisma.apiKey.create({ data: { id: v4(), userId: ctx.user.id, + teamId, ...rest, // And here we pass a null to expiresAt if never expires is true. otherwise just pass expiresAt from input expiresAt: neverExpires ? null : rest.expiresAt, diff --git a/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts index b38efc1957..17660d9e02 100644 --- a/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts +++ b/packages/trpc/server/routers/viewer/apiKeys/create.schema.ts @@ -5,6 +5,7 @@ export const ZCreateInputSchema = z.object({ expiresAt: z.date().optional().nullable(), neverExpires: z.boolean().optional(), appId: z.string().optional().nullable(), + teamId: z.number().optional(), }); export type TCreateInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts index 2bb04f3b70..08f6aa8732 100644 --- a/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts +++ b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts @@ -1,6 +1,8 @@ import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "../../../trpc"; +import { checkPermissions } from "./_auth-middleware"; import type { TFindKeyOfTypeInputSchema } from "./findKeyOfType.schema"; type FindKeyOfTypeOptions = { @@ -11,16 +13,16 @@ type FindKeyOfTypeOptions = { }; export const findKeyOfTypeHandler = async ({ ctx, input }: FindKeyOfTypeOptions) => { - return await prisma.apiKey.findFirst({ + const { teamId, appId } = input; + const userId = ctx.user.id; + /** Only admin or owner can create apiKeys of team (if teamId is passed) */ + await checkPermissions({ userId, teamId, role: { in: [MembershipRole.OWNER, MembershipRole.ADMIN] } }); + + return await prisma.apiKey.findMany({ where: { - AND: [ - { - userId: ctx.user.id, - }, - { - appId: input.appId, - }, - ], + teamId, + userId, + appId, }, }); }; diff --git a/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts index 1f83f53d38..4b91e9e57f 100644 --- a/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts +++ b/packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.schema.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const ZFindKeyOfTypeInputSchema = z.object({ appId: z.string().optional(), + teamId: z.number().optional(), }); export type TFindKeyOfTypeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 2985f04aab..7030d41dc4 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -22,6 +22,7 @@ import { ZUpdateMembershipInputSchema } from "./updateMembership.schema"; type TeamsRouterHandlerCache = { get?: typeof import("./get.handler").getHandler; list?: typeof import("./list.handler").listHandler; + listOwnedTeams?: typeof import("./listOwnedTeams.handler").listOwnedTeamsHandler; create?: typeof import("./create.handler").createHandler; update?: typeof import("./update.handler").updateHandler; delete?: typeof import("./delete.handler").deleteHandler; @@ -79,6 +80,23 @@ export const viewerTeamsRouter = router({ ctx, }); }), + // Returns Teams I am a owner/admin of + listOwnedTeams: authedProcedure.query(async ({ ctx }) => { + if (!UNSTABLE_HANDLER_CACHE.listOwnedTeams) { + UNSTABLE_HANDLER_CACHE.listOwnedTeams = await import("./listOwnedTeams.handler").then( + (mod) => mod.listOwnedTeamsHandler + ); + } + + // Unreachable code but required for type safety + if (!UNSTABLE_HANDLER_CACHE.listOwnedTeams) { + throw new Error("Failed to load handler"); + } + + return UNSTABLE_HANDLER_CACHE.listOwnedTeams({ + ctx, + }); + }), create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => { if (!UNSTABLE_HANDLER_CACHE.create) { diff --git a/packages/trpc/server/routers/viewer/teams/listOwnedTeams.handler.ts b/packages/trpc/server/routers/viewer/teams/listOwnedTeams.handler.ts new file mode 100644 index 0000000000..e28927a879 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listOwnedTeams.handler.ts @@ -0,0 +1,40 @@ +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type ListOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const listOwnedTeamsHandler = async ({ ctx }: ListOptions) => { + const user = await prisma.user.findFirst({ + where: { + id: ctx.user.id, + }, + select: { + id: true, + teams: { + where: { + accepted: true, + role: { + in: [MembershipRole.OWNER, MembershipRole.ADMIN], + }, + }, + select: { + team: true, + }, + }, + }, + }); + + return user?.teams + ?.filter((m) => { + const metadata = teamMetadataSchema.parse(m.team.metadata); + return !metadata?.isOrganization; + }) + ?.map(({ team }) => team); +};