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..63a0d5f99f --- /dev/null +++ b/apps/backend/prisma/migrations/20260420000000_add_conversations/migration.sql @@ -0,0 +1,80 @@ +CREATE TABLE "Conversation" ( + "id" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "projectUserId" UUID, + "teamId" UUID, + "subject" TEXT NOT NULL, + "status" TEXT NOT NULL, + "priority" TEXT NOT NULL, + "source" TEXT 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, + "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 +); + +CREATE TABLE "ConversationEntryPoint" ( + "id" UUID NOT NULL, + "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, + + CONSTRAINT "ConversationEntryPoint_pkey" PRIMARY KEY ("tenancyId","id"), + CONSTRAINT "ConversationEntryPoint_type_check" CHECK ("channelType" IN ('manual', 'chat', 'email', 'api')), + CONSTRAINT "ConversationEntryPoint_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ConversationEntryPoint_tenancyId_conversationId_fkey" FOREIGN KEY ("tenancyId", "conversationId") REFERENCES "Conversation"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE "ConversationMessage" ( + "id" UUID NOT NULL, + "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_tenancyId_conversationId_fkey" FOREIGN KEY ("tenancyId", "conversationId") REFERENCES "Conversation"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ConversationMessage_tenancyId_channelId_fkey" FOREIGN KEY ("tenancyId", "channelId") REFERENCES "ConversationEntryPoint"("tenancyId", "id") ON DELETE NO ACTION 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 "ConversationEntryPoint_conversation_createdAt_idx" ON "ConversationEntryPoint"("tenancyId", "conversationId", "createdAt"); +CREATE INDEX "ConversationEntryPoint_type_adapter_idx" ON "ConversationEntryPoint"("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..92c48a0d06 --- /dev/null +++ b/apps/backend/prisma/migrations/20260420000000_add_conversations/tests/creates-conversation-tables.ts @@ -0,0 +1,355 @@ +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(); + const teamId = 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()) + `; + await sql` + INSERT INTO "Team" ("teamId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "displayName") + VALUES (${teamId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), 'Conversation Team') + `; + + return { tenancyId, projectUserId, teamId }; +}; + +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', 'ConversationEntryPoint', 'ConversationMessage') + ORDER BY table_name + `; + expect(Array.from(tables)).toMatchInlineSnapshot(` + [ + { + "table_name": "Conversation", + }, + { + "table_name": "ConversationEntryPoint", + }, + { + "table_name": "ConversationMessage", + }, + ] + `); + + const conversationId = randomUUID(); + const channelId = randomUUID(); + const messageId = randomUUID(); + + await sql` + INSERT INTO "Conversation" ( + "id", + "tenancyId", + "projectUserId", + "subject", + "status", + "priority", + "source", + "assignedToUserId", + "assignedToDisplayName", + "tags", + "createdAt", + "updatedAt", + "lastMessageAt" + ) + VALUES ( + ${conversationId}::uuid, + ${ctx.tenancyId}::uuid, + ${ctx.projectUserId}::uuid, + 'Need support with onboarding', + 'open', + 'high', + 'chat', + 'support-admin-1', + 'Support Admin', + ${JSON.stringify(["vip", "auth"])}::jsonb, + NOW(), + NOW(), + NOW() + ) + `; + + await sql` + INSERT INTO "ConversationEntryPoint" ( + "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 "Conversation" ( + "id", + "tenancyId", + "projectUserId", + "subject", + "status", + "priority", + "source", + "createdAt", + "updatedAt", + "lastMessageAt" + ) + VALUES ( + ${randomUUID()}::uuid, + ${ctx.tenancyId}::uuid, + ${ctx.projectUserId}::uuid, + 'Broken conversation priority', + 'open', + 'invalid', + 'chat', + NOW(), + NOW(), + NOW() + ) + `).rejects.toThrow(/Conversation_priority_check/); + + 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 source', + 'open', + 'high', + 'invalid', + NOW(), + NOW(), + NOW() + ) + `).rejects.toThrow(/Conversation_source_check/); + + await expect(sql` + INSERT INTO "ConversationEntryPoint" ( + "id", + "tenancyId", + "conversationId", + "channelType", + "adapterKey", + "isEntryPoint", + "createdAt", + "updatedAt" + ) + VALUES ( + ${randomUUID()}::uuid, + ${ctx.tenancyId}::uuid, + ${conversationId}::uuid, + 'invalid', + 'support-chat', + true, + NOW(), + NOW() + ) + `).rejects.toThrow(/ConversationEntryPoint_type_check/); + + await expect(sql` + INSERT INTO "ConversationMessage" ( + "id", + "tenancyId", + "conversationId", + "messageType", + "senderType", + "createdAt" + ) + VALUES ( + ${randomUUID()}::uuid, + ${ctx.tenancyId}::uuid, + ${conversationId}::uuid, + 'invalid', + 'user', + NOW() + ) + `).rejects.toThrow(/ConversationMessage_messageType_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/); + + await sql` + DELETE FROM "ProjectUser" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "projectUserId" = ${ctx.projectUserId}::uuid + `; + + const userConversationRows = await sql` + SELECT "projectUserId" + FROM "Conversation" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "id" = ${conversationId}::uuid + `; + expect(userConversationRows).toHaveLength(1); + expect(userConversationRows[0].projectUserId).toBe(ctx.projectUserId); + + const teamConversationId = randomUUID(); + await sql` + INSERT INTO "Conversation" ( + "id", + "tenancyId", + "teamId", + "subject", + "status", + "priority", + "source", + "createdAt", + "updatedAt", + "lastMessageAt" + ) + VALUES ( + ${teamConversationId}::uuid, + ${ctx.tenancyId}::uuid, + ${ctx.teamId}::uuid, + 'Team conversation', + 'open', + 'normal', + 'chat', + NOW(), + NOW(), + NOW() + ) + `; + + await sql` + DELETE FROM "Team" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "teamId" = ${ctx.teamId}::uuid + `; + + const teamConversationRows = await sql` + SELECT "teamId" + FROM "Conversation" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "id" = ${teamConversationId}::uuid + `; + expect(teamConversationRows).toHaveLength(1); + expect(teamConversationRows[0].teamId).toBe(ctx.teamId); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0b32726af2..485d89da91 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -77,6 +77,9 @@ model Tenancy { sessionReplays SessionReplay[] sessionReplayChunks SessionReplayChunk[] managedEmailDomains ManagedEmailDomain[] + conversations Conversation[] + conversationEntryPoints ConversationEntryPoint[] + conversationMessages ConversationMessage[] // Email capacity boost - when set and in the future, email capacity is multiplied by 4 emailCapacityBoostExpiresAt DateTime? @@ -1135,6 +1138,89 @@ 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 + 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 + lastMessageAt DateTime @default(now()) + lastInboundAt DateTime? + lastOutboundAt DateTime? + closedAt DateTime? + + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + messages ConversationMessage[] + entryPoints ConversationEntryPoint[] + + @@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 ConversationEntryPoint { + 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) + messages ConversationMessage[] + + @@id([tenancyId, id]) + @@index([tenancyId, conversationId, createdAt], name: "ConversationEntryPoint_conversation_createdAt_idx") + @@index([tenancyId, channelType, adapterKey], name: "ConversationEntryPoint_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) + channel ConversationEntryPoint? @relation(fields: [tenancyId, channelId], references: [tenancyId, id], onDelete: NoAction) + + @@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/internal/conversations/[conversationId]/route.tsx b/apps/backend/src/app/api/latest/internal/conversations/[conversationId]/route.tsx new file mode 100644 index 0000000000..87cf8c88ef --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/conversations/[conversationId]/route.tsx @@ -0,0 +1,167 @@ +import { + appendConversationMessage, + getConversationDetail, + getManagedProjectTenancy, + updateConversationAttributes, + updateConversationStatus, +} from "@/lib/conversations"; +import { + conversationDetailResponseSchema, + conversationPriorityValues, + conversationStatusValues, +} from "@/lib/conversation-types"; +import { + conversationIdRouteParamsSchema, + internalDashboardAuthSchema, +} from "@/lib/conversations-api"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { + projectIdSchema, + yupArray, + yupNumber, + yupObject, + yupString, + yupUnion, +} from "@stackframe/stack-shared/dist/schema-fields"; + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Get conversation detail", + description: "Get conversation detail for a managed project", + }, + request: yupObject({ + auth: internalDashboardAuthSchema, + params: conversationIdRouteParamsSchema, + 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 conversation attributes on a managed project conversation", + }, + request: yupObject({ + auth: internalDashboardAuthSchema, + params: conversationIdRouteParamsSchema, + 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 updateConversationAttributes({ + 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..71051dec12 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/conversations/route.tsx @@ -0,0 +1,135 @@ +import { + createConversation, + getManagedProjectTenancy, + listConversationSummaries, +} from "@/lib/conversations"; +import { + conversationListResponseSchema, + conversationPriorityValues, + conversationSourceValues, + conversationStatusValues, + type ConversationSource, +} from "@/lib/conversation-types"; +import { internalDashboardAuthSchema, parseConversationListLimit, parseConversationListOffset } from "@/lib/conversations-api"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { projectIdSchema, userIdSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { globalPrismaClient } from "@/prisma-client"; + +const conversationEntryPointBySource = { + manual: { channelType: "manual", adapterKey: "support-dashboard" }, + chat: { channelType: "chat", adapterKey: "support-chat" }, + email: { channelType: "email", adapterKey: "support-dashboard" }, + api: { channelType: "api", adapterKey: "support-dashboard" }, +} satisfies Record; + +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(), + limit: yupString().optional(), + offset: yupString().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, + limit: parseConversationListLimit(query.limit), + offset: parseConversationListOffset(query.offset), + }); + + 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 source = body.source ?? "manual"; + const entryPoint = conversationEntryPointBySource[source]; + + const result = await createConversation({ + tenancyId: tenancy.id, + userId: body.userId, + subject: body.subject, + priority: body.priority, + source, + channelType: entryPoint.channelType, + adapterKey: entryPoint.adapterKey, + 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/app/api/latest/internal/dogfood/support/conversations/[conversationId]/route.tsx b/apps/backend/src/app/api/latest/internal/dogfood/support/conversations/[conversationId]/route.tsx new file mode 100644 index 0000000000..c33ea6b270 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/dogfood/support/conversations/[conversationId]/route.tsx @@ -0,0 +1,99 @@ +import { + authenticatedUserAuthSchema, + conversationIdRouteParamsSchema, + publicConversationDetailResponseSchema, + toPublicConversationDetail, +} from "@/lib/conversations-api"; +import { + appendConversationMessage, + getConversationDetail, +} from "@/lib/conversations"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { + yupNumber, + yupObject, + yupString, +} from "@stackframe/stack-shared/dist/schema-fields"; + +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: conversationIdRouteParamsSchema, + 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: conversationIdRouteParamsSchema, + body: yupObject({ + message: yupString().trim().min(1).max(5000).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/internal/dogfood/support/conversations/route.tsx b/apps/backend/src/app/api/latest/internal/dogfood/support/conversations/route.tsx new file mode 100644 index 0000000000..d3e16ce6c9 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/dogfood/support/conversations/route.tsx @@ -0,0 +1,107 @@ +import { + authenticatedUserAuthSchema, + parseConversationListLimit, + parseConversationListOffset, + publicConversationListResponseSchema, + toPublicConversationSummary, +} from "@/lib/conversations-api"; +import { + createConversation, + listConversationSummaries, +} from "@/lib/conversations"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { + yupNumber, + yupObject, + yupString, +} from "@stackframe/stack-shared/dist/schema-fields"; + +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(), + limit: yupString().optional(), + offset: 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, + limit: parseConversationListLimit(query.limit), + offset: parseConversationListOffset(query.offset), + }); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { + conversations: conversations.conversations.map(toPublicConversationSummary), + has_more: conversations.hasMore, + }, + }; + }, +}); + +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/feedback/route.tsx b/apps/backend/src/app/api/latest/internal/feedback/route.tsx index ef240622a0..e2f536b081 100644 --- a/apps/backend/src/app/api/latest/internal/feedback/route.tsx +++ b/apps/backend/src/app/api/latest/internal/feedback/route.tsx @@ -1,3 +1,4 @@ +import { createConversation } from "@/lib/conversations"; import { sendSupportFeedbackEmail } from "@/lib/internal-feedback-emails"; import { isLocalEmulatorEnabled } from "@/lib/local-emulator"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -75,6 +76,30 @@ export const POST = createSmartRouteHandler({ feedbackType: body.feedback_type, }); + // Dogfood: mirror dashboard support submissions into the managed inbox (same subject line as email). + // If the mirror write fails the user will see a 500 and retry; duplicate emails are preferable to + // silently swallowing a real failure (per AGENTS.md: never catch-all). + if (auth?.tenancy != null && auth.user != null) { + const feedbackLabel = body.feedback_type === "bug" ? "Bug Report" : "Support"; + const conversationSubject = `[${feedbackLabel}] ${body.email}`; + await createConversation({ + tenancyId: auth.tenancy.id, + userId: auth.user.id, + subject: conversationSubject, + priority: "normal", + source: "api", + channelType: "api", + adapterKey: "dashboard-support-form", + 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, bodyType: "json", diff --git a/apps/backend/src/lib/conversation-types.ts b/apps/backend/src/lib/conversation-types.ts new file mode 100644 index 0000000000..c7783475a3 --- /dev/null +++ b/apps/backend/src/lib/conversation-types.ts @@ -0,0 +1 @@ +export * from "@stackframe/stack-shared/dist/interface/conversations"; diff --git a/apps/backend/src/lib/conversations-api.ts b/apps/backend/src/lib/conversations-api.ts new file mode 100644 index 0000000000..babc54d828 --- /dev/null +++ b/apps/backend/src/lib/conversations-api.ts @@ -0,0 +1,178 @@ +import { getConversationDetail, listConversationSummaries } from "@/lib/conversations"; +import { + adaptSchema, + yupArray, + yupBoolean, + yupMixed, + yupObject, + yupString, +} from "@stackframe/stack-shared/dist/schema-fields"; + +/** + * Auth schema shared by internal dashboard conversation routes. Only signed-in + * internal-project users are permitted. Keep the auth shape consistent so all + * conversation routes enforce the same access model. + */ +export const internalDashboardAuthSchema = yupObject({ + type: adaptSchema, + user: adaptSchema.defined(), + project: yupObject({ + id: yupString().oneOf(["internal"]).defined(), + }).defined(), +}).defined(); + +/** + * Auth schema shared by the public user-facing conversation routes. Requires + * a signed-in client user scoped to a tenancy. + */ +export const authenticatedUserAuthSchema = yupObject({ + type: yupString().oneOf(["client"]).defined(), + tenancy: adaptSchema.defined(), + user: adaptSchema.defined(), +}).defined(); + +/** + * `[conversationId]` URL param schema — shared by every conversation sub-route. + */ +export const conversationIdRouteParamsSchema = yupObject({ + conversationId: yupString().uuid().defined(), +}).defined(); + +export function parseConversationListLimit(value: string | undefined) { + if (value == null) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + return Math.max(1, Math.min(200, Number.isFinite(parsed) ? parsed : 200)); +} + +export function parseConversationListOffset(value: string | undefined) { + if (value == null) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + return Math.max(0, Number.isFinite(parsed) ? parsed : 0); +} + +/** + * Public (snake_case) response schemas. These are what leaves the API boundary + * to external consumers; the internal camelCase shapes live in + * `@stackframe/stack-shared/dist/interface/conversations`. + */ +export 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(); + +export 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(); + +export const publicConversationListResponseSchema = yupObject({ + conversations: yupArray(publicConversationSummarySchema).defined(), + has_more: yupBoolean().defined(), +}).defined(); + +export 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(); + +export const publicConversationDetailResponseSchema = yupObject({ + conversation: publicConversationSummarySchema, + messages: yupArray(publicConversationMessageSchema).defined(), +}).defined(); + +type ConversationSummaryInput = Awaited>["conversations"][number]; +type ConversationDetailInput = Awaited>; + +export function toPublicConversationSummary(conversation: ConversationSummaryInput) { + return { + 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 function toPublicConversationDetail(detail: ConversationDetailInput) { + return { + conversation: toPublicConversationSummary(detail.conversation), + 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, + }, + })), + }; +} diff --git a/apps/backend/src/lib/conversations.tsx b/apps/backend/src/lib/conversations.tsx new file mode 100644 index 0000000000..04f22db8e5 --- /dev/null +++ b/apps/backend/src/lib/conversations.tsx @@ -0,0 +1,1016 @@ +import { Prisma } from "@/generated/prisma/client"; +import { + conversationMessageTypeValues, + conversationPriorityValues, + conversationSenderSchema, + conversationSenderTypeValues, + conversationSourceValues, + conversationStatusValues, + type ConversationDetailResponse, + type ConversationEntryPoint, + type ConversationMessage, + type ConversationMessageType, + type ConversationMetadata, + type ConversationPriority, + type ConversationSender, + type ConversationSource, + type ConversationStatus, + type ConversationSummary, +} from "@/lib/conversation-types"; +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 { computeFirstResponseDueAt, computeNextResponseDueAt, DEFAULT_SUPPORT_SLA } from "@stackframe/stack-shared/dist/helpers/support-sla"; +import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +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"; + +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, + updatedAt: Date, + lastMessageAt: Date, + lastInboundAt: Date | null, + lastOutboundAt: Date | null, + closedAt: Date | null, + recordMetadata: Prisma.JsonValue | null, + 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 ConversationEntryPointRow = { + id: string, + channelType: string, + adapterKey: string, + externalChannelId: string | null, + isEntryPoint: boolean, + metadata: Prisma.JsonValue | null, + createdAt: Date, + updatedAt: Date, +}; + +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 conversationAttributesFromRow(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: conversationAttributesFromRow(row), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + lastMessageAt: row.lastMessageAt.toISOString(), + lastInboundAt: toIsoString(row.lastInboundAt), + lastOutboundAt: toIsoString(row.lastOutboundAt), + closedAt: toIsoString(row.closedAt), + recordMetadata: row.recordMetadata ?? null, + }; +} + +function entryPointFromRow(row: ConversationEntryPointRow): ConversationEntryPoint { + return { + id: row.id, + channelType: row.channelType, + adapterKey: row.adapterKey, + externalChannelId: row.externalChannelId, + isEntryPoint: row.isEntryPoint, + metadata: row.metadata, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +// ConversationMessage responses are enriched with parent Conversation context so +// clients can render messages without separately joining each one to the thread. +// These fields are current conversation state, not columns on ConversationMessage. +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", + c."updatedAt", + c."lastMessageAt", + c."lastInboundAt", + c."lastOutboundAt", + c."closedAt", + c.metadata AS "recordMetadata", + pu."displayName" AS "userDisplayName", + pu."profileImageUrl" AS "userProfileImageUrl", + cc."value" AS "userPrimaryEmail", + c."assignedToUserId", + c."assignedToDisplayName", + c.tags, + c."firstResponseDueAt", + c."firstResponseAt", + c."nextResponseDueAt", + c."lastCustomerReplyAt", + c."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' + 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, + c."firstResponseAt", + c."lastCustomerReplyAt", + c."lastAgentReplyAt" + FROM "Conversation" c + 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 ensureConversationEntryPoint(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 "ConversationEntryPoint" + 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 entryPointId = generateUuid(); + await options.tx.$executeRaw(Prisma.sql` + INSERT INTO "ConversationEntryPoint" ( + id, + "tenancyId", + "conversationId", + "channelType", + "adapterKey", + "externalChannelId", + "isEntryPoint", + metadata, + "createdAt", + "updatedAt" + ) + VALUES ( + ${entryPointId}::uuid, + ${options.tenancyId}::uuid, + ${options.conversationId}::uuid, + ${options.channelType}, + ${options.adapterKey}, + NULL, + ${options.isEntryPoint}, + NULL, + NOW(), + NOW() + ) + `); + return entryPointId; +} + +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, + limit?: number, + offset?: number, +}) { + const escapedQuery = options.query?.trim().toLowerCase() + .replace(/\\/g, "\\\\") + .replace(/%/g, "\\%") + .replace(/_/g, "\\_"); + const searchPattern = options.query == null || options.query.trim() === "" + ? null + : `%${escapedQuery}%`; + const limit = options.limit ?? 200; + const offset = options.offset ?? 0; + + 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", + c."updatedAt", + c."lastMessageAt", + c."lastInboundAt", + c."lastOutboundAt", + c."closedAt", + c.metadata AS "recordMetadata", + pu."displayName" AS "userDisplayName", + pu."profileImageUrl" AS "userProfileImageUrl", + cc."value" AS "userPrimaryEmail", + c."assignedToUserId", + c."assignedToDisplayName", + c.tags, + c."firstResponseDueAt", + c."firstResponseAt", + c."nextResponseDueAt", + c."lastCustomerReplyAt", + c."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 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} ESCAPE '\\' + OR LOWER(COALESCE(lm.body, '')) LIKE ${searchPattern} ESCAPE '\\' + OR LOWER(COALESCE(pu."displayName", '')) LIKE ${searchPattern} ESCAPE '\\' + OR LOWER(COALESCE(cc."value", '')) LIKE ${searchPattern} ESCAPE '\\' + ) + ` : Prisma.empty} + ORDER BY COALESCE(lm."createdAt", c."createdAt") DESC, c.id DESC + LIMIT ${limit + 1} + OFFSET ${offset} + `); + + const hasMore = rows.length > limit; + return { + conversations: rows.slice(0, limit).map(summaryFromRow), + hasMore, + }; +} + +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"); + + const entryPointRows = await globalPrismaClient.$queryRaw(Prisma.sql` + SELECT + cep.id, + cep."channelType", + cep."adapterKey", + cep."externalChannelId", + cep."isEntryPoint", + cep.metadata, + cep."createdAt", + cep."updatedAt" + FROM "ConversationEntryPoint" cep + WHERE cep."tenancyId" = ${options.tenancyId}::uuid + AND cep."conversationId" = ${options.conversationId}::uuid + ORDER BY cep."createdAt" ASC, cep.id ASC + `); + + return { + conversation: summaryFromRow({ + ...conversation, + latestMessageType: latestMessage.messageType, + latestBody: latestMessage.body, + lastVisibleActivityAt: new Date(latestMessage.createdAt), + }), + messages, + entryPoints: entryPointRows.map(entryPointFromRow), + }; +} + +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"; + + const firstResponseDueAt = isUserMessage + ? computeFirstResponseDueAt(now, DEFAULT_SUPPORT_SLA) + : null; + + await retryTransaction(globalPrismaClient, async (tx) => { + await tx.$executeRaw(Prisma.sql` + INSERT INTO "Conversation" ( + id, + "tenancyId", + "projectUserId", + "teamId", + subject, + status, + priority, + source, + "assignedToUserId", + "assignedToDisplayName", + tags, + "firstResponseDueAt", + "firstResponseAt", + "nextResponseDueAt", + "lastCustomerReplyAt", + "lastAgentReplyAt", + metadata, + "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}, + NULL, + NULL, + ${jsonbParam([])}, + ${firstResponseDueAt == null ? Prisma.sql`NULL` : Prisma.sql`${firstResponseDueAt}`}, + NULL, + NULL, + ${isUserMessage ? Prisma.sql`${now}` : Prisma.sql`NULL`}, + ${isAgentMessage ? Prisma.sql`${now}` : Prisma.sql`NULL`}, + NULL, + ${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 "ConversationEntryPoint" ( + 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, + }); + + const shouldSetNextResponseDueAt = shouldTrackReplies && sender.type === "user" && autoStatus === "open"; + const shouldClearNextResponseDueAt = shouldTrackReplies && sender.type === "agent"; + const nextResponseDueAt = shouldSetNextResponseDueAt + ? computeNextResponseDueAt(now, DEFAULT_SUPPORT_SLA) + : null; + + await retryTransaction(globalPrismaClient, async (tx) => { + const channelId = ( + options.messageType === "message" + && options.channelType != null + && options.adapterKey != null + ) + ? await ensureConversationEntryPoint({ + 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 "Conversation" + 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"`}, + "nextResponseDueAt" = ${ + shouldClearNextResponseDueAt + ? Prisma.sql`NULL` + : nextResponseDueAt != null + ? Prisma.sql`${nextResponseDueAt}` + : Prisma.sql`"nextResponseDueAt"` + } + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND id = ${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 updateConversationAttributes(options: { + tenancyId: string, + conversationId: string, + assignedToUserId?: string | null, + assignedToDisplayName?: string | null, + tags?: string[], + priority?: ConversationPriority, +}) { + const conversationUpdates: Prisma.Sql[] = []; + + if ("assignedToUserId" in options) { + conversationUpdates.push(Prisma.sql`"assignedToUserId" = ${options.assignedToUserId ?? null}`); + } + if ("assignedToDisplayName" in options) { + conversationUpdates.push(Prisma.sql`"assignedToDisplayName" = ${options.assignedToDisplayName ?? null}`); + } + if ("tags" in options) { + conversationUpdates.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 (conversationUpdates.length > 0) { + await tx.$executeRaw(Prisma.sql` + UPDATE "Conversation" + SET + ${Prisma.join(conversationUpdates, ", ")}, + "updatedAt" = NOW() + WHERE "tenancyId" = ${options.tenancyId}::uuid + AND id = ${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..38cd0a4af9 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/conversations/page-client.tsx @@ -0,0 +1,1551 @@ +"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, DesignSelectorDropdown } from "@/components/design-components"; +import { + Avatar, + AvatarFallback, + AvatarImage, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + 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 { computeSlaUrgency, type SlaUrgency } from "@stackframe/stack-shared/dist/helpers/support-sla"; +import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; +import { + ArrowLeftIcon, + ArrowSquareOutIcon, + ArrowsClockwiseIcon, + ChatCircleDotsIcon, + HeadsetIcon, + MagnifyingGlassIcon, + NotePencilIcon, + PaperPlaneRightIcon, + PlusIcon, + UsersThreeIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useSearchParams } from "next/navigation"; +import { type Dispatch, type SetStateAction, 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 entryPointChannelLabel(channelType: string): string { + const known: ConversationSource[] = ["chat", "email", "api", "manual"]; + if (known.includes(channelType as ConversationSource)) { + return getSourceBadge(channelType as ConversationSource).label; + } + return channelType; +} + +function getConversationStartedFromLine(detail: ConversationDetailResponse): string { + if (detail.entryPoints.length === 0) { + return getSourceBadge(detail.conversation.source).label; + } + const ordered = [...detail.entryPoints].sort((a, b) => Number(b.isEntryPoint) - Number(a.isEntryPoint)); + const primary = ordered[0]; + return `${entryPointChannelLabel(primary.channelType)} · ${primary.adapterKey}`; +} + +function useTickingNow(intervalMs: number = 30_000): Date { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return now; +} + +function parseOptionalDate(value: string | null | undefined): Date | null { + if (value == null) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date; +} + +function getSlaUrgencyFor( + at: string | null, + now: Date, + windowStartedAt?: string | null, +): SlaUrgency | null { + const date = parseOptionalDate(at); + if (date == null) return null; + return computeSlaUrgency(date, now, { windowStartedAt: parseOptionalDate(windowStartedAt) }); +} + +function getSlaUrgencyTextClass(urgency: SlaUrgency | null): string | null { + if (urgency === "overdue" || urgency === "urgent") { + return "text-red-600 dark:text-red-400"; + } + if (urgency === "warning") { + return "text-amber-600 dark:text-amber-400"; + } + return null; +} + +function SlaUrgencyDot(props: { urgency: SlaUrgency | null, className?: string }) { + if (props.urgency == null || props.urgency === "ok") { + return null; + } + const bgClass = props.urgency === "warning" + ? "bg-amber-500" + : "bg-red-500"; + const animateClass = props.urgency === "overdue" ? "animate-pulse" : ""; + return ( + + ); +} + +function getSlaUrgencyAriaLabel(urgency: SlaUrgency | null): string | null { + if (urgency === "overdue") return "SLA overdue"; + if (urgency === "urgent") return "SLA due very soon"; + if (urgency === "warning") return "SLA approaching"; + return null; +} + +function CompactSlaRow(props: { + label: string, + at: string | null, + empty: string, + now?: Date, + isDue?: boolean, + windowStartedAt?: string | null, +}) { + if (props.at == null) { + return ( +
+ {props.label} + {props.empty} +
+ ); + } + const date = new Date(props.at); + if (Number.isNaN(date.getTime())) { + return ( +
+ {props.label} + Invalid +
+ ); + } + const urgency = props.isDue === true && props.now != null + ? computeSlaUrgency(date, props.now, { windowStartedAt: parseOptionalDate(props.windowStartedAt) }) + : null; + const urgencyClass = getSlaUrgencyTextClass(urgency); + const ariaLabel = getSlaUrgencyAriaLabel(urgency); + return ( +
+ {props.label} +
+ + + {fromNow(date)} + +
+
+ ); +} + +function ConversationTeamSlaSidebar(props: { + detail: ConversationDetailResponse, + assignedToDraft: { userId: string | null, displayName: string | null }, + setAssignedToDraft: Dispatch>, + priorityDraft: ConversationPriority, + setPriorityDraft: Dispatch>, + tagsDraft: string, + setTagsDraft: Dispatch>, + assigneeOptions: Array<{ value: string, label: string }>, + settingsSaving: boolean, + settingsError: string | null, + onSave: () => Promise, + now: Date, +}) { + const md = props.detail.conversation.metadata; + return ( +
+
+ + Team + +
+
+ + Assignee + + { + if (value === "unassigned") { + props.setAssignedToDraft({ userId: null, displayName: null }); + return; + } + const assigneeOption = props.assigneeOptions.find((option) => option.value === value) + ?? throwErr(`Assignee option "${value}" was not found.`); + props.setAssignedToDraft({ + userId: assigneeOption.value, + displayName: assigneeOption.label, + }); + }} + options={[ + { value: "unassigned", label: "Unassigned" }, + ...props.assigneeOptions, + ]} + size="sm" + /> +
+
+ + Priority + + { + if (value !== "low" && value !== "normal" && value !== "high" && value !== "urgent") { + throwErr(`Unknown priority value "${value}"`); + } + props.setPriorityDraft(value); + }} + options={PRIORITY_OPTIONS.map((option) => ({ value: option.id, label: option.label }))} + size="sm" + /> +
+
+ + Tags + + props.setTagsDraft(event.target.value)} + placeholder="vip, billing, bug-report" + /> +
+
+ + +
+
+
+ +
+ + SLA + +
+ + + + + +
+
+ + {props.settingsError != null && ( + {props.settingsError} + )} +
+ ); +} + +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() ?? ""; + const attachmentCount = props.message.attachments.length; + const hasAttachments = attachmentCount > 0; + if (props.message.messageType !== "status-change" && trimmedBody === "" && !hasAttachments) { + 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"; + const bodyContent = trimmedBody !== "" ? trimmedBody : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; + + if (props.message.messageType === "internal-note") { + return ( +
+
+
+ +
+
+
+
+
+ + Internal note + + + {senderLabel} + +
+ + {timestampLabel} + +
+ + {bodyContent} + +
+
+
+ ); + } + + 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} + +
+ {bodyContent} +
+ ); + + 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 + +