diff --git a/apps/web/app/api/customer-card/route.ts b/apps/web/app/api/customer-card/route.ts index 824fe1adce..e22164094a 100644 --- a/apps/web/app/api/customer-card/route.ts +++ b/apps/web/app/api/customer-card/route.ts @@ -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; + +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 }); } diff --git a/apps/web/app/api/plain-hash/route.ts b/apps/web/app/api/plain-hash/route.ts index 9ddb6cfda0..82cf359c31 100644 --- a/apps/web/app/api/plain-hash/route.ts +++ b/apps/web/app/api/plain-hash/route.ts @@ -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); diff --git a/apps/web/pages/api/plain/components.ts b/apps/web/lib/plain/card-components.ts similarity index 96% rename from apps/web/pages/api/plain/components.ts rename to apps/web/lib/plain/card-components.ts index 9e2b1ceb3f..465d52fbe2 100644 --- a/apps/web/pages/api/plain/components.ts +++ b/apps/web/lib/plain/card-components.ts @@ -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; +export type CardComponent = z.infer; diff --git a/apps/web/lib/plain/plainChat.tsx b/apps/web/lib/plain/plainChat.tsx index 8f1127a465..94a7f26888 100644 --- a/apps/web/lib/plain/plainChat.tsx +++ b/apps/web/lib/plain/plainChat.tsx @@ -34,6 +34,12 @@ interface PlainChatConfig { chatButtons: Array<{ icon: string; text: string; + threadDetails?: { + labelTypeIds: Array; + tierIdentifier: { + externalId: string; + }; + }; form?: { fields: Array<{ type: string; @@ -43,7 +49,11 @@ interface PlainChatConfig { text: string; threadDetails: { labelTypeIds: Array; + 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"); }, }, ], diff --git a/apps/web/pages/api/plain/customer-card-display.ts b/apps/web/pages/api/plain/customer-card-display.ts deleted file mode 100644 index eb3c76d5e9..0000000000 --- a/apps/web/pages/api/plain/customer-card-display.ts +++ /dev/null @@ -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", - }, - }, - ], - }, - }, - ], - }; -}; diff --git a/apps/web/pages/api/plain/example-cards/index.ts b/apps/web/pages/api/plain/example-cards/index.ts deleted file mode 100644 index ca688c9c8e..0000000000 --- a/apps/web/pages/api/plain/example-cards/index.ts +++ /dev/null @@ -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]; diff --git a/apps/web/pages/api/plain/index.ts b/apps/web/pages/api/plain/index.ts deleted file mode 100644 index 2073f7afd5..0000000000 --- a/apps/web/pages/api/plain/index.ts +++ /dev/null @@ -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; - -export const ResponseBody = z.object({ - cards: z.array(Card), -}); -export type ResponseBody = z.infer; diff --git a/apps/web/test/lib/plainTiers.test.ts b/apps/web/test/lib/plainTiers.test.ts new file mode 100644 index 0000000000..054562ee47 --- /dev/null +++ b/apps/web/test/lib/plainTiers.test.ts @@ -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) => 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); + }); +});