feat: Plain integration (#18130)
* revert: "fix: Request permissions to allow events to be created on shared Office365/Outlook calendars (#17760)" This reverts commit1680cba5a5. * plain.com card * detiled error handling for get customer * working email and id * hmac * Revert "fix: correct line-breaks in calendar event description (#18077)" This reverts commit06494a6999. * pr changes requet * remove pan for now * add-new-implementation-for-early-review * Pushing fix for createHmac stringify * added validation and user repository for email check * add apiRouteMiddleware which handles the error handling * HMAC_SECRET_KEY -> PLAIN_HMAC_SECRET_KEY * Use the right error * Convey right error to consumer * Fixup apiRouteMiddleware to handle handler * Don't export handler, only export POST * changed to app directory * working unkown user card --------- Co-authored-by: Keith Williams <keithwillcode@gmail.com> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
co-authored by
Keith Williams
Omar López
Alex van Andel
parent
6b3ccbc61a
commit
63013d402e
@@ -107,6 +107,12 @@ NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
NEXT_PUBLIC_POSTHOG_HOST=
|
||||
|
||||
# plain.com config
|
||||
|
||||
PLAIN_API_KEY=
|
||||
PLAIN_API_URL=https://api.plain.com/v1
|
||||
PLAIN_HMAC_SECRET_KEY=
|
||||
|
||||
# Zendesk Config
|
||||
NEXT_PUBLIC_ZENDESK_KEY=
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { cardExamples } from "@pages/api/plain/example-cards";
|
||||
import { createHmac } from "crypto";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiRouteMiddleware } from "@calcom/lib/server/apiRouteMiddleware";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const inputSchema = z.object({
|
||||
customer: z.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().optional(),
|
||||
timeZone: z.string().optional(),
|
||||
emailVerified: z.boolean().optional(),
|
||||
identityProvider: z.string().optional(),
|
||||
twoFactorEnabled: z.boolean().optional(),
|
||||
}),
|
||||
cardKeys: z.array(z.string()),
|
||||
});
|
||||
|
||||
export async function handler(request: Request) {
|
||||
const headersList = headers();
|
||||
const requestBody = await request.json();
|
||||
|
||||
// HMAC verification
|
||||
const incomingSignature = headersList.get("plain-request-signature");
|
||||
const expectedSignature = createHmac("sha-256", process.env.PLAIN_HMAC_SECRET_KEY!)
|
||||
.update(JSON.stringify(requestBody))
|
||||
.digest("hex");
|
||||
|
||||
if (incomingSignature !== expectedSignature) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const { cardKeys, customer } = inputSchema.parse(requestBody);
|
||||
|
||||
const user = await UserRepository.findByEmail({ email: customer.email });
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({
|
||||
cards: [
|
||||
{
|
||||
key: "customer-card",
|
||||
timeToLiveSeconds: null,
|
||||
components: [
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Email",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: customer.email || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Email Verified?",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel:
|
||||
customer.emailVerified === undefined
|
||||
? "Unknown"
|
||||
: customer.emailVerified
|
||||
? "Yes"
|
||||
: "No",
|
||||
badgeColor:
|
||||
customer.emailVerified === undefined
|
||||
? "YELLOW"
|
||||
: customer.emailVerified
|
||||
? "GREEN"
|
||||
: "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Username",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: customer.username || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "User ID",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Time Zone",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: customer.timeZone || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Two Factor Enabled?",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel:
|
||||
customer.twoFactorEnabled === undefined
|
||||
? "Unknown"
|
||||
: customer.twoFactorEnabled
|
||||
? "Yes"
|
||||
: "No",
|
||||
badgeColor:
|
||||
customer.twoFactorEnabled === undefined
|
||||
? "YELLOW"
|
||||
: customer.twoFactorEnabled
|
||||
? "GREEN"
|
||||
: "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Identity Provider",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: customer.identityProvider || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Parse user metadata
|
||||
const parsedMetadata = userMetadata.parse(user.metadata);
|
||||
|
||||
const cards = await Promise.all(
|
||||
cardExamples.map(async (cardFn) => {
|
||||
return cardFn(
|
||||
user.email,
|
||||
user.id.toString(),
|
||||
user.username || "Unknown",
|
||||
user.timeZone,
|
||||
user.emailVerified,
|
||||
user.twoFactorEnabled
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const filteredCards = cards.filter((card) => {
|
||||
return cardKeys.length === 0 || cardKeys.includes(card.key);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
cards: filteredCards,
|
||||
user: {
|
||||
...user,
|
||||
metadata: parsedMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const POST = apiRouteMiddleware(handler);
|
||||
@@ -0,0 +1,86 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ComponentTextSize = z.enum(["S", "M", "L"]);
|
||||
export type ComponentTextSize = z.infer<typeof ComponentTextSize>;
|
||||
|
||||
export const ComponentTextColor = z.enum(["NORMAL", "MUTED", "SUCCESS", "WARNING", "ERROR"]);
|
||||
export type ComponentTextColor = z.infer<typeof ComponentTextColor>;
|
||||
|
||||
export const ComponentSpacerSize = z.enum(["XS", "S", "M", "L", "XL"]);
|
||||
export type ComponentSpacerSize = z.infer<typeof ComponentSpacerSize>;
|
||||
|
||||
export const ComponentDividerSpacingSize = z.enum(["XS", "S", "M", "L", "XL"]);
|
||||
export type ComponentDividerSpacingSize = z.infer<typeof ComponentDividerSpacingSize>;
|
||||
|
||||
export const ComponentBadgeColor = z.enum(["GREY", "GREEN", "YELLOW", "RED", "BLUE"]);
|
||||
export type ComponentBadgeColor = z.infer<typeof ComponentBadgeColor>;
|
||||
|
||||
const Text = z.object({
|
||||
textSize: ComponentTextSize.nullish(),
|
||||
textColor: ComponentTextColor.nullish(),
|
||||
text: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
const Divider = z.object({
|
||||
dividerSpacingSize: ComponentDividerSpacingSize.nullish(),
|
||||
});
|
||||
|
||||
const LinkButton = z.object({
|
||||
linkButtonUrl: z.string().url(),
|
||||
linkButtonLabel: z.string().max(500),
|
||||
});
|
||||
|
||||
const Spacer = z.object({
|
||||
spacerSize: ComponentSpacerSize,
|
||||
});
|
||||
|
||||
const Badge = z.object({
|
||||
badgeLabel: z.string().max(500),
|
||||
badgeColor: ComponentBadgeColor.nullish(),
|
||||
});
|
||||
|
||||
const CopyButton = z.object({
|
||||
copyButtonValue: z.string().max(1000),
|
||||
copyButtonTooltipLabel: z.string().max(500).nullish(),
|
||||
});
|
||||
|
||||
const RowContentUnionInput = z.object({
|
||||
componentText: Text.optional(),
|
||||
componentDivider: Divider.optional(),
|
||||
componentLinkButton: LinkButton.optional(),
|
||||
componentSpacer: Spacer.optional(),
|
||||
componentBadge: Badge.optional(),
|
||||
componentCopyButton: CopyButton.optional(),
|
||||
});
|
||||
|
||||
const Row = z.object({
|
||||
rowMainContent: z.array(RowContentUnionInput),
|
||||
rowAsideContent: z.array(RowContentUnionInput),
|
||||
});
|
||||
|
||||
const ContainerContentUnionInput = z.object({
|
||||
componentText: Text.optional(),
|
||||
componentDivider: Divider.optional(),
|
||||
componentLinkButton: LinkButton.optional(),
|
||||
componentSpacer: Spacer.optional(),
|
||||
componentBadge: Badge.optional(),
|
||||
componentCopyButton: CopyButton.optional(),
|
||||
componentRow: Row.optional(),
|
||||
});
|
||||
|
||||
const Container = z.object({
|
||||
containerContent: z.array(ContainerContentUnionInput).min(1),
|
||||
});
|
||||
|
||||
export const Component = z.object({
|
||||
componentText: Text.optional(),
|
||||
componentDivider: Divider.optional(),
|
||||
componentLinkButton: LinkButton.optional(),
|
||||
componentSpacer: Spacer.optional(),
|
||||
componentBadge: Badge.optional(),
|
||||
componentCopyButton: CopyButton.optional(),
|
||||
componentRow: Row.optional(),
|
||||
componentContainer: Container.optional(),
|
||||
});
|
||||
|
||||
export type Component = z.infer<typeof Component>;
|
||||
@@ -0,0 +1,164 @@
|
||||
/* eslint-disable import/no-anonymous-default-export */
|
||||
import type { Card } from "@pages/api/plain";
|
||||
|
||||
export default (
|
||||
email: string,
|
||||
id: string,
|
||||
username: string,
|
||||
timeZone: string,
|
||||
emailVerified: Date | null,
|
||||
twoFactorEnabled: boolean | null
|
||||
): Card => {
|
||||
return {
|
||||
key: "customer-card",
|
||||
timeToLiveSeconds: null,
|
||||
components: [
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Email",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: email || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Email Verified?",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel: emailVerified === null ? "Unknown" : emailVerified ? "Yes" : "No",
|
||||
badgeColor: emailVerified === null ? "YELLOW" : emailVerified ? "GREEN" : "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Username",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: username || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "User ID",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: id || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Time Zone",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: timeZone || "Unknown",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: "M",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRow: {
|
||||
rowMainContent: [
|
||||
{
|
||||
componentText: {
|
||||
text: "Two Factor Enabled?",
|
||||
textColor: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
rowAsideContent: [
|
||||
{
|
||||
componentBadge: {
|
||||
badgeLabel: twoFactorEnabled === null ? "Unknown" : twoFactorEnabled ? "Yes" : "No",
|
||||
badgeColor: twoFactorEnabled === null ? "YELLOW" : twoFactorEnabled ? "GREEN" : "RED",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Card } from "@pages/api/plain";
|
||||
import customerCardDisplay from "@pages/api/plain/customer-card-display";
|
||||
|
||||
export const cardExamples: ((
|
||||
email: string,
|
||||
id: string,
|
||||
username: string,
|
||||
timeZone: string,
|
||||
emailVerified: Date | null,
|
||||
twoFactorEnabled: boolean | null
|
||||
) => Card)[] = [customerCardDisplay];
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { Component } from "./components";
|
||||
|
||||
export const Card = z.object({
|
||||
key: z.string(),
|
||||
timeToLiveSeconds: z.number().int().min(0).nullish().default(null),
|
||||
components: z.array(Component).nullable(),
|
||||
});
|
||||
export type Card = z.infer<typeof Card>;
|
||||
|
||||
export const ResponseBody = z.object({
|
||||
cards: z.array(Card),
|
||||
});
|
||||
export type ResponseBody = z.infer<typeof ResponseBody>;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import { ApiError } from "next/dist/server/api-utils";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getServerErrorFromUnknown } from "./getServerErrorFromUnknown";
|
||||
|
||||
export const apiRouteMiddleware =
|
||||
(handler: (req: Request, res?: Response) => Promise<Response>) =>
|
||||
async (req: NextRequest, res: NextResponse) => {
|
||||
try {
|
||||
return await handler(req, res);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.statusCode });
|
||||
} else {
|
||||
const serverError = getServerErrorFromUnknown(error);
|
||||
// we don't want to report Bad Request errors to Sentry / console
|
||||
if (!(serverError.statusCode >= 400 && serverError.statusCode < 500)) {
|
||||
console.error(error);
|
||||
captureException(error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: serverError.message,
|
||||
url: serverError.url,
|
||||
method: serverError.method,
|
||||
},
|
||||
{
|
||||
status: serverError.statusCode,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -199,6 +199,16 @@ export class UserRepository {
|
||||
return { where, profiles };
|
||||
}
|
||||
|
||||
static async findByEmail({ email }: { email: string }) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
static async findByEmailAndIncludeProfilesAndPassword({ email }: { email: string }) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
|
||||
+4
-1
@@ -114,7 +114,10 @@
|
||||
"STRIPE_SUPPORT_TABLE",
|
||||
"MOTIFLAND_REST_ENDPOINT",
|
||||
"MOTIFLAND_DOCS_API_KEY",
|
||||
"MOTIFLAND_DOCS_PROJECT_ID"
|
||||
"MOTIFLAND_DOCS_PROJECT_ID",
|
||||
"PLAIN_HMAC_SECRET_KEY",
|
||||
"PLAIN_API_KEY",
|
||||
"PLAIN_API_URL"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user