feat: plain-thread-tier (#19463)

* tier select

* thread tier

* remove console logs

* requested changes to getting user tier

* test

* fix test error

* fix type errors on test

* pull latest

* route middleware fix

* double tierIdentifier

* updated test file

* build error

* plain cards fix up

* dayjs

* change orgs check to teams isOrgs check

* removed uneeded prisma calls and changed teams check
This commit is contained in:
nizzy
2025-02-28 17:42:34 +00:00
committed by GitHub
parent c31b7eaaf1
commit 1849fe7b74
8 changed files with 568 additions and 444 deletions
+430 -2
View File
@@ -1,4 +1,3 @@
import { cardExamples } from "@pages/api/plain/example-cards";
import { apiRouteMiddleware } from "app/api/apiRouteMiddleware";
import { createHmac } from "crypto";
import { headers } from "next/headers";
@@ -6,9 +5,439 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { CardComponent } from "@lib/plain/card-components";
const customerCardDisplay = (
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
): Card => {
return {
key: "customer-card",
timeToLiveSeconds: null,
components: [
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentText: {
text: "Basic Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: name || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: email || "Unknown",
},
},
],
},
},
{
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",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Account Settings Section Title
{
componentText: {
text: "Account Settings",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Time Zone",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: timeZone || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Identity Provider",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: identityProvider || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Last Active At",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: lastActiveAt ? dayjs(lastActiveAt).fromNow() : "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Team Information Section Title
{
componentText: {
text: "Team Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamName || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Slug",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamSlug || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Is Organization",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: isOrganization ? "Yes" : "No",
badgeColor: isOrganization ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
{
componentText: {
text: "Security & Billing",
textColor: "NORMAL",
textSize: "M",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Stripe Customer ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: stripeCustomerId || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email Verified?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: emailVerified === null ? "No" : emailVerified ? "Yes" : "No",
badgeColor: emailVerified === null ? "RED" : emailVerified ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Two Factor Enabled?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: twoFactorEnabled === null ? "No" : twoFactorEnabled ? "Yes" : "No",
badgeColor: twoFactorEnabled === null ? "RED" : twoFactorEnabled ? "GREEN" : "RED",
},
},
],
},
},
],
};
};
const Card = z.object({
key: z.string(),
timeToLiveSeconds: z.number().int().min(0).nullish().default(null),
components: z.array(CardComponent).nullable(),
});
type Card = z.infer<typeof Card>;
const cardExamples: ((
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
) => Card)[] = [customerCardDisplay];
const inputSchema = z.object({
customer: z.object({
name: z.string().optional(),
@@ -37,7 +466,6 @@ async function handler(request: NextRequest) {
const expectedSignature = createHmac("sha-256", process.env.PLAIN_HMAC_SECRET_KEY!)
.update(JSON.stringify(requestBody))
.digest("hex");
if (incomingSignature !== expectedSignature) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
+31
View File
@@ -5,6 +5,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -15,6 +16,7 @@ const responseSchema = z.object({
appId: z.string(),
fullName: z.string(),
chatAvatarUrl: z.string(),
userTier: z.enum(["free", "teams", "enterprise"]),
});
async function handler() {
@@ -28,6 +30,34 @@ async function handler() {
return NextResponse.json({ error: "Missing Plain Chat secret" }, { status: 500 });
}
// Get user's team membership info
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
teams: {
select: {
team: {
select: {
isOrganization: true,
},
},
},
},
},
});
// Check if user is part of a team and determine tier
let userTier = "free";
if (user?.teams.length) {
const teamMemberships = user.teams;
const isEnterprise = teamMemberships.some((membership) => membership.team.isOrganization === true);
const isTeams = user?.teams.length > 0;
userTier = isEnterprise ? "enterprise" : isTeams ? "teams" : "free";
}
const hmac = createHmac("sha256", secret);
hmac.update(session.user.email.toLowerCase().trim());
const hash = hmac.digest("hex");
@@ -43,6 +73,7 @@ async function handler() {
appId: process.env.NEXT_PUBLIC_PLAIN_CHAT_ID,
fullName: session.user.name || "User",
chatAvatarUrl: session.user.avatarUrl || "",
userTier,
});
return NextResponse.json(response);
@@ -72,7 +72,7 @@ const Container = z.object({
containerContent: z.array(ContainerContentUnionInput).min(1),
});
export const Component = z.object({
export const CardComponent = z.object({
componentText: Text.optional(),
componentDivider: Divider.optional(),
componentLinkButton: LinkButton.optional(),
@@ -83,4 +83,4 @@ export const Component = z.object({
componentContainer: Container.optional(),
});
export type Component = z.infer<typeof Component>;
export type CardComponent = z.infer<typeof CardComponent>;
+30
View File
@@ -34,6 +34,12 @@ interface PlainChatConfig {
chatButtons: Array<{
icon: string;
text: string;
threadDetails?: {
labelTypeIds: Array<string>;
tierIdentifier: {
externalId: string;
};
};
form?: {
fields: Array<{
type: string;
@@ -43,7 +49,11 @@ interface PlainChatConfig {
text: string;
threadDetails: {
labelTypeIds: Array<string>;
tierIdentifier: {
externalId: string;
};
};
onClick?: () => void;
}>;
}>;
};
@@ -150,10 +160,18 @@ const PlainChat = () => {
{
icon: "chat",
text: "Ask a question",
threadDetails: {
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
tierIdentifier: { externalId: data.userTier },
},
},
{
icon: "bulb",
text: "Send feedback",
threadDetails: {
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
tierIdentifier: { externalId: data.userTier },
},
},
{
icon: "error",
@@ -169,6 +187,10 @@ const PlainChat = () => {
text: "I'm unable to use the app",
threadDetails: {
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
tierIdentifier: { externalId: data.userTier },
},
onClick: () => {
console.log("User selected severity: Unable to use app");
},
},
{
@@ -176,6 +198,10 @@ const PlainChat = () => {
text: "Major functionality degraded",
threadDetails: {
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
tierIdentifier: { externalId: data.userTier },
},
onClick: () => {
console.log("User selected severity: Major functionality degraded");
},
},
{
@@ -183,6 +209,10 @@ const PlainChat = () => {
text: "Minor annoyance",
threadDetails: {
labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"],
tierIdentifier: { externalId: data.userTier },
},
onClick: () => {
console.log("User selected severity: Minor annoyance");
},
},
],
@@ -1,407 +0,0 @@
/* eslint-disable import/no-anonymous-default-export */
import type { Card } from "@pages/api/plain";
import dayjs from "@calcom/dayjs";
export default (
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
): Card => {
return {
key: "customer-card",
timeToLiveSeconds: null,
components: [
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentText: {
text: "Basic Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: name || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: email || "Unknown",
},
},
],
},
},
{
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",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Account Settings Section Title
{
componentText: {
text: "Account Settings",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Time Zone",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: timeZone || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Identity Provider",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: identityProvider || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Last Active At",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: lastActiveAt ? dayjs(lastActiveAt).fromNow() : "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Team Information Section Title
{
componentText: {
text: "Team Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamName || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Slug",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamSlug || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Is Organization",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: isOrganization ? "Yes" : "No",
badgeColor: isOrganization ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
{
componentText: {
text: "Security & Billing",
textColor: "NORMAL",
textSize: "M",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Stripe Customer ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: stripeCustomerId || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email Verified?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: emailVerified === null ? "No" : emailVerified ? "Yes" : "No",
badgeColor: emailVerified === null ? "RED" : emailVerified ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Two Factor Enabled?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: twoFactorEnabled === null ? "No" : twoFactorEnabled ? "Yes" : "No",
badgeColor: twoFactorEnabled === null ? "RED" : twoFactorEnabled ? "GREEN" : "RED",
},
},
],
},
},
],
};
};
@@ -1,18 +0,0 @@
import type { Card } from "@pages/api/plain";
import customerCardDisplay from "@pages/api/plain/customer-card-display";
export const cardExamples: ((
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
) => Card)[] = [customerCardDisplay];
-15
View File
@@ -1,15 +0,0 @@
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>;
+75
View File
@@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { POST } from "../../app/api/plain-hash/route";
// Mock NextResponse
vi.mock("next/server", () => ({
NextResponse: {
json: (data: any, init?: ResponseInit) => ({
json: async () => data,
status: init?.status || 200,
}),
},
}));
// Mock session
vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: () => new Headers(),
cookies: () => ({ get: () => null }),
}));
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn().mockReturnValue({
headers: new Headers(),
cookies: {},
}),
}));
vi.mock("app/api/apiRouteMiddleware", () => ({
apiRouteMiddleware: (handler: () => Promise<Response>) => handler,
}));
vi.mock("@calcom/prisma", () => ({
default: prismaMock,
}));
describe("Plain Integration API", () => {
beforeAll(() => {
process.env.PLAIN_CHAT_HMAC_SECRET_KEY = "test-secret";
process.env.NEXT_PUBLIC_PLAIN_CHAT_ID = "test-chat-id";
});
beforeEach(() => {
vi.resetAllMocks();
});
const tiers = [
{ tier: "free", teams: [] },
{ tier: "teams", teams: [{ team: { id: 2 } }] },
{ tier: "enterprise", teams: [{ team: { id: 3, isOrganization: true } }] },
];
it.each(tiers)("should return $tier tier", async ({ tier, teams }) => {
const userData = { id: 1, email: `${tier}@example.com`, name: `${tier} User` };
(getServerSession as any).mockResolvedValue({ user: userData });
prismaMock.user.findUnique.mockResolvedValue({ ...userData, teams });
const data = await (await POST({} as NextRequest, { params: {} })).json();
expect(data).toMatchObject({ userTier: tier, email: userData.email, fullName: userData.name });
});
it("should return 401 when no session exists", async () => {
(getServerSession as any).mockResolvedValue(null);
expect((await POST({} as NextRequest, { params: {} })).status).toBe(401);
});
});