diff --git a/.env.example b/.env.example index 64ef21e4ac..016b02fd22 100644 --- a/.env.example +++ b/.env.example @@ -103,19 +103,14 @@ NEXT_PUBLIC_INTERCOM_APP_ID= # Secret to enable Intercom Identity Verification INTERCOM_SECRET= +# Intercom api token for free users +INTERCOM_API_TOKEN= + # Posthog Config 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= -NEXT_PUBLIC_PLAIN_CHAT_ID= -PLAIN_CHAT_HMAC_SECRET_KEY= -NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS= # Dub Config diff --git a/apps/web/app/api/customer-card/route.ts b/apps/web/app/api/customer-card/route.ts deleted file mode 100644 index 64a6f484f7..0000000000 --- a/apps/web/app/api/customer-card/route.ts +++ /dev/null @@ -1,546 +0,0 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import { createHmac } from "crypto"; -import { headers } from "next/headers"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { z } from "zod"; - -import dayjs from "@calcom/dayjs"; -import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema"; -import { UserRepository } from "@calcom/lib/server/repository/user"; -import prisma from "@calcom/prisma"; -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(), - email: z.string().email(), - username: z.string().optional(), - timeZone: timeZoneSchema.optional(), - emailVerified: z.boolean().optional(), - identityProvider: z.string().optional(), - twoFactorEnabled: z.boolean().optional(), - lastActiveAt: z.string().optional(), - teamName: z.string().optional(), - teamSlug: z.string().optional(), - isOrganization: z.boolean().optional(), - stripeCustomerId: z.string().optional(), - }), - cardKeys: z.array(z.string()), -}); - -async function handler(request: NextRequest) { - const headersList = await headers(); - const requestBody = await request.json(); - - // HMAC verification - const incomingSignature = headersList.get("plain-request-signature"); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - 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 }); - } - - // Validate request body - const { cardKeys, customer } = inputSchema.parse(requestBody); - - const userRepo = new UserRepository(prisma); - const user = await userRepo.findByEmail({ email: customer.email }); - - if (!user) { - return NextResponse.json({ - cards: [ - { - key: "customer-card", - timeToLiveSeconds: null, - components: [ - { - componentSpacer: { - spacerSize: "M", - }, - }, - { - componentText: { - textSize: "L", - textColor: "MUTED", - text: "User does not exist!", - }, - }, - ], - }, - ], - }); - } - - // Fetch team details including userId and team name - const teamMemberships = await userRepo.findTeamsByUserId({ userId: user.id }); - const firstTeam = teamMemberships.teams[0] ?? null; - - // Parse user metadata - const parsedMetadata = userMetadata.parse(user.metadata); - - const cards = await Promise.all( - cardExamples.map(async (cardFn) => { - return cardFn( - user.name || "Unknown", - user.email, - user.id.toString(), - user.username || "Unknown", - user.timeZone, - user.emailVerified, - user.twoFactorEnabled, - user.identityProvider, - user.lastActiveAt, - firstTeam?.name || "Unknown", - firstTeam?.slug || "Unknown", - firstTeam?.isOrganization || false, - (user.metadata as { stripeCustomerId?: string })?.stripeCustomerId || "Unknown" - ); - }) - ); - - 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 = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/plain-hash/route.ts b/apps/web/app/api/plain-hash/route.ts deleted file mode 100644 index 6a239a7c5f..0000000000 --- a/apps/web/app/api/plain-hash/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import { createHmac } from "crypto"; -import { cookies, headers } from "next/headers"; -import { NextResponse } from "next/server"; -import { z } from "zod"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants"; -import prisma from "@calcom/prisma"; - -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; - -const responseSchema = z.object({ - hash: z.string(), - email: z.string().email(), - shortName: z.string(), - appId: z.string(), - fullName: z.string(), - chatAvatarUrl: z.string(), - userTier: z.enum(["free", "teams", "enterprise"]), -}); - -async function handler() { - // Early return if Plain Chat is not enabled - if (!IS_PLAIN_CHAT_ENABLED) { - return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 }); - } - - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorized - No session email found" }, { status: 401 }); - } - - const secret = process.env.PLAIN_CHAT_HMAC_SECRET_KEY; - if (!secret) { - 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"); - - const shortName = - (session.user.name?.split(" ")[0] || session.user.email).charAt(0).toUpperCase() + - (session.user.name?.split(" ")[0] || session.user.email).slice(1) || "User"; - - const response = responseSchema.parse({ - hash, - email: session.user.email || "user@example.com", - shortName, - appId: process.env.NEXT_PUBLIC_PLAIN_CHAT_ID, - fullName: session.user.name || "User", - chatAvatarUrl: session.user.avatarUrl || "", - userTier, - }); - - return NextResponse.json(response); -} - -export const POST = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/support/__tests__/route.test.ts b/apps/web/app/api/support/__tests__/route.test.ts deleted file mode 100644 index 21c6f6d9d6..0000000000 --- a/apps/web/app/api/support/__tests__/route.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; - -import { POST } from "../route"; - -vi.mock("@calcom/features/auth/lib/getServerSession", () => ({ - getServerSession: vi.fn(), -})); - -vi.mock("@calcom/lib/constants", async () => { - const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants"); - return { - ...actual, - IS_PRODUCTION: true, - IS_PLAIN_CHAT_ENABLED: true, - }; -}); - -vi.mock("@lib/buildLegacyCtx", () => ({ - buildLegacyRequest: vi.fn(() => ({ headers: {}, cookies: {} })), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(() => new Map()), - cookies: vi.fn(() => ({ getAll: () => [] })), -})); - -vi.mock("next/server", () => ({ - NextResponse: { - json: vi.fn((data, options) => ({ - json: () => Promise.resolve(data), - status: options?.status || 200, - })), - }, -})); - -vi.mock("@lib/plain/plain", () => { - const mockGetCustomerByEmail = vi.fn(); - const mockCreateThread = vi.fn(); - const mockUpsertPlainCustomer = vi.fn(); - - return { - plain: { - getCustomerByEmail: mockGetCustomerByEmail, - createThread: mockCreateThread, - }, - upsertPlainCustomer: mockUpsertPlainCustomer, - }; -}); - -const mockGetServerSession = vi.mocked(getServerSession); - -describe("/api/support", () => { - beforeEach(() => { - vi.clearAllMocks(); - process.env.PLAIN_API_KEY = "test-api-key"; - }); - - it("should return 404 when Plain Chat is disabled", async () => { - expect(true).toBe(true); - }); - - it("should return 401 when user is not authenticated", async () => { - mockGetServerSession.mockResolvedValue(null); - - const request = new Request("http://localhost:3000/api/support", { - method: "POST", - body: JSON.stringify({ - message: "Test message", - attachmentIds: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(401); - }); - - it("should return 500 for invalid form data", async () => { - mockGetServerSession.mockResolvedValue({ - hasValidLicense: true, - upId: "test-up-id", - expires: "2025-12-31T23:59:59.999Z", - user: { id: 123, email: "test@example.com" }, - }); - - const request = new Request("http://localhost:3000/api/support", { - method: "POST", - body: JSON.stringify({ - message: "", - attachmentIds: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(500); - }); - - it("should return 500 when Plain API key is not configured", async () => { - delete process.env.PLAIN_API_KEY; - - mockGetServerSession.mockResolvedValue({ - hasValidLicense: true, - upId: "test-up-id", - expires: "2025-12-31T23:59:59.999Z", - user: { id: 123, email: "test@example.com" }, - }); - - const request = new Request("http://localhost:3000/api/support", { - method: "POST", - body: JSON.stringify({ - message: "Test message", - attachmentIds: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(500); - }); - - it("should successfully create customer and thread", async () => { - mockGetServerSession.mockResolvedValue({ - hasValidLicense: true, - upId: "test-up-id", - expires: "2025-12-31T23:59:59.999Z", - user: { id: 123, email: "test@example.com" }, - }); - - const { plain } = vi.mocked(await import("@lib/plain/plain")); - vi.mocked(plain.getCustomerByEmail).mockResolvedValue({ - data: { - id: "customer-123", - __typename: "Customer", - fullName: "Test User", - shortName: "Test", - externalId: "123", - email: { - email: "test@example.com", - isVerified: true, - verifiedAt: { - __typename: "DateTime", - iso8601: "2025-01-01T00:00:00Z", - unixTimestamp: "1735689600", - }, - }, - company: null, - createdBy: { __typename: "SystemActor" as const, systemId: "system" }, - updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - markedAsSpamAt: null, - }, - }); - - vi.mocked(plain.createThread).mockResolvedValue({ - data: { - id: "thread-123", - __typename: "Thread", - externalId: null, - title: "Test message", - description: null, - status: "Todo" as any, - statusChangedAt: { - __typename: "DateTime", - iso8601: "2025-01-01T00:00:00Z", - unixTimestamp: "1735689600", - }, - statusDetail: null, - customer: { id: "customer-123" }, - priority: 0, - previewText: "Test message", - updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - } as any, - }); - - const request = new Request("http://localhost:3000/api/support", { - method: "POST", - body: JSON.stringify({ - message: "Test message", - attachmentIds: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(200); - - const responseData = await response.json(); - expect(responseData).toBeDefined(); - - expect(vi.mocked(plain.getCustomerByEmail)).toHaveBeenCalledWith({ email: "test@example.com" }); - expect(vi.mocked(plain.createThread)).toHaveBeenCalled(); - }); - - it("should handle form submission with file attachments", async () => { - mockGetServerSession.mockResolvedValue({ - hasValidLicense: true, - upId: "test-up-id", - expires: "2025-12-31T23:59:59.999Z", - user: { id: 123, email: "test@example.com" }, - }); - - const { plain, upsertPlainCustomer } = vi.mocked(await import("@lib/plain/plain")); - vi.mocked(plain.getCustomerByEmail).mockResolvedValue({ - data: null, - }); - - vi.mocked(upsertPlainCustomer).mockResolvedValue({ - data: { - result: "Created" as any, - customer: { - id: "customer-456", - __typename: "Customer", - fullName: "Test User", - shortName: "Test", - externalId: "123", - email: { - email: "test@example.com", - isVerified: true, - verifiedAt: { - __typename: "DateTime", - iso8601: "2025-01-01T00:00:00Z", - unixTimestamp: "1735689600", - }, - }, - company: null, - createdBy: { __typename: "SystemActor" as const, systemId: "system" }, - updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - markedAsSpamAt: null, - }, - }, - }); - - vi.mocked(plain.createThread).mockResolvedValue({ - data: { - id: "thread-456", - __typename: "Thread", - externalId: null, - title: "Test message", - description: null, - status: "Todo" as any, - statusChangedAt: { - __typename: "DateTime", - iso8601: "2025-01-01T00:00:00Z", - unixTimestamp: "1735689600", - }, - statusDetail: null, - customer: { id: "customer-456" }, - priority: 0, - previewText: "Test message", - updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - } as any, - }); - - const request = new Request("http://localhost:3000/api/support", { - method: "POST", - body: JSON.stringify({ - message: "Test message", - attachmentIds: ["attachment-123"], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(200); - - const responseData = await response.json(); - expect(responseData).toBeDefined(); - - expect(vi.mocked(plain.getCustomerByEmail)).toHaveBeenCalledWith({ email: "test@example.com" }); - expect(vi.mocked(upsertPlainCustomer)).toHaveBeenCalled(); - expect(vi.mocked(plain.createThread)).toHaveBeenCalled(); - }); - - it("should handle Plain customer creation error", async () => { - mockGetServerSession.mockResolvedValue({ - hasValidLicense: true, - upId: "test-up-id", - expires: "2025-12-31T23:59:59.999Z", - user: { id: 123, email: "test@example.com" }, - }); - - const { plain, upsertPlainCustomer } = vi.mocked(await import("@lib/plain/plain")); - vi.mocked(plain.getCustomerByEmail).mockResolvedValue({ - data: null, - }); - - vi.mocked(upsertPlainCustomer).mockResolvedValue({ - error: { - type: "unknown", - message: "Customer creation failed", - }, - }); - - const request = new Request("http://localhost:3000/api/support", { - method: "POST", - body: JSON.stringify({ - message: "Test message", - attachmentIds: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(500); - }); - - it("should handle Plain thread creation error", async () => { - mockGetServerSession.mockResolvedValue({ - hasValidLicense: true, - upId: "test-up-id", - expires: "2025-12-31T23:59:59.999Z", - user: { id: 123, email: "test@example.com" }, - }); - - const { plain } = vi.mocked(await import("@lib/plain/plain")); - vi.mocked(plain.getCustomerByEmail).mockResolvedValue({ - data: { - id: "customer-123", - __typename: "Customer", - fullName: "Test User", - shortName: "Test", - externalId: "123", - email: { - email: "test@example.com", - isVerified: true, - verifiedAt: { - __typename: "DateTime", - iso8601: "2025-01-01T00:00:00Z", - unixTimestamp: "1735689600", - }, - }, - company: null, - createdBy: { __typename: "SystemActor" as const, systemId: "system" }, - updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" }, - markedAsSpamAt: null, - }, - }); - - vi.mocked(plain.createThread).mockResolvedValue({ - error: { - type: "unknown", - message: "Thread creation failed", - }, - }); - - const request = new Request("http://localhost:3000/api/support", { - method: "POST", - body: JSON.stringify({ - message: "Test message", - attachmentIds: [], - }), - }); - - const response = await POST(request); - expect(response.status).toBe(500); - }); -}); diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts new file mode 100644 index 0000000000..d2f1cc2930 --- /dev/null +++ b/apps/web/app/api/support/conversation/route.ts @@ -0,0 +1,122 @@ +import { cookies, headers } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom"; +import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom"; +import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { UserRepository } from "@calcom/lib/server/repository/user"; +import prisma from "@calcom/prisma"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + +const log = logger.getSubLogger({ prefix: [`/api/support/conversation`] }); + +export async function POST(req: NextRequest) { + const headersList = await headers(); + const cookiesList = await cookies(); + const legacyReq = buildLegacyRequest(headersList, cookiesList); + + const session = await getServerSession({ req: legacyReq }); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 }); + } + + const intercomApiKey = process.env.INTERCOM_API_TOKEN; + if (!intercomApiKey) { + return NextResponse.json({ error: "Intercom API key not configured" }, { status: 500 }); + } + + let contact: Contact; + + const existingContact = await intercom.getContactByEmail(session.user.email); + + if (existingContact.error) { + return NextResponse.json( + { error: existingContact?.error ?? "Error fetching intercom contact for user" }, + { status: 502 } + ); + } + + const { user } = session; + + if (!existingContact.data) { + const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id }); + const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce( + (sum, team) => sum + team.team.eventTypes.length, + 0 + ); + + const newContact = await intercom.createContact({ + email: session.user.email, + external_id: session.user.id.toString(), + name: session.user.name ?? session.user.email, + type: "contact", + custom_attributes: { + user_name: user?.username, + link: `${WEBSITE_URL}/${encodeURIComponent(user?.username ?? "")}`, + admin_link: `${WEBAPP_URL}/settings/admin/users/${user?.id}/edit`, + impersonate_user: `${WEBAPP_URL}/settings/admin/impersonation?username=${encodeURIComponent( + user?.email ?? user?.username ?? "" + )}`, + locale: user?.locale, + completed_onboarding: user?.completedOnboarding, + is_logged_in: !!user, + has_orgs_plan: !!user?.org, + organization: user?.org?.slug, + sum_of_bookings: additionalUserInfo?._count?.bookings, + sum_of_calendars: additionalUserInfo?._count?.userLevelSelectedCalendars, + sum_of_teams: additionalUserInfo?._count?.teams, + sum_of_event_types: additionalUserInfo?._count?.eventTypes, + sum_of_team_event_types: sumOfTeamEventTypes, + }, + }); + + if (newContact.error || !newContact.data) { + return NextResponse.json( + { error: newContact.error ?? "Error creating contact from email" }, + { status: 500 } + ); + } + + contact = newContact.data; + } else { + contact = existingContact.data; + } + + if (!contact) { + return NextResponse.json({ error: "No contactId found" }, { status: 404 }); + } + + try { + const { message } = await req.json(); + + if (!message.trim()) { + return NextResponse.json({ error: "Cannot start a conversation without message" }, { status: 400 }); + } + + const conversation = await intercom.createConversation({ + body: message, + from: { + id: contact.id, + type: "user", + }, + }); + + if (conversation.error) { + return NextResponse.json( + { error: conversation.error ?? "Error creating conversation" }, + { status: 400 } + ); + } + + return NextResponse.json({ message: "Created conversation in Intercom" }, { status: 200 }); + } catch (err) { + console.error(err); + log.error(`Error creating Intercom conversation:`, safeStringify(err)); + return NextResponse.json({ error: "Unexpected error occurred" }, { status: 500 }); + } +} diff --git a/apps/web/app/api/intercom-hash/route.ts b/apps/web/app/api/support/hash/route.ts similarity index 100% rename from apps/web/app/api/intercom-hash/route.ts rename to apps/web/app/api/support/hash/route.ts diff --git a/apps/web/app/api/support/route.ts b/apps/web/app/api/support/route.ts deleted file mode 100644 index e5276c8113..0000000000 --- a/apps/web/app/api/support/route.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { cookies, headers } from "next/headers"; -import { NextResponse } from "next/server"; -import { z } from "zod"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants"; -import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; - -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; -import { plain, upsertPlainCustomer } from "@lib/plain/plain"; - -const contactFormSchema = z.object({ - message: z.string().min(1, "Message is required"), - attachmentIds: z.array(z.string()).optional(), -}); - -const log = logger.getSubLogger({ prefix: [`/api/support`] }); - -export async function POST(req: Request) { - if (!IS_PLAIN_CHAT_ENABLED) { - return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 }); - } - - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 }); - } - - try { - const body = await req.json(); - const { message, attachmentIds } = contactFormSchema.parse(body); - - const plainApiKey = process.env.PLAIN_API_KEY; - if (!plainApiKey) { - return NextResponse.json({ error: "Plain API key not configured" }, { status: 500 }); - } - - let plainCustomerId: string | null = null; - - const plainCustomer = await plain.getCustomerByEmail({ email: session.user.email }); - - if (plainCustomer.data) { - plainCustomerId = plainCustomer.data.id; - } else { - const { data, error } = await upsertPlainCustomer({ - email: session.user.email, - id: session.user.id, - name: session.user.name, - }); - - if (error) { - log.error(`Error submitting plain contact form: `, safeStringify(error)); - return NextResponse.json( - { - message: error.message, - }, - { status: 500 } - ); - } - - if (data) { - plainCustomerId = data.customer.id; - } - } - - if (!plainCustomerId) { - return NextResponse.json({ message: "Plain customer not found" }, { status: 404 }); - } - - const { data, error } = await plain.createThread({ - customerIdentifier: { - customerId: plainCustomerId, - }, - components: [ - { - componentText: { - text: message, - }, - }, - ], - attachmentIds, - }); - - if (error) { - log.error("Error creating plain contact form thread: ", safeStringify(error)); - return NextResponse.json({ message: error.message }, { status: 500 }); - } - - return NextResponse.json(data); - } catch (err) { - log.error(`Error submitting plain contact form: `, safeStringify(err)); - return NextResponse.json({ message: "Unexpected error occured" }, { status: 500 }); - } -} diff --git a/apps/web/app/api/support/upload/route.ts b/apps/web/app/api/support/upload/route.ts deleted file mode 100644 index f473c70a25..0000000000 --- a/apps/web/app/api/support/upload/route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { AttachmentType } from "@team-plain/typescript-sdk"; -import { cookies, headers } from "next/headers"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants"; -import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; - -import { buildLegacyRequest } from "@lib/buildLegacyCtx"; -import { plain, upsertPlainCustomer } from "@lib/plain/plain"; - -const log = logger.getSubLogger({ prefix: ["/api/support/upload"] }); - -/** - * Returns a signed url from plain to upload the attachment - */ -export async function GET(req: NextRequest) { - if (!IS_PLAIN_CHAT_ENABLED) { - return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 }); - } - - const searchParams = req.nextUrl.searchParams; - const name = searchParams.get("name"); - const size = searchParams.get("size"); - - if (!name || !size) { - return NextResponse.json({ error: "Missing required parameters: name and size" }, { status: 400 }); - } - - const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 }); - } - - let plainCustomerId: string | null = null; - - const plainCustomer = await plain.getCustomerByEmail({ - email: session.user.email, - }); - - if (plainCustomer.data) { - plainCustomerId = plainCustomer.data.id; - } else { - const { data, error } = await upsertPlainCustomer({ - name: session.user.name, - email: session.user.email, - id: session.user.id, - }); - - if (error) { - log.error("Error getting customer info: ", safeStringify(error)); - return NextResponse.json({ error: error.message }, { status: 500 }); - } - - if (data) { - plainCustomerId = data.customer.id; - } - } - - if (!plainCustomerId) { - return NextResponse.json({ error: "Plain customer not found" }, { status: 404 }); - } - - const { data, error } = await plain.createAttachmentUploadUrl({ - customerId: plainCustomerId, - fileName: name, - fileSizeBytes: parseInt(size), - attachmentType: AttachmentType.CustomTimelineEntry, - }); - - if (error) { - log.error(`Error getting signed url for attachment upload: `, safeStringify(error)); - return NextResponse.json( - { - error: error.message, - }, - { status: 500 } - ); - } - - return NextResponse.json(data); -} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 3654a8c98a..2f51289b23 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -2,14 +2,12 @@ import { TrpcProvider } from "app/_trpc/trpc-provider"; import { SessionProvider } from "next-auth/react"; -import { usePathname } from "next/navigation"; import CacheProvider from "react-inlinesvg/provider"; import { WebPushProvider } from "@calcom/features/notifications/WebPushContext"; import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler"; import useIsBookingPage from "@lib/hooks/useIsBookingPage"; -import PlainChat from "@lib/plain/dynamicProvider"; type ProvidersProps = { isEmbed: boolean; @@ -18,13 +16,10 @@ type ProvidersProps = { }; export function Providers({ isEmbed, children, nonce }: ProvidersProps) { const isBookingPage = useIsBookingPage(); - const pathname = usePathname(); - const isOnboardingPage = pathname?.startsWith("/getting-started"); return ( - {!isBookingPage && !isOnboardingPage ? : null} {!isEmbed && !isBookingPage && } {/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */} diff --git a/apps/web/components/plain/PlainContactForm.tsx b/apps/web/components/plain/PlainContactForm.tsx deleted file mode 100644 index 039d19f5cf..0000000000 --- a/apps/web/components/plain/PlainContactForm.tsx +++ /dev/null @@ -1,236 +0,0 @@ -"use client"; - -import { Dispatch, SetStateAction, useState } from "react"; - -import { Button } from "@calcom/ui/components/button"; -import { FileUploader, type FileData } from "@calcom/ui/components/file-uploader"; -import { Label, TextArea } from "@calcom/ui/components/form"; -import { Icon } from "@calcom/ui/components/icon"; -import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover"; -import { showToast } from "@calcom/ui/components/toast"; - -const PlainContactForm = ({ open, setIsOpen }: { open: boolean, setIsOpen: Dispatch> }) => { - const [isSubmitting, setIsSubmitting] = useState(false); - const [isSubmitted, setIsSubmitted] = useState(false); - const [isUploadingImage, setIsUploadingImage] = useState(false); - - const [data, setData] = useState<{ - message: string; - attachmentIds: string[]; - }>({ - message: "", - attachmentIds: [], - }); - - const [uploads, setUploads] = useState< - { - attachmentId?: string; - uploading: boolean; - file: File; - id: string; - }[] - >([]); - - const handleUpload = async (allFiles: FileData[], newFiles: FileData[], removedFiles: FileData[]) => { - if (newFiles.length > 0) { - const newFile = newFiles[0]; - setUploads((prev) => [...prev, { file: newFile.file, uploading: true, id: newFile.id }]); - setIsUploadingImage(true); - - const { file } = newFile; - - const res = await fetch(`/api/support/upload?name=${file.name}&size=${file.size}`); - if (!res.ok) { - showToast("Error uploading attachment", "error"); - setIsUploadingImage(false); - return; - } - - const { - uploadFormUrl, - uploadFormData, - attachment: { id: attachmentId }, - } = await res.json(); - setIsUploadingImage(false); - - const formData = new FormData(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - uploadFormData.forEach(({ key, value }: any) => { - formData.append(key, value); - }); - - formData.append("file", file); - - const uploadRes = await fetch(uploadFormUrl, { - method: "POST", - body: formData, - }); - - if (!uploadRes.ok) { - showToast(`Failed while uploading file: ${uploadRes.text}`, "error"); - setIsUploadingImage(false); - return; - } - - setUploads((prev) => - prev.map((upload) => (upload.file === file ? { ...upload, uploading: false, attachmentId } : upload)) - ); - setData((prev) => ({ - ...prev, - attachmentIds: [...prev.attachmentIds, attachmentId], - })); - setIsUploadingImage(false); - showToast("File uploaded successfully", "success"); - } else if (removedFiles.length > 0) { - const removedFile = removedFiles[0]; - const file = uploads.find((upload) => upload.id === removedFile.id); - if (!file) { - console.warn("File not found in uploads: ", removedFile.id); - return; - } - setData((prev) => ({ - ...prev, - attachmentIds: prev.attachmentIds.filter((id) => id !== file.attachmentId), - })); - setUploads((prev) => prev.filter((upload) => upload.id !== removedFile.id)); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - try { - const response = await fetch("/api/support", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - const errorData = await response.json(); - showToast(errorData.message ?? "Failed to submit contact form", "error"); - setIsSubmitting(false); - return; - } - - setIsSubmitted(true); - setIsSubmitting(false); - setData({ - message: "", - attachmentIds: [], - }); - } catch (err) { - showToast(err instanceof Error ? err.message : "An error occurred", "error"); - setIsSubmitting(false); - } - }; - - const resetForm = () => { - setIsSubmitted(false); - setData({ - message: "", - attachmentIds: [], - }); - setUploads([]); - }; - - return ( -
- - - - - - -
-

Contact support

-
- -
- {isSubmitted ? ( -
-

Message Sent

-

- Thank you for contacting us. We'll get back to you as soon as possible. -

- -
- ) : ( -
-
- -