feat: Plain integration (#18130)

* revert: "fix: Request permissions to allow events to be created on shared Office365/Outlook calendars (#17760)"

This reverts commit 1680cba5a5.

* 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 commit 06494a6999.

* 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:
Nizzy
2024-12-17 20:24:07 -05:00
committed by GitHub
co-authored by Keith Williams Omar López Alex van Andel
parent 6b3ccbc61a
commit 63013d402e
10 changed files with 7455 additions and 69 deletions
+6
View File
@@ -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=
+274
View File
@@ -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);
+86
View File
@@ -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];
+15
View File
@@ -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>;
+35
View File
@@ -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,
}
);
}
}
};
+10
View File
@@ -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
View File
@@ -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": {
+6850 -68
View File
File diff suppressed because it is too large Load Diff