From 8391a7fbdda57b7b11c19b0d453cba8d2ec3a180 Mon Sep 17 00:00:00 2001 From: Madison Date: Wed, 15 Apr 2026 14:54:33 -0500 Subject: [PATCH 01/26] init tutorial docs --- .../build-a-saas-with-stack-auth.mdx | 548 +++++++++++++++++- 1 file changed, 541 insertions(+), 7 deletions(-) diff --git a/docs-mintlify/guides/other/tutorials/build-a-saas-with-stack-auth.mdx b/docs-mintlify/guides/other/tutorials/build-a-saas-with-stack-auth.mdx index 9aee84cddd..45eee7a36d 100644 --- a/docs-mintlify/guides/other/tutorials/build-a-saas-with-stack-auth.mdx +++ b/docs-mintlify/guides/other/tutorials/build-a-saas-with-stack-auth.mdx @@ -1,14 +1,548 @@ --- title: Build a SaaS with Stack Auth -description: End-to-end tutorial stub for building a SaaS app with Stack Auth. +description: End-to-end path from project setup to teams, permissions, and production for a multi-tenant SaaS on Stack Auth. --- -This tutorial is coming soon. +This tutorial maps a typical SaaS build onto Stack Auth: bootstrap auth, model customers as teams, enforce access with permissions, and close the loop before production. A plain Markdown version with the same structure lives in the repo at `docs-mintlify/guides/other/tutorials/build-a-saas-with-stack-auth.md`. -## Planned coverage +## What you will have at the end -- Project setup and auth bootstrapping -- App onboarding, teams, and permissions -- Production deployment checklist +- A working sign-in and account flow using Stack Auth's handler routes (Next.js) or your framework’s equivalent. +- A clear pattern for **who** is signed in and **what** they can access in the product. +- A **multi-tenant** shape (usually **teams** as organizations) with a sensible **team selection** story. +- A checklist mindset for **production** domains, OAuth, and email. -If you want this prioritized, open a request in our community channels and include your stack details. +## Prerequisites + +- A **Next.js** project using the **App Router** (Stack’s first-class path for hosted UI and handlers), or another stack supported in [Setup](/guides/getting-started/setup) (React, Express, or REST from any backend). +- A Stack Auth account and a **project** in the [dashboard](https://app.stack-auth.com/projects). + + + Stack does not officially support the Next.js Pages Router. If you are on Pages Router, consider the React or JavaScript SDKs per the [FAQ](/guides/faq). + + +The examples below focus on **Next.js (App Router)**. The same ideas apply on other stacks—swap in `StackClientApp` / REST calls as in [Setup](/guides/getting-started/setup). + +## 1. Install Stack and wire environment variables + +The fastest path for JavaScript and TypeScript is the **setup wizard**: + +```bash title="Terminal" +npx @stackframe/stack-cli@latest init +``` + +Then create or open a project in the dashboard and copy **project ID**, **publishable client key** (if your project uses one), and **secret server key** into your app configuration. For Next.js, that usually means `.env.local`: + +```bash title=".env.local" +NEXT_PUBLIC_STACK_PROJECT_ID= +NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= +STACK_SECRET_SERVER_KEY= +``` + +### What the wizard sets up (Next.js) + +After `init`, you should see files similar to: + +- `app/handler/[...stack]/page.tsx` — hosted sign-in, sign-up, account settings, and more +- `app/layout.tsx` — wraps the app with `StackProvider` and `StackTheme` +- `app/loading.tsx` — Suspense boundary for Stack’s async hooks +- `stack/server.ts` — `stackServerApp` for server components, actions, and route handlers +- `stack/client.ts` — `stackClientApp` when you need the client app object explicitly + +If you ever need to align manually with the wizard output, the core pieces look like this: + + + + ```typescript title="stack/server.ts" + import "server-only"; + import { StackServerApp } from "@stackframe/stack"; + + export const stackServerApp = new StackServerApp({ + tokenStore: "nextjs-cookie", + }); + ``` + + + ```typescript title="stack/client.ts" + import { StackClientApp } from "@stackframe/stack"; + + export const stackClientApp = new StackClientApp({ + // Reads NEXT_PUBLIC_STACK_* from the environment by default + }); + ``` + + + ```tsx title="app/handler/[...stack]/page.tsx" + import { StackHandler } from "@stackframe/stack"; + import { stackServerApp } from "@/stack/server"; + + export default function Handler(props: unknown) { + return ; + } + ``` + + + ```tsx title="app/layout.tsx" + import { StackProvider, StackTheme } from "@stackframe/stack"; + import { stackServerApp } from "@/stack/server"; + + export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); + } + ``` + + + ```tsx title="app/loading.tsx" + export default function Loading() { + return <>Loading...; + } + ``` + + + +Full variants (React, Express, Python, manual install) are in [Setup](/guides/getting-started/setup). + +After setup, open the hosted auth UI (for example `/handler/sign-up`), create a test user, and confirm you land back in your app. + +### Marketing header: sign in / sign out + +Use `useStackApp()` so you do not hard-code handler URLs (they can be customized in the project): + +```tsx title="components/auth-header.tsx" +"use client"; + +import Link from "next/link"; +import { useStackApp, useUser } from "@stackframe/stack"; + +export function AuthHeader() { + const app = useStackApp(); + const user = useUser(); + + return ( +
+ {user ? ( + <> + {user.displayName ?? user.primaryEmail ?? user.id} + Account + Sign out + + ) : ( + <> + Sign in + Sign up + + )} +
+ ); +} +``` + +## 2. Resolve the signed-in user everywhere + +Almost every SaaS screen starts from the **current user**: profile, billing state, which teams they belong to, and admin vs member behavior. + + + + ```tsx title="app/dashboard/page.tsx" + import { stackServerApp } from "@/stack/server"; + + export default async function DashboardPage() { + const user = await stackServerApp.getUser(); + + if (!user) { + return

You are not signed in.

; + } + + return

Hello, {user.displayName ?? user.primaryEmail ?? user.id}

; + } + ``` +
+ + ```tsx title="app/app/page.tsx" + import { stackServerApp } from "@/stack/server"; + + export default async function AppHomePage() { + const user = await stackServerApp.getUser({ or: "redirect" }); + return

Hello, {user.displayName ?? user.primaryEmail ?? user.id}

; + } + ``` +
+ + ```tsx title="components/greeting.tsx" + "use client"; + + import { useUser } from "@stackframe/stack"; + + export function Greeting() { + const user = useUser(); + + if (!user) { + return

Please sign in.

; + } + + return

Hello, {user.displayName ?? user.primaryEmail ?? user.id}

; + } + ``` +
+ + ```tsx title="components/greeting.tsx" + "use client"; + + import { useUser } from "@stackframe/stack"; + + export function Greeting() { + const user = useUser({ or: "redirect" }); + return

Hello, {user.displayName ?? user.primaryEmail ?? user.id}

; + } + ``` +
+
+ +### Server action that requires a user + +`{ or: "throw" }` is useful when a redirect would be wrong (for example, from a form POST): + +```tsx title="app/actions/workspace.ts" +"use server"; + +import { stackServerApp } from "@/stack/server"; + +export async function createWorkspaceAction(formData: FormData) { + const user = await stackServerApp.getUser({ or: "throw" }); + const displayName = String(formData.get("displayName") ?? "").trim(); + if (!displayName) { + throw new Error("Workspace name is required"); + } + + const team = await user.createTeam({ displayName }); + return { teamId: team.id }; +} +``` + +### Route Handler (App Router API) + +```tsx title="app/api/me/route.ts" +import { stackServerApp } from "@/stack/server"; +import { NextResponse } from "next/server"; + +export async function GET() { + const user = await stackServerApp.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return NextResponse.json({ + id: user.id, + displayName: user.displayName, + primaryEmail: user.primaryEmail, + }); +} +``` + +### Middleware for a `/app` (or `/private`) section + +Match only the routes that should be gated, and **exclude** `/handler` so Stack’s auth pages keep working: + +```tsx title="middleware.ts" +import { NextRequest, NextResponse } from "next/server"; +import { stackServerApp } from "@/stack/server"; + +export async function middleware(request: NextRequest) { + const user = await stackServerApp.getUser(); + if (!user) { + return NextResponse.redirect(new URL("/handler/sign-in", request.url)); + } + return NextResponse.next(); +} + +export const config = { + matcher: "/app/:path*", +}; +``` + +More detail on protection patterns and sensitive HTML is in [User fundamentals](/guides/getting-started/user-fundamentals). + + + Treat **client-side** permission and role checks as **UX only**. Anything that mutates data or exposes another tenant’s data must be enforced again on the server (server components, server actions, route handlers, or your backend using the secret key or verified tokens). + + +## 3. Model tenants with teams + +For B2B SaaS, a **team** is usually the right abstraction for an account, workspace, or organization. Users can belong to **multiple** teams; permissions are often evaluated **per team**. + +### List teams (client vs server) + + + + ```tsx title="components/workspace-list.tsx" + "use client"; + + import Link from "next/link"; + import { useUser } from "@stackframe/stack"; + + export function WorkspaceList() { + const user = useUser({ or: "redirect" }); + const teams = user.useTeams(); + + return ( +
    + {teams.map((team) => ( +
  • + {team.displayName} +
  • + ))} +
+ ); + } + ``` +
+ + ```tsx title="app/app/workspaces/page.tsx" + import Link from "next/link"; + import { stackServerApp } from "@/stack/server"; + + export default async function WorkspacesPage() { + const user = await stackServerApp.getUser({ or: "redirect" }); + const teams = await user.listTeams(); + + return ( +
    + {teams.map((team) => ( +
  • + {team.displayName} +
  • + ))} +
+ ); + } + ``` +
+
+ +### Create a workspace from the client + +Enable **client-side team creation** in the dashboard (Teams settings) if you call `createTeam` from the browser: + +```tsx title="components/create-workspace-button.tsx" +"use client"; + +import { useRouter } from "next/navigation"; +import { useUser } from "@stackframe/stack"; + +export function CreateWorkspaceButton() { + const user = useUser({ or: "redirect" }); + const router = useRouter(); + + return ( + + ); +} +``` + +### Create a workspace without tying it to the current user (server) + +Provisioning jobs (imports, admin tools) sometimes use the app-level API: + +```tsx title="scripts/provision-team.ts (example pattern)" +import { stackServerApp } from "@/stack/server"; + +export async function provisionEmptyTeam(displayName: string) { + return await stackServerApp.createTeam({ displayName }); +} +``` + +See [Teams](/guides/apps/teams/overview) for updates, metadata (`clientMetadata` / `serverMetadata`), and listing members. + +### Deep-link dashboard for one workspace + +Resolve the team **through the current user** so you only render data for workspaces they belong to: + +```tsx title="app/team/[teamId]/page.tsx" +import { stackServerApp } from "@/stack/server"; + +type PageProps = { params: { teamId: string } }; + +export default async function TeamHomePage({ params }: PageProps) { + const user = await stackServerApp.getUser({ or: "redirect" }); + const team = await user.getTeam(params.teamId); + + if (!team) { + return

You are not a member of this workspace.

; + } + + return ( +
+

{team.displayName}

+

Workspace ID: {team.id}

+
+ ); +} +``` + +### Team selection UI + +For a global “current team” switcher (often combined with deep links), use `SelectedTeamSwitcher` with the `Team` object from `useTeam`: + +```tsx title="components/team-switcher.tsx" +"use client"; + +import { SelectedTeamSwitcher, useUser } from "@stackframe/stack"; + +type Props = { currentTeamId: string }; + +export function TeamSwitcher({ currentTeamId }: Props) { + const user = useUser({ or: "redirect" }); + const team = user.useTeam(currentTeamId); + if (!team) { + return null; + } + + return ( + `/team/${t.id}`} + selectedTeam={team} + /> + ); +} +``` + +See [Team selection](/guides/apps/teams/team-selection) for `noUpdateSelectedTeam` and combining deep links with “default workspace” behavior. + +## 4. Authorize with RBAC + +Define permissions in the dashboard (nested permissions = roles). Then gate UI and **re-check on the server** before mutations. + +Replace `export_customer_data` with a permission you create in your project. + + + + ```tsx title="app/team/[teamId]/export/page.tsx" + import { stackServerApp } from "@/stack/server"; + + type PageProps = { params: { teamId: string } }; + + export default async function ExportPage({ params }: PageProps) { + const user = await stackServerApp.getUser({ or: "redirect" }); + const team = await user.getTeam(params.teamId); + if (!team) { + return

Workspace not found.

; + } + + const canExport = await user.getPermission(team, "export_customer_data"); + if (!canExport) { + return

You do not have access to exports in this workspace.

; + } + + return ; + } + ``` +
+ + ```tsx title="components/export-button.tsx" + "use client"; + + import { useUser } from "@stackframe/stack"; + import type { CurrentUser, Team } from "@stackframe/stack"; + + type Props = { teamId: string }; + + /** Split so `usePermission` only runs when `useTeam` returned a real team (Rules of Hooks). */ + export function ExportButton({ teamId }: Props) { + const user = useUser({ or: "redirect" }); + const team = user.useTeam(teamId); + if (!team) { + return

Workspace not found.

; + } + return ; + } + + function ExportButtonInner({ user, team }: { user: CurrentUser; team: Team }) { + const permission = user.usePermission(team, "export_customer_data"); + if (!permission) { + return null; + } + return ; + } + ``` +
+
+ +System permissions (IDs starting with `$`) ship with Stack—for example `$invite_members`. You can branch on those the same way: + +```tsx +const canInvite = await user.getPermission(team, "$invite_members"); +``` + +Full permission modeling is in [RBAC](/guides/apps/rbac/overview). + +## 5. Product polish: onboarding, email, and optional billing + +Hook flows to the guides—no extra Stack APIs are required at this layer: + +- **Onboarding and sign-up rules** — [User onboarding](/guides/apps/authentication/user-onboarding), [Sign-up rules](/guides/apps/authentication/sign-up-rules) +- **Email** — [Emails](/guides/apps/emails/overview) +- **Stripe / plans** — [Payments](/guides/apps/payments/overview) + +Example: after sign-up, send users to an onboarding route from your own `app/page.tsx` or a server layout once `getUser()` is non-null. + +## 6. Production checklist + +Before going live, tighten **callback domains**, replace shared **OAuth** keys with your own provider apps where needed, and review email and security defaults. Follow [Launch checklist](/guides/apps/launch-checklist/overview). + +## Related guides + +| Topic | Guide | +|--------|--------| +| Install and configure | [Setup](/guides/getting-started/setup) | +| `StackApp` object | [Stack App](/guides/going-further/stack-app) | +| Current user and page protection | [User fundamentals](/guides/getting-started/user-fundamentals) | +| Teams and membership | [Teams](/guides/apps/teams/overview) | +| Switching workspaces | [Team selection](/guides/apps/teams/team-selection) | +| Permissions | [RBAC](/guides/apps/rbac/overview) | +| Pre-launch hardening | [Launch checklist](/guides/apps/launch-checklist/overview) | +| Billing (optional) | [Payments](/guides/apps/payments/overview) | +| General questions | [FAQ](/guides/faq) | + +## FAQ + + + + No. Single-user B2C products can rely on **user permissions** and user-scoped data only. Teams become important when multiple people share one customer account or workspace. + + + + Use dashboard-defined permissions for **authorization**, but enforce **business rules** on the server: Server Components, server actions, route handlers, or your backend with the **secret server key** or validated access tokens. Client checks alone are not enough for sensitive operations. + + + + Yes. Non-JS or custom frontends can use the [REST API](/api/overview) with the same project keys; the mental model (users, teams, permissions) stays the same. + + + + Localhost callback behavior and production domain restrictions are covered under **Domains** in [Launch checklist](/guides/apps/launch-checklist/overview). Keep localhost allowances enabled only for development. + + + + [Build a team-based app](/guides/other/tutorials/build-a-team-based-app) goes deeper on teams and RBAC modeling. This SaaS tutorial is the broader **product** path: auth bootstrap, tenant selection, optional billing, and launch. + + + + See [FAQ](/guides/faq) for contribution and community pointers. + + From 2748980ef33b3752bdb4122897d0f3386b2ea001 Mon Sep 17 00:00:00 2001 From: Madison Date: Mon, 20 Apr 2026 13:43:40 -0500 Subject: [PATCH 02/26] init support app --- .../migration.sql | 93 ++ .../tests/creates-conversation-tables.ts | 214 +++ apps/backend/prisma/schema.prisma | 103 ++ .../conversations/[conversationId]/route.tsx | 212 +++ .../app/api/latest/conversations/route.tsx | 159 +++ .../conversations/[conversationId]/route.tsx | 176 +++ .../latest/internal/conversations/route.tsx | 127 ++ apps/backend/src/lib/conversation-types.ts | 85 ++ apps/backend/src/lib/conversations.tsx | 946 +++++++++++++ .../[projectId]/conversations/page-client.tsx | 1238 +++++++++++++++++ .../[projectId]/conversations/page.tsx | 9 + .../projects/[projectId]/support/page.tsx | 6 + .../users/[userId]/page-client.tsx | 11 +- apps/dashboard/src/lib/apps-frontend.tsx | 18 +- apps/dashboard/src/lib/conversation-types.ts | 71 + apps/dashboard/src/lib/conversations.ts | 116 ++ .../backend/endpoints/api/v1/support.test.ts | 256 ++++ claude/CLAUDE-KNOWLEDGE.md | 14 + packages/stack-shared/src/apps/apps-config.ts | 6 + .../src/interface/conversations.ts | 85 ++ 20 files changed, 3942 insertions(+), 3 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260420000000_add_conversations/migration.sql create mode 100644 apps/backend/prisma/migrations/20260420000000_add_conversations/tests/creates-conversation-tables.ts create mode 100644 apps/backend/src/app/api/latest/conversations/[conversationId]/route.tsx create mode 100644 apps/backend/src/app/api/latest/conversations/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/conversations/[conversationId]/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/conversations/route.tsx create mode 100644 apps/backend/src/lib/conversation-types.ts create mode 100644 apps/backend/src/lib/conversations.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/conversations/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/conversations/page.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/support/page.tsx create mode 100644 apps/dashboard/src/lib/conversation-types.ts create mode 100644 apps/dashboard/src/lib/conversations.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/support.test.ts create mode 100644 packages/stack-shared/src/interface/conversations.ts diff --git a/apps/backend/prisma/migrations/20260420000000_add_conversations/migration.sql b/apps/backend/prisma/migrations/20260420000000_add_conversations/migration.sql new file mode 100644 index 0000000000..e97244e584 --- /dev/null +++ b/apps/backend/prisma/migrations/20260420000000_add_conversations/migration.sql @@ -0,0 +1,93 @@ +CREATE TABLE "Conversation" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenancyId" UUID NOT NULL, + "projectUserId" UUID, + "teamId" UUID, + "subject" TEXT NOT NULL, + "status" TEXT NOT NULL, + "priority" TEXT NOT NULL, + "source" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastMessageAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastInboundAt" TIMESTAMP(3), + "lastOutboundAt" TIMESTAMP(3), + "closedAt" TIMESTAMP(3), + + CONSTRAINT "Conversation_pkey" PRIMARY KEY ("tenancyId","id"), + CONSTRAINT "Conversation_status_check" CHECK ("status" IN ('open', 'pending', 'closed')), + CONSTRAINT "Conversation_priority_check" CHECK ("priority" IN ('low', 'normal', 'high', 'urgent')), + CONSTRAINT "Conversation_source_check" CHECK ("source" IN ('manual', 'chat', 'email', 'api')), + CONSTRAINT "Conversation_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Conversation_projectUser_fkey" FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Conversation_team_fkey" FOREIGN KEY ("tenancyId", "teamId") REFERENCES "Team"("tenancyId", "teamId") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE "ConversationMetadata" ( + "conversationId" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "assignedToUserId" TEXT, + "assignedToDisplayName" TEXT, + "tags" JSONB, + "firstResponseDueAt" TIMESTAMP(3), + "firstResponseAt" TIMESTAMP(3), + "nextResponseDueAt" TIMESTAMP(3), + "lastCustomerReplyAt" TIMESTAMP(3), + "lastAgentReplyAt" TIMESTAMP(3), + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ConversationMetadata_pkey" PRIMARY KEY ("tenancyId","conversationId"), + CONSTRAINT "ConversationMetadata_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ConversationMetadata_conversation_fkey" FOREIGN KEY ("tenancyId", "conversationId") REFERENCES "Conversation"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE "ConversationChannel" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenancyId" UUID NOT NULL, + "conversationId" UUID NOT NULL, + "channelType" TEXT NOT NULL, + "adapterKey" TEXT NOT NULL, + "externalChannelId" TEXT, + "isEntryPoint" BOOLEAN NOT NULL DEFAULT FALSE, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ConversationChannel_pkey" PRIMARY KEY ("tenancyId","id"), + CONSTRAINT "ConversationChannel_type_check" CHECK ("channelType" IN ('manual', 'chat', 'email', 'api')), + CONSTRAINT "ConversationChannel_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ConversationChannel_conversation_fkey" FOREIGN KEY ("tenancyId", "conversationId") REFERENCES "Conversation"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE "ConversationMessage" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenancyId" UUID NOT NULL, + "conversationId" UUID NOT NULL, + "channelId" UUID, + "messageType" TEXT NOT NULL, + "senderType" TEXT NOT NULL, + "senderId" TEXT, + "senderDisplayName" TEXT, + "senderPrimaryEmail" TEXT, + "body" TEXT, + "attachments" JSONB, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ConversationMessage_pkey" PRIMARY KEY ("tenancyId","id"), + CONSTRAINT "ConversationMessage_messageType_check" CHECK ("messageType" IN ('message', 'internal-note', 'status-change')), + CONSTRAINT "ConversationMessage_senderType_check" CHECK ("senderType" IN ('user', 'agent', 'system')), + CONSTRAINT "ConversationMessage_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ConversationMessage_conversation_fkey" FOREIGN KEY ("tenancyId", "conversationId") REFERENCES "Conversation"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ConversationMessage_channel_fkey" FOREIGN KEY ("tenancyId", "channelId") REFERENCES "ConversationChannel"("tenancyId", "id") ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE INDEX "Conversation_user_lastMessageAt_idx" ON "Conversation"("tenancyId", "projectUserId", "lastMessageAt" DESC); +CREATE INDEX "Conversation_status_lastMessageAt_idx" ON "Conversation"("tenancyId", "status", "lastMessageAt" DESC); +CREATE INDEX "Conversation_team_lastMessageAt_idx" ON "Conversation"("tenancyId", "teamId", "lastMessageAt" DESC); +CREATE INDEX "ConversationChannel_conversation_createdAt_idx" ON "ConversationChannel"("tenancyId", "conversationId", "createdAt"); +CREATE INDEX "ConversationChannel_type_adapter_idx" ON "ConversationChannel"("tenancyId", "channelType", "adapterKey"); +CREATE INDEX "ConversationMessage_conversation_createdAt_idx" ON "ConversationMessage"("tenancyId", "conversationId", "createdAt"); +CREATE INDEX "ConversationMessage_channel_createdAt_idx" ON "ConversationMessage"("tenancyId", "channelId", "createdAt"); diff --git a/apps/backend/prisma/migrations/20260420000000_add_conversations/tests/creates-conversation-tables.ts b/apps/backend/prisma/migrations/20260420000000_add_conversations/tests/creates-conversation-tables.ts new file mode 100644 index 0000000000..b0cfa1dfa0 --- /dev/null +++ b/apps/backend/prisma/migrations/20260420000000_add_conversations/tests/creates-conversation-tables.ts @@ -0,0 +1,214 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const tenancyId = randomUUID(); + const projectUserId = randomUUID(); + + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Conversation Migration Test', '', false) + `; + await sql` + INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") + VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue") + `; + await sql` + INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") + VALUES (${projectUserId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW()) + `; + + return { tenancyId, projectUserId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const tables = await sql<{ table_name: string }[]>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('Conversation', 'ConversationMetadata', 'ConversationChannel', 'ConversationMessage') + ORDER BY table_name + `; + expect(Array.from(tables)).toMatchInlineSnapshot(` + [ + { + "table_name": "Conversation", + }, + { + "table_name": "ConversationChannel", + }, + { + "table_name": "ConversationMessage", + }, + { + "table_name": "ConversationMetadata", + }, + ] + `); + + const conversationId = randomUUID(); + const channelId = randomUUID(); + const messageId = randomUUID(); + + await sql` + INSERT INTO "Conversation" ( + "id", + "tenancyId", + "projectUserId", + "subject", + "status", + "priority", + "source", + "createdAt", + "updatedAt", + "lastMessageAt" + ) + VALUES ( + ${conversationId}::uuid, + ${ctx.tenancyId}::uuid, + ${ctx.projectUserId}::uuid, + 'Need support with onboarding', + 'open', + 'high', + 'chat', + NOW(), + NOW(), + NOW() + ) + `; + + await sql` + INSERT INTO "ConversationMetadata" ( + "conversationId", + "tenancyId", + "assignedToUserId", + "assignedToDisplayName", + "tags", + "createdAt", + "updatedAt" + ) + VALUES ( + ${conversationId}::uuid, + ${ctx.tenancyId}::uuid, + 'support-admin-1', + 'Support Admin', + ${JSON.stringify(["vip", "auth"])}::jsonb, + NOW(), + NOW() + ) + `; + + await sql` + INSERT INTO "ConversationChannel" ( + "id", + "tenancyId", + "conversationId", + "channelType", + "adapterKey", + "isEntryPoint", + "createdAt", + "updatedAt" + ) + VALUES ( + ${channelId}::uuid, + ${ctx.tenancyId}::uuid, + ${conversationId}::uuid, + 'chat', + 'support-chat', + true, + NOW(), + NOW() + ) + `; + + await sql` + INSERT INTO "ConversationMessage" ( + "id", + "tenancyId", + "conversationId", + "channelId", + "messageType", + "senderType", + "senderId", + "body", + "attachments", + "createdAt" + ) + VALUES ( + ${messageId}::uuid, + ${ctx.tenancyId}::uuid, + ${conversationId}::uuid, + ${channelId}::uuid, + 'message', + 'user', + ${ctx.projectUserId}, + 'The sign-in flow loops forever.', + '[]'::jsonb, + NOW() + ) + `; + + const insertedConversation = await sql` + SELECT "status", "priority", "source" + FROM "Conversation" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "id" = ${conversationId}::uuid + `; + expect(Array.from(insertedConversation)).toMatchInlineSnapshot(` + [ + { + "priority": "high", + "source": "chat", + "status": "open", + }, + ] + `); + + await expect(sql` + INSERT INTO "Conversation" ( + "id", + "tenancyId", + "projectUserId", + "subject", + "status", + "priority", + "source", + "createdAt", + "updatedAt", + "lastMessageAt" + ) + VALUES ( + ${randomUUID()}::uuid, + ${ctx.tenancyId}::uuid, + ${ctx.projectUserId}::uuid, + 'Broken conversation row', + 'invalid', + 'high', + 'chat', + NOW(), + NOW(), + NOW() + ) + `).rejects.toThrow(/Conversation_status_check/); + + await expect(sql` + INSERT INTO "ConversationMessage" ( + "id", + "tenancyId", + "conversationId", + "messageType", + "senderType", + "createdAt" + ) + VALUES ( + ${randomUUID()}::uuid, + ${ctx.tenancyId}::uuid, + ${conversationId}::uuid, + 'message', + 'invalid', + NOW() + ) + `).rejects.toThrow(/ConversationMessage_senderType_check/); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 1b20c77b17..684c9dfc67 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -76,6 +76,10 @@ model Tenancy { sessionReplays SessionReplay[] sessionReplayChunks SessionReplayChunk[] managedEmailDomains ManagedEmailDomain[] + conversations Conversation[] + conversationChannels ConversationChannel[] + conversationMessages ConversationMessage[] + conversationMetadata ConversationMetadata[] // Email capacity boost - when set and in the future, email capacity is multiplied by 4 emailCapacityBoostExpiresAt DateTime? @@ -185,6 +189,7 @@ model Team { teamMembers TeamMember[] projectApiKey ProjectApiKey[] + conversations Conversation[] @@id([tenancyId, teamId]) @@unique([mirroredProjectId, mirroredBranchId, teamId]) @@ -323,6 +328,7 @@ model ProjectUser { projectId String? userNotificationPreference UserNotificationPreference[] sessionReplays SessionReplay[] + conversations Conversation[] @@id([tenancyId, projectUserId]) @@unique([mirroredProjectId, mirroredBranchId, projectUserId]) @@ -1134,6 +1140,103 @@ model UserNotificationPreference { @@index([tenancyId, sequenceId], name: "UserNotificationPreference_tenancyId_sequenceId_idx") } +model Conversation { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + projectUserId String? @db.Uuid + teamId String? @db.Uuid + + subject String + status String + priority String + source String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastMessageAt DateTime @default(now()) + lastInboundAt DateTime? + lastOutboundAt DateTime? + closedAt DateTime? + + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + projectUser ProjectUser? @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade) + team Team? @relation(fields: [tenancyId, teamId], references: [tenancyId, teamId], onDelete: Cascade) + messages ConversationMessage[] + channels ConversationChannel[] + metadata ConversationMetadata? + + @@id([tenancyId, id]) + @@index([tenancyId, projectUserId, lastMessageAt(sort: Desc)], name: "Conversation_user_lastMessageAt_idx") + @@index([tenancyId, status, lastMessageAt(sort: Desc)], name: "Conversation_status_lastMessageAt_idx") + @@index([tenancyId, teamId, lastMessageAt(sort: Desc)], name: "Conversation_team_lastMessageAt_idx") +} + +model ConversationMetadata { + conversationId String @db.Uuid + tenancyId String @db.Uuid + + assignedToUserId String? + assignedToDisplayName String? + tags Json? + firstResponseDueAt DateTime? + firstResponseAt DateTime? + nextResponseDueAt DateTime? + lastCustomerReplyAt DateTime? + lastAgentReplyAt DateTime? + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + conversation Conversation @relation(fields: [tenancyId, conversationId], references: [tenancyId, id], onDelete: Cascade) + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + + @@id([tenancyId, conversationId]) +} + +model ConversationChannel { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + conversationId String @db.Uuid + + channelType String + adapterKey String + externalChannelId String? + isEntryPoint Boolean @default(false) + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + conversation Conversation @relation(fields: [tenancyId, conversationId], references: [tenancyId, id], onDelete: Cascade) + + @@id([tenancyId, id]) + @@index([tenancyId, conversationId, createdAt], name: "ConversationChannel_conversation_createdAt_idx") + @@index([tenancyId, channelType, adapterKey], name: "ConversationChannel_type_adapter_idx") +} + +model ConversationMessage { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + conversationId String @db.Uuid + channelId String? @db.Uuid + + messageType String + senderType String + senderId String? + senderDisplayName String? + senderPrimaryEmail String? + body String? + attachments Json? + metadata Json? + createdAt DateTime @default(now()) + + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + conversation Conversation @relation(fields: [tenancyId, conversationId], references: [tenancyId, id], onDelete: Cascade) + + @@id([tenancyId, id]) + @@index([tenancyId, conversationId, createdAt], name: "ConversationMessage_conversation_createdAt_idx") + @@index([tenancyId, channelId, createdAt], name: "ConversationMessage_channel_createdAt_idx") +} + model ThreadMessage { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid diff --git a/apps/backend/src/app/api/latest/conversations/[conversationId]/route.tsx b/apps/backend/src/app/api/latest/conversations/[conversationId]/route.tsx new file mode 100644 index 0000000000..ac6ec8b18a --- /dev/null +++ b/apps/backend/src/app/api/latest/conversations/[conversationId]/route.tsx @@ -0,0 +1,212 @@ +import { + appendConversationMessage, + getConversationDetail, +} from "@/lib/conversations"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { + adaptSchema, + yupArray, + yupMixed, + yupNumber, + yupObject, + yupString, +} from "@stackframe/stack-shared/dist/schema-fields"; + +const authenticatedUserAuthSchema = yupObject({ + type: yupString().oneOf(["client"]).defined(), + tenancy: adaptSchema.defined(), + user: adaptSchema.defined(), +}).defined(); + +const routeParamsSchema = yupObject({ + conversationId: yupString().uuid().defined(), +}).defined(); + +const publicConversationMetadataSchema = yupObject({ + assigned_to_user_id: yupString().nullable().defined(), + assigned_to_display_name: yupString().nullable().defined(), + tags: yupArray(yupString().defined()).defined(), + first_response_due_at: yupString().nullable().defined(), + first_response_at: yupString().nullable().defined(), + next_response_due_at: yupString().nullable().defined(), + last_customer_reply_at: yupString().nullable().defined(), + last_agent_reply_at: yupString().nullable().defined(), +}).defined(); + +const publicConversationSummarySchema = yupObject({ + conversation_id: yupString().uuid().defined(), + user_id: yupString().uuid().nullable().defined(), + team_id: yupString().uuid().nullable().defined(), + user_display_name: yupString().nullable().defined(), + user_primary_email: yupString().nullable().defined(), + user_profile_image_url: yupString().nullable().defined(), + subject: yupString().defined(), + status: yupString().defined(), + priority: yupString().defined(), + source: yupString().defined(), + last_message_type: yupString().defined(), + preview: yupString().nullable().defined(), + last_activity_at: yupString().defined(), + metadata: publicConversationMetadataSchema, +}).defined(); + +const publicConversationMessageSchema = yupObject({ + id: yupString().uuid().defined(), + conversation_id: yupString().uuid().defined(), + user_id: yupString().uuid().nullable().defined(), + team_id: yupString().uuid().nullable().defined(), + subject: yupString().defined(), + status: yupString().defined(), + priority: yupString().defined(), + source: yupString().defined(), + message_type: yupString().defined(), + body: yupString().nullable().defined(), + attachments: yupArray(yupMixed().defined()).defined(), + metadata: yupMixed().nullable().defined(), + created_at: yupString().defined(), + sender: yupObject({ + type: yupString().defined(), + id: yupString().nullable().defined(), + display_name: yupString().nullable().defined(), + primary_email: yupString().nullable().defined(), + }).defined(), +}).defined(); + +const publicConversationDetailResponseSchema = yupObject({ + conversation: publicConversationSummarySchema, + messages: yupArray(publicConversationMessageSchema).defined(), +}).defined(); + +function toPublicConversationDetail(detail: Awaited>) { + return { + conversation: { + conversation_id: detail.conversation.conversationId, + user_id: detail.conversation.userId, + team_id: detail.conversation.teamId, + user_display_name: detail.conversation.userDisplayName, + user_primary_email: detail.conversation.userPrimaryEmail, + user_profile_image_url: detail.conversation.userProfileImageUrl, + subject: detail.conversation.subject, + status: detail.conversation.status, + priority: detail.conversation.priority, + source: detail.conversation.source, + last_message_type: detail.conversation.lastMessageType, + preview: detail.conversation.preview, + last_activity_at: detail.conversation.lastActivityAt, + metadata: { + assigned_to_user_id: detail.conversation.metadata.assignedToUserId, + assigned_to_display_name: detail.conversation.metadata.assignedToDisplayName, + tags: detail.conversation.metadata.tags, + first_response_due_at: detail.conversation.metadata.firstResponseDueAt, + first_response_at: detail.conversation.metadata.firstResponseAt, + next_response_due_at: detail.conversation.metadata.nextResponseDueAt, + last_customer_reply_at: detail.conversation.metadata.lastCustomerReplyAt, + last_agent_reply_at: detail.conversation.metadata.lastAgentReplyAt, + }, + }, + messages: detail.messages.map((message) => ({ + id: message.id, + conversation_id: message.conversationId, + user_id: message.userId, + team_id: message.teamId, + subject: message.subject, + status: message.status, + priority: message.priority, + source: message.source, + message_type: message.messageType, + body: message.body, + attachments: message.attachments, + metadata: message.metadata, + created_at: message.createdAt, + sender: { + type: message.sender.type, + id: message.sender.id, + display_name: message.sender.displayName, + primary_email: message.sender.primaryEmail, + }, + })), + }; +} + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Get a conversation for the current user", + description: "Get conversation detail visible to the currently authenticated user", + tags: ["Conversations"], + }, + request: yupObject({ + auth: authenticatedUserAuthSchema, + params: routeParamsSchema, + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: publicConversationDetailResponseSchema, + }), + handler: async ({ auth, params }) => { + const detail = await getConversationDetail({ + tenancyId: auth.tenancy.id, + conversationId: params.conversationId, + viewerProjectUserId: auth.user.id, + includeInternalNotes: false, + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: toPublicConversationDetail(detail), + }; + }, +}); + +export const PATCH = createSmartRouteHandler({ + metadata: { + summary: "Reply to a conversation", + description: "Append a user message to an existing conversation", + tags: ["Conversations"], + }, + request: yupObject({ + auth: authenticatedUserAuthSchema, + params: routeParamsSchema, + body: yupObject({ + message: yupString().trim().min(1).defined(), + }).defined(), + method: yupString().oneOf(["PATCH"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: publicConversationDetailResponseSchema, + }), + handler: async ({ auth, params, body }) => { + await appendConversationMessage({ + tenancyId: auth.tenancy.id, + conversationId: params.conversationId, + messageType: "message", + body: body.message, + viewerProjectUserId: auth.user.id, + channelType: "chat", + adapterKey: "support-chat", + sender: { + type: "user", + id: auth.user.id, + displayName: auth.user.display_name ?? null, + primaryEmail: auth.user.primary_email ?? null, + }, + }); + + const detail = await getConversationDetail({ + tenancyId: auth.tenancy.id, + conversationId: params.conversationId, + viewerProjectUserId: auth.user.id, + includeInternalNotes: false, + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: toPublicConversationDetail(detail), + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/conversations/route.tsx b/apps/backend/src/app/api/latest/conversations/route.tsx new file mode 100644 index 0000000000..e2a6f65a6f --- /dev/null +++ b/apps/backend/src/app/api/latest/conversations/route.tsx @@ -0,0 +1,159 @@ +import { + createConversation, + listConversationSummaries, +} from "@/lib/conversations"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { + adaptSchema, + yupArray, + yupNumber, + yupObject, + yupString, +} from "@stackframe/stack-shared/dist/schema-fields"; + +const authenticatedUserAuthSchema = yupObject({ + type: yupString().oneOf(["client"]).defined(), + tenancy: adaptSchema.defined(), + user: adaptSchema.defined(), +}).defined(); + +const publicConversationMetadataSchema = yupObject({ + assigned_to_user_id: yupString().nullable().defined(), + assigned_to_display_name: yupString().nullable().defined(), + tags: yupArray(yupString().defined()).defined(), + first_response_due_at: yupString().nullable().defined(), + first_response_at: yupString().nullable().defined(), + next_response_due_at: yupString().nullable().defined(), + last_customer_reply_at: yupString().nullable().defined(), + last_agent_reply_at: yupString().nullable().defined(), +}).defined(); + +const publicConversationSummarySchema = yupObject({ + conversation_id: yupString().uuid().defined(), + user_id: yupString().uuid().nullable().defined(), + team_id: yupString().uuid().nullable().defined(), + user_display_name: yupString().nullable().defined(), + user_primary_email: yupString().nullable().defined(), + user_profile_image_url: yupString().nullable().defined(), + subject: yupString().defined(), + status: yupString().defined(), + priority: yupString().defined(), + source: yupString().defined(), + last_message_type: yupString().defined(), + preview: yupString().nullable().defined(), + last_activity_at: yupString().defined(), + metadata: publicConversationMetadataSchema, +}).defined(); + +const publicConversationListResponseSchema = yupObject({ + conversations: yupArray(publicConversationSummarySchema).defined(), +}).defined(); + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "List conversations for the current user", + description: "List conversations visible to the currently authenticated user", + tags: ["Conversations"], + }, + request: yupObject({ + auth: authenticatedUserAuthSchema, + query: yupObject({ + query: yupString().optional(), + }).defined(), + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: publicConversationListResponseSchema, + }), + handler: async ({ auth, query }) => { + const conversations = await listConversationSummaries({ + tenancyId: auth.tenancy.id, + userId: auth.user.id, + query: query.query, + includeInternalNotes: false, + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { + conversations: conversations.map((conversation) => ({ + conversation_id: conversation.conversationId, + user_id: conversation.userId, + team_id: conversation.teamId, + user_display_name: conversation.userDisplayName, + user_primary_email: conversation.userPrimaryEmail, + user_profile_image_url: conversation.userProfileImageUrl, + subject: conversation.subject, + status: conversation.status, + priority: conversation.priority, + source: conversation.source, + last_message_type: conversation.lastMessageType, + preview: conversation.preview, + last_activity_at: conversation.lastActivityAt, + metadata: { + assigned_to_user_id: conversation.metadata.assignedToUserId, + assigned_to_display_name: conversation.metadata.assignedToDisplayName, + tags: conversation.metadata.tags, + first_response_due_at: conversation.metadata.firstResponseDueAt, + first_response_at: conversation.metadata.firstResponseAt, + next_response_due_at: conversation.metadata.nextResponseDueAt, + last_customer_reply_at: conversation.metadata.lastCustomerReplyAt, + last_agent_reply_at: conversation.metadata.lastAgentReplyAt, + }, + })), + }, + }; + }, +}); + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Create a conversation", + description: "Create a new conversation as the current user", + tags: ["Conversations"], + }, + request: yupObject({ + auth: authenticatedUserAuthSchema, + body: yupObject({ + subject: yupString().trim().min(1).defined(), + message: yupString().trim().min(1).defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + conversation_id: yupString().uuid().defined(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const result = await createConversation({ + tenancyId: auth.tenancy.id, + userId: auth.user.id, + subject: body.subject, + priority: "normal", + source: "chat", + channelType: "chat", + adapterKey: "support-chat", + body: body.message, + sender: { + type: "user", + id: auth.user.id, + displayName: auth.user.display_name ?? null, + primaryEmail: auth.user.primary_email ?? null, + }, + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { + conversation_id: result.conversationId, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/conversations/[conversationId]/route.tsx b/apps/backend/src/app/api/latest/internal/conversations/[conversationId]/route.tsx new file mode 100644 index 0000000000..a07c154ed4 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/conversations/[conversationId]/route.tsx @@ -0,0 +1,176 @@ +import { + appendConversationMessage, + getConversationDetail, + getManagedProjectTenancy, + updateConversationMetadata, + updateConversationStatus, +} from "@/lib/conversations"; +import { + conversationDetailResponseSchema, + conversationPriorityValues, + conversationStatusValues, +} from "@/lib/conversation-types"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { + adaptSchema, + projectIdSchema, + yupArray, + yupNumber, + yupObject, + yupString, + yupUnion, +} from "@stackframe/stack-shared/dist/schema-fields"; + +const internalDashboardAuthSchema = yupObject({ + type: adaptSchema, + user: adaptSchema.defined(), + project: yupObject({ + id: yupString().oneOf(["internal"]).defined(), + }).defined(), +}).defined(); + +const routeParamsSchema = yupObject({ + conversationId: yupString().uuid().defined(), +}).defined(); + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Get conversation detail", + description: "Get conversation detail for a managed project", + }, + request: yupObject({ + auth: internalDashboardAuthSchema, + params: routeParamsSchema, + query: yupObject({ + projectId: projectIdSchema.defined(), + }).defined(), + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: conversationDetailResponseSchema.defined(), + }), + handler: async ({ auth, params, query }) => { + const tenancy = await getManagedProjectTenancy(query.projectId, auth.user); + const detail = await getConversationDetail({ + tenancyId: tenancy.id, + conversationId: params.conversationId, + includeInternalNotes: true, + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: detail, + }; + }, +}); + +export const PATCH = createSmartRouteHandler({ + metadata: { + summary: "Update conversation", + description: "Append a message or update metadata on a managed project conversation", + }, + request: yupObject({ + auth: internalDashboardAuthSchema, + params: routeParamsSchema, + body: yupUnion( + yupObject({ + projectId: projectIdSchema.defined(), + type: yupString().oneOf(["internal-note"]).defined(), + body: yupString().trim().min(1).defined(), + }).defined(), + yupObject({ + projectId: projectIdSchema.defined(), + type: yupString().oneOf(["reply"]).defined(), + body: yupString().trim().min(1).defined(), + }).defined(), + yupObject({ + projectId: projectIdSchema.defined(), + type: yupString().oneOf(["status"]).defined(), + status: yupString().oneOf(conversationStatusValues).defined(), + }).defined(), + yupObject({ + projectId: projectIdSchema.defined(), + type: yupString().oneOf(["metadata"]).defined(), + assignedToUserId: yupString().nullable().optional(), + assignedToDisplayName: yupString().nullable().optional(), + priority: yupString().oneOf(conversationPriorityValues).optional(), + tags: yupArray(yupString().defined()).optional(), + }).defined(), + ).defined(), + method: yupString().oneOf(["PATCH"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: conversationDetailResponseSchema.defined(), + }), + handler: async ({ auth, params, body }) => { + const tenancy = await getManagedProjectTenancy(body.projectId, auth.user); + + if (body.type === "reply") { + await appendConversationMessage({ + tenancyId: tenancy.id, + conversationId: params.conversationId, + messageType: "message", + body: body.body, + channelType: "chat", + adapterKey: "support-chat", + sender: { + type: "agent", + id: auth.user.id, + displayName: auth.user.display_name ?? null, + primaryEmail: auth.user.primary_email ?? null, + }, + }); + } else if (body.type === "internal-note") { + await appendConversationMessage({ + tenancyId: tenancy.id, + conversationId: params.conversationId, + messageType: "internal-note", + body: body.body, + sender: { + type: "agent", + id: auth.user.id, + displayName: auth.user.display_name ?? null, + primaryEmail: auth.user.primary_email ?? null, + }, + }); + } else if (body.type === "status") { + await updateConversationStatus({ + tenancyId: tenancy.id, + conversationId: params.conversationId, + status: body.status, + sender: { + type: "agent", + id: auth.user.id, + displayName: auth.user.display_name ?? null, + primaryEmail: auth.user.primary_email ?? null, + }, + }); + } else { + await updateConversationMetadata({ + tenancyId: tenancy.id, + conversationId: params.conversationId, + assignedToUserId: body.assignedToUserId, + assignedToDisplayName: body.assignedToDisplayName, + priority: body.priority, + tags: body.tags, + }); + } + + const detail = await getConversationDetail({ + tenancyId: tenancy.id, + conversationId: params.conversationId, + includeInternalNotes: true, + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: detail, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/conversations/route.tsx b/apps/backend/src/app/api/latest/internal/conversations/route.tsx new file mode 100644 index 0000000000..4c6803c467 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/conversations/route.tsx @@ -0,0 +1,127 @@ +import { + createConversation, + getManagedProjectTenancy, + listConversationSummaries, +} from "@/lib/conversations"; +import { + conversationListResponseSchema, + conversationPriorityValues, + conversationSourceValues, + conversationStatusValues, +} from "@/lib/conversation-types"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, projectIdSchema, userIdSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { globalPrismaClient } from "@/prisma-client"; + +const internalDashboardAuthSchema = yupObject({ + type: adaptSchema, + user: adaptSchema.defined(), + project: yupObject({ + id: yupString().oneOf(["internal"]).defined(), + }).defined(), +}).defined(); + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "List conversations", + description: "List conversations for a managed project", + }, + request: yupObject({ + auth: internalDashboardAuthSchema, + query: yupObject({ + projectId: projectIdSchema.defined(), + query: yupString().optional(), + status: yupString().oneOf(conversationStatusValues).optional(), + userId: userIdSchema.optional(), + }).defined(), + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: conversationListResponseSchema.defined(), + }), + handler: async ({ auth, query }) => { + const tenancy = await getManagedProjectTenancy(query.projectId, auth.user); + const conversations = await listConversationSummaries({ + tenancyId: tenancy.id, + query: query.query, + status: query.status, + userId: query.userId, + includeInternalNotes: true, + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { conversations }, + }; + }, +}); + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Create conversation", + description: "Create a managed project conversation for a user", + }, + request: yupObject({ + auth: internalDashboardAuthSchema, + body: yupObject({ + projectId: projectIdSchema.defined(), + userId: userIdSchema.defined(), + subject: yupString().trim().min(1).defined(), + initialMessage: yupString().trim().min(1).defined(), + priority: yupString().oneOf(conversationPriorityValues).defined(), + source: yupString().oneOf(conversationSourceValues).optional(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + conversationId: yupString().uuid().defined(), + }).defined(), + }), + handler: async ({ auth, body }) => { + const tenancy = await getManagedProjectTenancy(body.projectId, auth.user); + const existingUser = await globalPrismaClient.projectUser.findFirst({ + where: { + tenancyId: tenancy.id, + projectUserId: body.userId, + }, + select: { + projectUserId: true, + }, + }); + if (existingUser == null) { + throw new KnownErrors.UserIdDoesNotExist(body.userId); + } + + const result = await createConversation({ + tenancyId: tenancy.id, + userId: body.userId, + subject: body.subject, + priority: body.priority, + source: body.source ?? "manual", + channelType: body.source ?? "manual", + adapterKey: body.source === "chat" ? "support-chat" : "support-dashboard", + body: body.initialMessage, + sender: { + type: "agent", + id: auth.user.id, + displayName: auth.user.display_name ?? null, + primaryEmail: auth.user.primary_email ?? null, + }, + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { + conversationId: result.conversationId, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/conversation-types.ts b/apps/backend/src/lib/conversation-types.ts new file mode 100644 index 0000000000..53ee5a7499 --- /dev/null +++ b/apps/backend/src/lib/conversation-types.ts @@ -0,0 +1,85 @@ +import * as yup from "yup"; +import { yupArray, yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const conversationStatusValues = ["open", "pending", "closed"] as const; +export type ConversationStatus = (typeof conversationStatusValues)[number]; + +export const conversationPriorityValues = ["low", "normal", "high", "urgent"] as const; +export type ConversationPriority = (typeof conversationPriorityValues)[number]; + +export const conversationSourceValues = ["manual", "chat", "email", "api"] as const; +export type ConversationSource = (typeof conversationSourceValues)[number]; + +export const conversationSenderTypeValues = ["user", "agent", "system"] as const; +export type ConversationSenderType = (typeof conversationSenderTypeValues)[number]; + +export const conversationMessageTypeValues = ["message", "internal-note", "status-change"] as const; +export type ConversationMessageType = (typeof conversationMessageTypeValues)[number]; + +export const conversationMetadataSchema = yupObject({ + assignedToUserId: yupString().nullable().defined(), + assignedToDisplayName: yupString().nullable().defined(), + tags: yupArray(yupString().defined()).defined(), + firstResponseDueAt: yupString().nullable().defined(), + firstResponseAt: yupString().nullable().defined(), + nextResponseDueAt: yupString().nullable().defined(), + lastCustomerReplyAt: yupString().nullable().defined(), + lastAgentReplyAt: yupString().nullable().defined(), +}); + +export const conversationSenderSchema = yupObject({ + type: yupString().oneOf(conversationSenderTypeValues).defined(), + id: yupString().nullable().defined(), + displayName: yupString().nullable().defined(), + primaryEmail: yupString().nullable().defined(), +}); + +export const conversationSummarySchema = yupObject({ + conversationId: yupString().uuid().defined(), + userId: yupString().uuid().nullable().defined(), + teamId: yupString().uuid().nullable().defined(), + userDisplayName: yupString().nullable().defined(), + userPrimaryEmail: yupString().nullable().defined(), + userProfileImageUrl: yupString().nullable().defined(), + subject: yupString().defined(), + status: yupString().oneOf(conversationStatusValues).defined(), + priority: yupString().oneOf(conversationPriorityValues).defined(), + source: yupString().oneOf(conversationSourceValues).defined(), + lastMessageType: yupString().oneOf(conversationMessageTypeValues).defined(), + preview: yupString().nullable().defined(), + lastActivityAt: yupString().defined(), + metadata: conversationMetadataSchema.defined(), +}); + +export const conversationMessageSchema = yupObject({ + id: yupString().uuid().defined(), + conversationId: yupString().uuid().defined(), + userId: yupString().uuid().nullable().defined(), + teamId: yupString().uuid().nullable().defined(), + subject: yupString().defined(), + status: yupString().oneOf(conversationStatusValues).defined(), + priority: yupString().oneOf(conversationPriorityValues).defined(), + source: yupString().oneOf(conversationSourceValues).defined(), + messageType: yupString().oneOf(conversationMessageTypeValues).defined(), + body: yupString().nullable().defined(), + attachments: yupArray(yupMixed().defined()).defined(), + metadata: yupMixed().nullable().defined(), + createdAt: yupString().defined(), + sender: conversationSenderSchema.defined(), +}); + +export const conversationListResponseSchema = yupObject({ + conversations: yupArray(conversationSummarySchema.defined()).defined(), +}); + +export const conversationDetailResponseSchema = yupObject({ + conversation: conversationSummarySchema.defined(), + messages: yupArray(conversationMessageSchema.defined()).defined(), +}); + +export type ConversationMetadata = yup.InferType; +export type ConversationSender = yup.InferType; +export type ConversationSummary = yup.InferType; +export type ConversationMessage = yup.InferType; +export type ConversationListResponse = yup.InferType; +export type ConversationDetailResponse = yup.InferType; diff --git a/apps/backend/src/lib/conversations.tsx b/apps/backend/src/lib/conversations.tsx new file mode 100644 index 0000000000..f4c139e7a9 --- /dev/null +++ b/apps/backend/src/lib/conversations.tsx @@ -0,0 +1,946 @@ +import { Prisma } from "@/generated/prisma/client"; +import { listManagedProjectIds } from "@/lib/projects"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { globalPrismaClient, retryTransaction, type PrismaClientTransaction } from "@/prisma-client"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { + conversationMessageTypeValues, + conversationPriorityValues, + conversationSenderSchema, + conversationSenderTypeValues, + conversationSourceValues, + conversationStatusValues, + type ConversationDetailResponse, + type ConversationMessage, + type ConversationMessageType, + type ConversationMetadata, + type ConversationPriority, + type ConversationSender, + type ConversationSource, + type ConversationStatus, + type ConversationSummary, +} from "@/lib/conversation-types"; +import { yupArray, yupMixed, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import * as yup from "yup"; + +const tagsSchema = yupArray(yupString().defined()).defined(); +const attachmentsSchema = yupArray(yupMixed().defined()).defined(); + +type DbConversationRow = { + conversationId: string, + userId: string | null, + teamId: string | null, + subject: string, + status: string, + priority: string, + source: string, + createdAt: Date, + userDisplayName: string | null, + userPrimaryEmail: string | null, + userProfileImageUrl: string | null, + assignedToUserId: string | null, + assignedToDisplayName: string | null, + tags: Prisma.JsonValue | null, + firstResponseDueAt: Date | null, + firstResponseAt: Date | null, + nextResponseDueAt: Date | null, + lastCustomerReplyAt: Date | null, + lastAgentReplyAt: Date | null, +}; + +type ConversationSummaryRow = DbConversationRow & { + latestMessageType: string | null, + latestBody: string | null, + lastVisibleActivityAt: Date | null, +}; + +type ConversationMessageRow = { + id: string, + messageType: string, + senderType: string, + senderId: string | null, + senderDisplayName: string | null, + senderPrimaryEmail: string | null, + body: string | null, + attachments: Prisma.JsonValue | null, + metadata: Prisma.JsonValue | null, + createdAt: Date, +}; + +type ConversationStateRow = { + conversationId: string, + userId: string | null, + teamId: string | null, + subject: string, + status: string, + priority: string, + source: string, + firstResponseAt: Date | null, + lastCustomerReplyAt: Date | null, + lastAgentReplyAt: Date | null, +}; + +function parseEnumValue( + values: T, + value: string, + errorContext: string, +): T[number] { + if (values.includes(value)) { + return value; + } + throw new Error(`Unexpected ${errorContext}: ${value}`); +} + +function parseSender(sender: ConversationSender) { + return conversationSenderSchema.validateSync(sender); +} + +function parseTags(value: Prisma.JsonValue | null): string[] { + if (value == null) { + return []; + } + return tagsSchema.validateSync(value); +} + +function parseAttachments(value: Prisma.JsonValue | null): ConversationMessage["attachments"] { + if (value == null) { + return []; + } + return attachmentsSchema.validateSync(value); +} + +function toIsoString(value: Date | null): string | null { + return value?.toISOString() ?? null; +} + +function metadataFromRow(row: DbConversationRow): ConversationMetadata { + return { + assignedToUserId: row.assignedToUserId, + assignedToDisplayName: row.assignedToDisplayName, + tags: parseTags(row.tags), + firstResponseDueAt: toIsoString(row.firstResponseDueAt), + firstResponseAt: toIsoString(row.firstResponseAt), + nextResponseDueAt: toIsoString(row.nextResponseDueAt), + lastCustomerReplyAt: toIsoString(row.lastCustomerReplyAt), + lastAgentReplyAt: toIsoString(row.lastAgentReplyAt), + }; +} + +function previewForSummary(row: Pick): string | null { + if (row.latestBody != null && row.latestBody.trim() !== "") { + return row.latestBody.trim(); + } + + const messageType = row.latestMessageType == null + ? "message" + : parseEnumValue(conversationMessageTypeValues, row.latestMessageType, "conversation message type"); + const status = parseEnumValue(conversationStatusValues, row.status, "conversation status"); + + if (messageType === "status-change") { + if (status === "closed") return "Conversation closed"; + if (status === "open") return "Conversation reopened"; + return "Conversation moved to pending"; + } + if (messageType === "internal-note") { + return "Internal note"; + } + return null; +} + +function summaryFromRow(row: ConversationSummaryRow): ConversationSummary { + return { + conversationId: row.conversationId, + userId: row.userId, + teamId: row.teamId, + userDisplayName: row.userDisplayName, + userPrimaryEmail: row.userPrimaryEmail, + userProfileImageUrl: row.userProfileImageUrl, + subject: row.subject, + status: parseEnumValue(conversationStatusValues, row.status, "conversation status"), + priority: parseEnumValue(conversationPriorityValues, row.priority, "conversation priority"), + source: parseEnumValue(conversationSourceValues, row.source, "conversation source"), + lastMessageType: parseEnumValue( + conversationMessageTypeValues, + row.latestMessageType ?? "message", + "conversation message type", + ), + preview: previewForSummary(row), + lastActivityAt: (row.lastVisibleActivityAt ?? row.createdAt).toISOString(), + metadata: metadataFromRow(row), + }; +} + +function messageFromRow(row: ConversationMessageRow, conversation: DbConversationRow): ConversationMessage { + return { + id: row.id, + conversationId: conversation.conversationId, + userId: conversation.userId, + teamId: conversation.teamId, + subject: conversation.subject, + status: parseEnumValue(conversationStatusValues, conversation.status, "conversation status"), + priority: parseEnumValue(conversationPriorityValues, conversation.priority, "conversation priority"), + source: parseEnumValue(conversationSourceValues, conversation.source, "conversation source"), + messageType: parseEnumValue(conversationMessageTypeValues, row.messageType, "conversation message type"), + body: row.body, + attachments: parseAttachments(row.attachments), + metadata: row.metadata, + createdAt: row.createdAt.toISOString(), + sender: { + type: parseEnumValue(conversationSenderTypeValues, row.senderType, "conversation sender type"), + id: row.senderId, + displayName: row.senderDisplayName, + primaryEmail: row.senderPrimaryEmail, + }, + }; +} + +function jsonbParam(value: unknown) { + return Prisma.sql`CAST(${JSON.stringify(value)} AS jsonb)`; +} + +async function getConversationRow(options: { + tenancyId: string, + conversationId: string, + viewerProjectUserId?: string, +}) { + const rows = await globalPrismaClient.$queryRaw(Prisma.sql` + SELECT + c.id AS "conversationId", + c."projectUserId" AS "userId", + c."teamId" AS "teamId", + c.subject, + c.status, + c.priority, + c.source, + c."createdAt", + pu."displayName" AS "userDisplayName", + pu."profileImageUrl" AS "userProfileImageUrl", + cc."value" AS "userPrimaryEmail", + cm."assignedToUserId", + cm."assignedToDisplayName", + cm.tags, + cm."firstResponseDueAt", + cm."firstResponseAt", + cm."nextResponseDueAt", + cm."lastCustomerReplyAt", + cm."lastAgentReplyAt" + FROM "Conversation" c + LEFT JOIN "ProjectUser" pu + ON pu."tenancyId" = c."tenancyId" + AND pu."projectUserId" = c."projectUserId" + LEFT JOIN "ContactChannel" cc + ON cc."tenancyId" = c."tenancyId" + AND cc."projectUserId" = c."projectUserId" + AND cc."type" = 'EMAIL' + AND cc."isPrimary" = 'TRUE' + LEFT JOIN "ConversationMetadata" cm + ON cm."tenancyId" = c."tenancyId" + AND cm."conversationId" = c.id + WHERE c."tenancyId" = ${options.tenancyId}::uuid + AND c.id = ${options.conversationId}::uuid + ${options.viewerProjectUserId != null ? Prisma.sql`AND c."projectUserId" = ${options.viewerProjectUserId}::uuid` : Prisma.empty} + LIMIT 1 + `); + + return rows.at(0) ?? null; +} + +async function getConversationState(options: { + tenancyId: string, + conversationId: string, + viewerProjectUserId?: string, +}) { + const rows = await globalPrismaClient.$queryRaw(Prisma.sql` + SELECT + c.id AS "conversationId", + c."projectUserId" AS "userId", + c."teamId" AS "teamId", + c.subject, + c.status, + c.priority, + c.source, + cm."firstResponseAt", + cm."lastCustomerReplyAt", + cm."lastAgentReplyAt" + FROM "Conversation" c + LEFT JOIN "ConversationMetadata" cm + ON cm."tenancyId" = c."tenancyId" + AND cm."conversationId" = c.id + WHERE c."tenancyId" = ${options.tenancyId}::uuid + AND c.id = ${options.conversationId}::uuid + ${options.viewerProjectUserId != null ? Prisma.sql`AND c."projectUserId" = ${options.viewerProjectUserId}::uuid` : Prisma.empty} + LIMIT 1 + `); + + const row = rows.at(0); + if (row == null) { + throw new StatusError(404, "Conversation not found."); + } + + return { + conversationId: row.conversationId, + userId: row.userId, + teamId: row.teamId, + subject: row.subject, + status: parseEnumValue(conversationStatusValues, row.status, "conversation status"), + priority: parseEnumValue(conversationPriorityValues, row.priority, "conversation priority"), + source: parseEnumValue(conversationSourceValues, row.source, "conversation source"), + firstResponseAt: row.firstResponseAt, + lastCustomerReplyAt: row.lastCustomerReplyAt, + lastAgentReplyAt: row.lastAgentReplyAt, + }; +} + +async function ensureConversationChannel(options: { + tx: PrismaClientTransaction, + tenancyId: string, + conversationId: string, + channelType: ConversationSource, + adapterKey: string, + isEntryPoint: boolean, +}) { + const existingRows = await options.tx.$queryRaw<{ id: string }[]>(Prisma.sql` + SELECT id + FROM "ConversationChannel" + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND "conversationId" = ${options.conversationId}::uuid + AND "channelType" = ${options.channelType} + AND "adapterKey" = ${options.adapterKey} + AND "externalChannelId" IS NULL + ORDER BY "createdAt" ASC + LIMIT 1 + `); + + const existingRow = existingRows.at(0); + if (existingRow != null) { + return existingRow.id; + } + + const channelId = generateUuid(); + await options.tx.$executeRaw(Prisma.sql` + INSERT INTO "ConversationChannel" ( + id, + "tenancyId", + "conversationId", + "channelType", + "adapterKey", + "externalChannelId", + "isEntryPoint", + metadata, + "createdAt", + "updatedAt" + ) + VALUES ( + ${channelId}::uuid, + ${options.tenancyId}::uuid, + ${options.conversationId}::uuid, + ${options.channelType}, + ${options.adapterKey}, + NULL, + ${options.isEntryPoint}, + NULL, + NOW(), + NOW() + ) + `); + return channelId; +} + +export async function getManagedProjectTenancy(projectId: string, user: UsersCrud["Admin"]["Read"]) { + const managedProjectIds = await listManagedProjectIds(user); + if (!managedProjectIds.includes(projectId)) { + throw new KnownErrors.ProjectNotFound(projectId); + } + return await getSoleTenancyFromProjectBranch(projectId, DEFAULT_BRANCH_ID); +} + +export async function listConversationSummaries(options: { + tenancyId: string, + status?: ConversationStatus, + query?: string, + userId?: string, + includeInternalNotes: boolean, +}) { + const searchPattern = options.query == null || options.query.trim() === "" + ? null + : `%${options.query.trim().toLowerCase()}%`; + + const rows = await globalPrismaClient.$queryRaw(Prisma.sql` + SELECT + c.id AS "conversationId", + c."projectUserId" AS "userId", + c."teamId" AS "teamId", + c.subject, + c.status, + c.priority, + c.source, + c."createdAt", + pu."displayName" AS "userDisplayName", + pu."profileImageUrl" AS "userProfileImageUrl", + cc."value" AS "userPrimaryEmail", + md."assignedToUserId", + md."assignedToDisplayName", + md.tags, + md."firstResponseDueAt", + md."firstResponseAt", + md."nextResponseDueAt", + md."lastCustomerReplyAt", + md."lastAgentReplyAt", + lm."messageType" AS "latestMessageType", + lm.body AS "latestBody", + lm."createdAt" AS "lastVisibleActivityAt" + FROM "Conversation" c + LEFT JOIN "ProjectUser" pu + ON pu."tenancyId" = c."tenancyId" + AND pu."projectUserId" = c."projectUserId" + LEFT JOIN "ContactChannel" cc + ON cc."tenancyId" = c."tenancyId" + AND cc."projectUserId" = c."projectUserId" + AND cc."type" = 'EMAIL' + AND cc."isPrimary" = 'TRUE' + LEFT JOIN "ConversationMetadata" md + ON md."tenancyId" = c."tenancyId" + AND md."conversationId" = c.id + LEFT JOIN LATERAL ( + SELECT + cm."messageType", + cm.body, + cm."createdAt" + FROM "ConversationMessage" cm + WHERE cm."tenancyId" = c."tenancyId" + AND cm."conversationId" = c.id + ${options.includeInternalNotes ? Prisma.empty : Prisma.sql`AND cm."messageType" != 'internal-note'`} + ORDER BY cm."createdAt" DESC, cm.id DESC + LIMIT 1 + ) lm ON TRUE + WHERE c."tenancyId" = ${options.tenancyId}::uuid + ${options.userId != null ? Prisma.sql`AND c."projectUserId" = ${options.userId}::uuid` : Prisma.empty} + ${options.status != null ? Prisma.sql`AND c.status = ${options.status}` : Prisma.empty} + ${searchPattern != null ? Prisma.sql` + AND ( + LOWER(c.subject) LIKE ${searchPattern} + OR LOWER(COALESCE(lm.body, '')) LIKE ${searchPattern} + OR LOWER(COALESCE(pu."displayName", '')) LIKE ${searchPattern} + OR LOWER(COALESCE(cc."value", '')) LIKE ${searchPattern} + ) + ` : Prisma.empty} + ORDER BY COALESCE(lm."createdAt", c."createdAt") DESC, c.id DESC + LIMIT 200 + `); + + return rows.map(summaryFromRow); +} + +export async function getConversationDetail(options: { + tenancyId: string, + conversationId: string, + includeInternalNotes: boolean, + viewerProjectUserId?: string, +}): Promise { + const conversation = await getConversationRow(options); + if (conversation == null) { + throw new StatusError(404, "Conversation not found."); + } + + const messageRows = await globalPrismaClient.$queryRaw(Prisma.sql` + SELECT + cm.id, + cm."messageType", + cm."senderType", + cm."senderId", + cm."senderDisplayName", + cm."senderPrimaryEmail", + cm.body, + cm.attachments, + cm.metadata, + cm."createdAt" + FROM "ConversationMessage" cm + WHERE cm."tenancyId" = ${options.tenancyId}::uuid + AND cm."conversationId" = ${options.conversationId}::uuid + ${options.includeInternalNotes ? Prisma.empty : Prisma.sql`AND cm."messageType" != 'internal-note'`} + ORDER BY cm."createdAt" ASC, cm.id ASC + `); + + if (messageRows.length === 0) { + throw new StatusError(404, "Conversation not found."); + } + + const messages = messageRows.map((row) => messageFromRow(row, conversation)); + const latestMessage = messages.at(-1) ?? throwErr("Conversations must contain at least one message"); + + return { + conversation: { + ...summaryFromRow({ + ...conversation, + latestMessageType: latestMessage.messageType, + latestBody: latestMessage.body, + lastVisibleActivityAt: new Date(latestMessage.createdAt), + }), + status: parseEnumValue(conversationStatusValues, conversation.status, "conversation status"), + priority: parseEnumValue(conversationPriorityValues, conversation.priority, "conversation priority"), + source: parseEnumValue(conversationSourceValues, conversation.source, "conversation source"), + metadata: metadataFromRow(conversation), + }, + messages, + }; +} + +export async function createConversation(options: { + tenancyId: string, + userId: string | null, + teamId?: string | null, + subject: string, + priority: ConversationPriority, + source: ConversationSource, + channelType: ConversationSource, + adapterKey: string, + body: string, + sender: ConversationSender, + attachments?: unknown[], +}) { + const sender = parseSender(options.sender); + const now = new Date(); + const conversationId = generateUuid(); + const messageId = generateUuid(); + const channelId = generateUuid(); + + const isUserMessage = sender.type === "user"; + const isAgentMessage = sender.type === "agent"; + + await retryTransaction(globalPrismaClient, async (tx) => { + await tx.$executeRaw(Prisma.sql` + INSERT INTO "Conversation" ( + id, + "tenancyId", + "projectUserId", + "teamId", + subject, + status, + priority, + source, + "createdAt", + "updatedAt", + "lastMessageAt", + "lastInboundAt", + "lastOutboundAt", + "closedAt" + ) + VALUES ( + ${conversationId}::uuid, + ${options.tenancyId}::uuid, + ${options.userId}::uuid, + ${options.teamId ?? null}::uuid, + ${options.subject}, + 'open', + ${options.priority}, + ${options.source}, + ${now}, + ${now}, + ${now}, + ${isUserMessage ? Prisma.sql`${now}` : Prisma.sql`NULL`}, + ${isAgentMessage ? Prisma.sql`${now}` : Prisma.sql`NULL`}, + NULL + ) + `); + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "ConversationMetadata" ( + "conversationId", + "tenancyId", + "assignedToUserId", + "assignedToDisplayName", + tags, + "firstResponseDueAt", + "firstResponseAt", + "nextResponseDueAt", + "lastCustomerReplyAt", + "lastAgentReplyAt", + metadata, + "createdAt", + "updatedAt" + ) + VALUES ( + ${conversationId}::uuid, + ${options.tenancyId}::uuid, + NULL, + NULL, + ${jsonbParam([])}, + NULL, + NULL, + NULL, + ${isUserMessage ? Prisma.sql`${now}` : Prisma.sql`NULL`}, + ${isAgentMessage ? Prisma.sql`${now}` : Prisma.sql`NULL`}, + NULL, + ${now}, + ${now} + ) + `); + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "ConversationChannel" ( + id, + "tenancyId", + "conversationId", + "channelType", + "adapterKey", + "externalChannelId", + "isEntryPoint", + metadata, + "createdAt", + "updatedAt" + ) + VALUES ( + ${channelId}::uuid, + ${options.tenancyId}::uuid, + ${conversationId}::uuid, + ${options.channelType}, + ${options.adapterKey}, + NULL, + TRUE, + NULL, + ${now}, + ${now} + ) + `); + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "ConversationMessage" ( + id, + "tenancyId", + "conversationId", + "channelId", + "messageType", + "senderType", + "senderId", + "senderDisplayName", + "senderPrimaryEmail", + body, + attachments, + metadata, + "createdAt" + ) + VALUES ( + ${messageId}::uuid, + ${options.tenancyId}::uuid, + ${conversationId}::uuid, + ${channelId}::uuid, + 'message', + ${sender.type}, + ${sender.id}, + ${sender.displayName}, + ${sender.primaryEmail}, + ${options.body}, + ${jsonbParam(options.attachments ?? [])}, + NULL, + ${now} + ) + `); + }); + + return { + conversationId, + }; +} + +/** + * User-visible messages (`message` type) bump workflow status so the inbox matches who should act next: + * agent reply on `open` → `pending` (waiting on user); user reply on `pending` → `open` (needs support). + * Internal notes and explicit status changes are handled elsewhere; `closed` is left unchanged here. + */ +export function nextConversationStatusAfterAppend(options: { + messageType: Extract, + senderType: ConversationSender["type"], + currentStatus: ConversationStatus, +}): ConversationStatus | null { + if (options.messageType !== "message") { + return null; + } + if (options.senderType === "agent" && options.currentStatus === "open") { + return "pending"; + } + if (options.senderType === "user" && options.currentStatus === "pending") { + return "open"; + } + return null; +} + +export async function appendConversationMessage(options: { + tenancyId: string, + conversationId: string, + messageType: Extract, + body: string, + sender: ConversationSender, + viewerProjectUserId?: string, + channelType?: ConversationSource, + adapterKey?: string, + attachments?: unknown[], + metadata?: unknown | null, +}) { + const sender = parseSender(options.sender); + const conversation = await getConversationState({ + tenancyId: options.tenancyId, + conversationId: options.conversationId, + viewerProjectUserId: options.viewerProjectUserId, + }); + + const now = new Date(); + const messageId = generateUuid(); + const shouldTrackReplies = options.messageType === "message"; + const nextFirstResponseAt = ( + shouldTrackReplies + && sender.type === "agent" + && conversation.firstResponseAt == null + && conversation.lastCustomerReplyAt != null + ) ? now : conversation.firstResponseAt; + + const autoStatus = nextConversationStatusAfterAppend({ + messageType: options.messageType, + senderType: sender.type, + currentStatus: conversation.status, + }); + + await retryTransaction(globalPrismaClient, async (tx) => { + const channelId = ( + options.messageType === "message" + && options.channelType != null + && options.adapterKey != null + ) + ? await ensureConversationChannel({ + tx, + tenancyId: options.tenancyId, + conversationId: options.conversationId, + channelType: options.channelType, + adapterKey: options.adapterKey, + isEntryPoint: false, + }) + : null; + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "ConversationMessage" ( + id, + "tenancyId", + "conversationId", + "channelId", + "messageType", + "senderType", + "senderId", + "senderDisplayName", + "senderPrimaryEmail", + body, + attachments, + metadata, + "createdAt" + ) + VALUES ( + ${messageId}::uuid, + ${options.tenancyId}::uuid, + ${options.conversationId}::uuid, + ${channelId}::uuid, + ${options.messageType}, + ${sender.type}, + ${sender.id}, + ${sender.displayName}, + ${sender.primaryEmail}, + ${options.body}, + ${jsonbParam(options.attachments ?? [])}, + ${options.metadata == null ? Prisma.sql`NULL` : jsonbParam(options.metadata)}, + ${now} + ) + `); + + const conversationSetParts: Prisma.Sql[] = []; + if (autoStatus != null) { + conversationSetParts.push(Prisma.sql`status = ${autoStatus}`); + } + conversationSetParts.push( + Prisma.sql`"updatedAt" = ${now}`, + Prisma.sql`"lastMessageAt" = ${now}`, + Prisma.sql`"lastInboundAt" = ${shouldTrackReplies && sender.type === "user" ? Prisma.sql`${now}` : Prisma.sql`"lastInboundAt"`}`, + Prisma.sql`"lastOutboundAt" = ${shouldTrackReplies && sender.type === "agent" ? Prisma.sql`${now}` : Prisma.sql`"lastOutboundAt"`}`, + ); + + await tx.$executeRaw(Prisma.sql` + UPDATE "Conversation" + SET ${Prisma.join(conversationSetParts, ", ")} + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND id = ${options.conversationId}::uuid + `); + + await tx.$executeRaw(Prisma.sql` + UPDATE "ConversationMetadata" + SET + "updatedAt" = ${now}, + "firstResponseAt" = ${nextFirstResponseAt == null ? Prisma.sql`"firstResponseAt"` : Prisma.sql`${nextFirstResponseAt}`}, + "lastCustomerReplyAt" = ${shouldTrackReplies && sender.type === "user" ? Prisma.sql`${now}` : Prisma.sql`"lastCustomerReplyAt"`}, + "lastAgentReplyAt" = ${shouldTrackReplies && sender.type === "agent" ? Prisma.sql`${now}` : Prisma.sql`"lastAgentReplyAt"`} + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND "conversationId" = ${options.conversationId}::uuid + `); + }); +} + +export async function updateConversationStatus(options: { + tenancyId: string, + conversationId: string, + status: ConversationStatus, + sender: ConversationSender, + viewerProjectUserId?: string, +}) { + const sender = parseSender(options.sender); + const conversation = await getConversationState({ + tenancyId: options.tenancyId, + conversationId: options.conversationId, + viewerProjectUserId: options.viewerProjectUserId, + }); + + if (conversation.status === options.status) { + throw new StatusError(400, `Conversation is already ${options.status}.`); + } + + const now = new Date(); + const messageId = generateUuid(); + + await retryTransaction(globalPrismaClient, async (tx) => { + await tx.$executeRaw(Prisma.sql` + UPDATE "Conversation" + SET + status = ${options.status}, + "updatedAt" = ${now}, + "lastMessageAt" = ${now}, + "closedAt" = ${options.status === "closed" ? Prisma.sql`${now}` : Prisma.sql`NULL`} + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND id = ${options.conversationId}::uuid + `); + + await tx.$executeRaw(Prisma.sql` + INSERT INTO "ConversationMessage" ( + id, + "tenancyId", + "conversationId", + "channelId", + "messageType", + "senderType", + "senderId", + "senderDisplayName", + "senderPrimaryEmail", + body, + attachments, + metadata, + "createdAt" + ) + VALUES ( + ${messageId}::uuid, + ${options.tenancyId}::uuid, + ${options.conversationId}::uuid, + NULL, + 'status-change', + ${sender.type}, + ${sender.id}, + ${sender.displayName}, + ${sender.primaryEmail}, + NULL, + ${jsonbParam([])}, + ${jsonbParam({ status: options.status })}, + ${now} + ) + `); + }); +} + +export async function updateConversationMetadata(options: { + tenancyId: string, + conversationId: string, + assignedToUserId?: string | null, + assignedToDisplayName?: string | null, + tags?: string[], + priority?: ConversationPriority, +}) { + const metadataUpdates: Prisma.Sql[] = []; + + if ("assignedToUserId" in options) { + metadataUpdates.push(Prisma.sql`"assignedToUserId" = ${options.assignedToUserId ?? null}`); + } + if ("assignedToDisplayName" in options) { + metadataUpdates.push(Prisma.sql`"assignedToDisplayName" = ${options.assignedToDisplayName ?? null}`); + } + if ("tags" in options) { + metadataUpdates.push(Prisma.sql`tags = ${jsonbParam(options.tags ?? [])}`); + } + + await retryTransaction(globalPrismaClient, async (tx) => { + if (options.priority != null) { + await tx.$executeRaw(Prisma.sql` + UPDATE "Conversation" + SET + priority = ${options.priority}, + "updatedAt" = NOW() + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND id = ${options.conversationId}::uuid + `); + } + + if (metadataUpdates.length > 0) { + await tx.$executeRaw(Prisma.sql` + UPDATE "ConversationMetadata" + SET + ${Prisma.join(metadataUpdates, ", ")}, + "updatedAt" = NOW() + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND "conversationId" = ${options.conversationId}::uuid + `); + } + }); +} + +import.meta.vitest?.describe("conversation helpers", (test) => { + test("nextConversationStatusAfterAppend moves open → pending on agent message", ({ expect }) => { + expect(nextConversationStatusAfterAppend({ + messageType: "message", + senderType: "agent", + currentStatus: "open", + })).toBe("pending"); + }); + + test("nextConversationStatusAfterAppend moves pending → open on user message", ({ expect }) => { + expect(nextConversationStatusAfterAppend({ + messageType: "message", + senderType: "user", + currentStatus: "pending", + })).toBe("open"); + }); + + test("nextConversationStatusAfterAppend leaves open on user message", ({ expect }) => { + expect(nextConversationStatusAfterAppend({ + messageType: "message", + senderType: "user", + currentStatus: "open", + })).toBe(null); + }); + + test("nextConversationStatusAfterAppend leaves internal notes unchanged", ({ expect }) => { + expect(nextConversationStatusAfterAppend({ + messageType: "internal-note", + senderType: "agent", + currentStatus: "open", + })).toBe(null); + }); + + test("previewForSummary returns body text for messages", ({ expect }) => { + expect(previewForSummary({ + latestBody: " Need help with onboarding ", + latestMessageType: "message", + status: "open", + })).toBe("Need help with onboarding"); + }); + + test("previewForSummary formats status changes without a body", ({ expect }) => { + expect(previewForSummary({ + latestBody: null, + latestMessageType: "status-change", + status: "closed", + })).toBe("Conversation closed"); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/conversations/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/conversations/page-client.tsx new file mode 100644 index 0000000000..5ad87980e0 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/conversations/page-client.tsx @@ -0,0 +1,1238 @@ +"use client"; + +import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { UserSearchPicker } from "@/components/data-table/user-search-picker"; +import { useRouter } from "@/components/router"; +import { DesignAlert, DesignBadge, DesignCard, DesignCategoryTabs, DesignInput, DesignPillToggle, DesignSelectorDropdown } from "@/components/design-components"; +import { + Avatar, + AvatarFallback, + AvatarImage, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, + Textarea, + Typography, + cn, +} from "@/components/ui"; +import { + appendConversationUpdate, + createConversation, + getConversation, + listConversations, +} from "@/lib/conversations"; +import { + type ConversationDetailResponse, + type ConversationPriority, + type ConversationSource, + type ConversationStatus, + type ConversationSummary, +} from "@/lib/conversation-types"; +import { useUser } from "@stackframe/stack"; +import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; +import { + ArrowLeftIcon, + ArrowSquareOutIcon, + ArrowsClockwiseIcon, + ChatCircleDotsIcon, + HeadsetIcon, + MagnifyingGlassIcon, + NotePencilIcon, + PlusIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; +import { AppEnabledGuard } from "../app-enabled-guard"; +import { PageLayout } from "../page-layout"; + +const EMPTY_USER_ID = "00000000-0000-4000-8000-000000000000"; + +const PRIORITY_OPTIONS: Array<{ id: ConversationPriority, label: string, color: "blue" | "orange" | "red" | "purple" }> = [ + { id: "low", label: "Low", color: "purple" }, + { id: "normal", label: "Normal", color: "blue" }, + { id: "high", label: "High", color: "orange" }, + { id: "urgent", label: "Urgent", color: "red" }, +]; + +const STATUS_FILTER_OPTIONS = [ + { id: "all", label: "All" }, + { id: "open", label: "Open" }, + { id: "pending", label: "Pending" }, + { id: "closed", label: "Closed" }, +] as const; + +function getPriorityMeta(priority: ConversationPriority) { + return PRIORITY_OPTIONS.find((option) => option.id === priority) ?? PRIORITY_OPTIONS[1]; +} + +function getStatusBadge(status: ConversationStatus) { + if (status === "open") { + return { label: "Open", color: "green" as const }; + } + if (status === "pending") { + return { label: "Pending", color: "orange" as const }; + } + return { label: "Closed", color: "red" as const }; +} + +function getSourceBadge(source: ConversationSource) { + if (source === "chat") { + return { label: "Chat", color: "blue" as const }; + } + if (source === "email") { + return { label: "Email", color: "orange" as const }; + } + if (source === "api") { + return { label: "API", color: "green" as const }; + } + return { label: "Manual", color: "purple" as const }; +} + +function getConversationQueueLabel(status: ConversationStatus) { + if (status === "pending") { + return "Waiting on user"; + } + if (status === "closed") { + return "Resolved"; + } + return "Needs support"; +} + +function formatSupportTimestamp(value: string) { + return fromNow(new Date(value)); +} + +function formatAbsoluteTimestamp(value: string | null) { + if (value == null) { + return "Not set"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Invalid date"; + } + return date.toLocaleString(); +} + +function parseTagInput(value: string) { + const tags = value + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag !== ""); + return Array.from(new Set(tags)); +} + +function getTeamMemberDisplayName(member: { + id: string, + teamProfile?: { displayName?: string | null } | null, + primaryEmail?: string | null, +}) { + const teamProfileDisplayName = member.teamProfile?.displayName; + if (teamProfileDisplayName != null && teamProfileDisplayName.trim() !== "") { + return teamProfileDisplayName; + } + if (member.primaryEmail != null && member.primaryEmail.trim() !== "") { + return member.primaryEmail; + } + return member.id; +} + +function getStatusChangeTarget(message: ConversationDetailResponse["messages"][number]) { + if (typeof message.metadata !== "object" || message.metadata == null || !("status" in message.metadata)) { + return message.status; + } + const nextStatus = message.metadata.status; + return typeof nextStatus === "string" ? nextStatus : message.status; +} + +function getConversationSenderLabel(message: ConversationDetailResponse["messages"][number]) { + return message.sender.displayName ?? message.sender.primaryEmail ?? ( + message.sender.type === "user" ? "Customer" : message.sender.type === "agent" ? "Support" : "System" + ); +} + +function SupportChatMessage(props: { + message: ConversationDetailResponse["messages"][number], + conversation: ConversationSummary, +}) { + const trimmedBody = props.message.body?.trim() ?? ""; + if (props.message.messageType !== "status-change" && trimmedBody === "") { + return null; + } + + const senderLabel = getConversationSenderLabel(props.message); + const timestampLabel = formatSupportTimestamp(props.message.createdAt); + + if (props.message.messageType === "status-change") { + const statusText = trimmedBody !== "" ? trimmedBody : `Conversation marked ${getStatusChangeTarget(props.message)}`; + return ( +
+
+ + {statusText} + + {timestampLabel} + +
+
+ ); + } + + const customerName = props.conversation.userDisplayName ?? props.conversation.userPrimaryEmail ?? "Customer"; + + if (props.message.messageType === "internal-note") { + return ( +
+
+
+ +
+
+
+
+
+ + Internal note + + + {senderLabel} + +
+ + {timestampLabel} + +
+ + {trimmedBody} + +
+
+
+ ); + } + + const isCustomer = props.message.sender.type === "user"; + /** Reserve avatar (2rem) + gap (gap-2 = 0.5rem) so %/calc max-widths resolve against the full row width. */ + const bubbleMaxClass = "max-w-[min(720px,calc(100%-2.5rem))]"; + const bubble = ( +
+
+ + {isCustomer ? customerName : senderLabel} + + + {timestampLabel} + +
+ {trimmedBody} +
+ ); + + if (isCustomer) { + const initialsSource = props.conversation.userDisplayName ?? props.conversation.userPrimaryEmail ?? "??"; + return ( +
+
+
+ + + {initialsSource.slice(0, 2)} + +
+
+ {bubble} +
+
+
+ ); + } + + return ( +
+
+
+ {bubble} +
+
+
+ +
+
+
+
+ ); +} + +function SupportUserHeader(props: { + displayName: string | null, + primaryEmail: string | null, + profileImageUrl: string | null, + size?: "default" | "compact", +}) { + const name = props.displayName ?? props.primaryEmail ?? "Unknown user"; + const size = props.size ?? "default"; + return ( +
+ + + {name.slice(0, 2)} + +
+ {name} + + {props.primaryEmail ?? "No primary email"} + +
+
+ ); +} + +function NewConversationDialog(props: { + open: boolean, + onOpenChange: (open: boolean) => void, + currentUser: { getAccessToken: () => Promise } | null, + projectId: string, + initialUserId?: string | null, + initialUserLabel?: string | null, + onCreated: (conversationId: string, userId: string) => void, +}) { + const [selectedUserId, setSelectedUserId] = useState(props.initialUserId ?? null); + const [selectedUserLabel, setSelectedUserLabel] = useState(props.initialUserLabel ?? null); + const [subject, setSubject] = useState(""); + const [initialMessage, setInitialMessage] = useState(""); + const [priority, setPriority] = useState("normal"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + if (!props.open) { + return; + } + setSelectedUserId(props.initialUserId ?? null); + setSelectedUserLabel(props.initialUserLabel ?? null); + setSubject(""); + setInitialMessage(""); + setPriority("normal"); + setErrorMessage(null); + }, [props.initialUserId, props.initialUserLabel, props.open]); + + const canSubmit = subject.trim() !== "" && initialMessage.trim() !== ""; + + return ( + + + + Create conversation + + Start a support conversation for a user and keep replies, notes, and context in one place. + + + +
+
+ + User + + {selectedUserLabel != null ? ( +
+ {selectedUserLabel} + +
+ ) : ( + ( + + )} + /> + )} +
+ +
+
+ + Subject + + setSubject(event.target.value)} + placeholder="Password reset loop on mobile" + /> +
+
+ + Priority + + +
+
+ +
+ + Initial message + +