feat: Zapier For Teams (#9851)

Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Udit Takkar 2023-07-15 04:36:57 +05:30 committed by GitHub
parent dcd7e80e6a
commit e98bebb9b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 247 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Record<string, string>>({});
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) {
</div>
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
<div className="text-default">{t("setting_up_zapier")}</div>
{!newApiKey ? (
<>
<div className="mt-1 text-xl">{t("generate_api_key")}:</div>
<Button color="primary" onClick={() => createApiKey()} className="mb-4 mt-4">
<>
<div className="mt-1 text-xl">{t("generate_api_key")}:</div>
{!teams ? (
<Button color="secondary" onClick={() => createApiKey()} className="mb-4 mt-2">
{t("generate_api_key")}
</Button>
</>
) : (
<>
<div className="mt-1 text-xl">{t("your_unique_api_key")}</div>
<div className="my-2 mt-3 flex-wrap sm:flex sm:flex-nowrap">
<code className="bg-subtle h-full w-full whitespace-pre-wrap rounded-md py-[6px] pl-2 pr-2 sm:rounded-r-none sm:pr-5">
{newApiKey}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(newApiKey);
showToast(t("api_key_copied"), "success");
}}
type="button"
className="mt-4 text-base sm:mt-0 sm:rounded-l-none">
<Clipboard className="h-5 w-5 text-gray-100 ltr:mr-2 rtl:ml-2" />
{t("copy")}
) : (
<>
<div className="mt-8 text-sm font-semibold">Your event types:</div>
{!newApiKeys[""] ? (
<Button color="secondary" onClick={() => generateApiKey()} className="mb-4 mt-2">
{t("generate_api_key")}
</Button>
</Tooltip>
</div>
<div className="text-default mb-5 mt-2 text-sm font-semibold">{t("copy_safe_api_key")}</div>
</>
)}
) : (
<CopyApiKey apiKey={newApiKeys[""]} />
)}
{teams.map((team) => {
return (
<div key={team.name}>
<div className="mt-2 text-sm font-semibold">{team.name}:</div>
{!newApiKeys[team.id] ? (
<Button
color="secondary"
onClick={() => generateApiKey(team.id)}
className="mb-4 mt-2">
{t("generate_api_key")}
</Button>
) : (
<CopyApiKey apiKey={newApiKeys[team.id]} />
)}
</div>
);
})}
</>
)}
</>
<ol className="mb-5 ml-5 mt-5 list-decimal ltr:mr-5 rtl:ml-5">
{isCalDev && (
@ -122,3 +149,29 @@ export default function ZapierSetup(props: IZapierSetupProps) {
</div>
);
}
const CopyApiKey = ({ apiKey }: { apiKey: string }) => {
const { t } = useLocale();
return (
<div>
<div className="my-2 mt-3 flex-wrap sm:flex sm:flex-nowrap">
<code className="bg-subtle h-full w-full whitespace-pre-wrap rounded-md py-[6px] pl-2 pr-2 sm:rounded-r-none sm:pr-5">
{apiKey}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(apiKey);
showToast(t("api_key_copied"), "success");
}}
type="button"
className="mt-4 text-base sm:mt-0 sm:rounded-l-none">
<Clipboard className="h-4 w-4 ltr:mr-2 rtl:ml-2" />
{t("copy")}
</Button>
</Tooltip>
</div>
<div className="text-subtle mb-5 mt-2 text-sm">{t("copy_somewhere_safe")}</div>
</div>
);
};

View File

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

View File

@ -20,6 +20,7 @@ const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient =
OR: [
{
userId,
teamId: null,
},
{
eventTypeId,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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