Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8391a7f
init tutorial docs
madster456 Apr 15, 2026
2748980
init support app
madster456 Apr 20, 2026
13fa65f
Dogfood/ui - Updated UI to cleaner solution. SLA config implemented, …
madster456 Apr 22, 2026
524fa81
remove claude-knowledge dumb stuff
madster456 Apr 22, 2026
712a230
Merge remote-tracking branch 'origin/dev' into apps/support
madster456 Apr 22, 2026
c14c09a
Merge branch 'dev' into apps/support
madster456 Apr 28, 2026
7396b4d
replaced runAsync with runAsyncWithAlert, removed raw error display c…
madster456 Apr 28, 2026
5b7b886
listConversationSummaries now accepts limit/offset, fetches limit +1,…
madster456 Apr 28, 2026
3ba2ab3
Added explicit conversationEntryPointBySource mapping and now pass so…
madster456 Apr 28, 2026
080c4ec
Test only covered conversation_status_check and conversationMessage_s…
madster456 Apr 28, 2026
f98ebb0
the PATCH reply schema had no max, while feedback has max(5000). Upda…
madster456 Apr 28, 2026
c5a6c95
searchPattern was using raw user input and four LIKE caluses had no E…
madster456 Apr 28, 2026
e11b239
Replace support server redirect with client redirect
madster456 Apr 28, 2026
8eba792
Update claude-knowledge
madster456 Apr 28, 2026
9f71471
Update code notes for messageFromRow intentionally populating subject…
madster456 Apr 28, 2026
e608f40
both onBlur handlers in support-settings/page-client were using void …
madster456 Apr 28, 2026
2efa59f
Preserve support conversations when users or teams are deleted.
madster456 Apr 29, 2026
e768468
changed SupportChatMessage so it only returns null when the body is e…
madster456 Apr 29, 2026
71640b1
Merge branch 'dev' into apps/support
madster456 Apr 29, 2026
8bea90b
Merge branch 'dev' into apps/support
madster456 Apr 30, 2026
548bc00
Merge branch 'dev' into apps/support
madster456 May 5, 2026
30db8dd
update pnpm
madster456 May 3, 2026
ae06167
Remove support from config
madster456 May 5, 2026
19dc89d
simplify migrations, remove FK
madster456 May 5, 2026
263d844
Update crud to remove support on deletes, remove support settings pag…
madster456 May 5, 2026
90ba9df
Move conversation routes to internal/dogfood/support/conversations
madster456 May 5, 2026
601dfd1
Merge origin/dev into apps/support
madster456 May 5, 2026
30663e5
fix pnpm-lock
madster456 May 5, 2026
274369b
update docs for app icon
madster456 May 5, 2026
c5c7584
fix migration
madster456 May 6, 2026
c413847
Merge branch 'dev' into apps/support
madster456 May 6, 2026
6b86fb6
align conversations migration with prisma schema
madster456 May 6, 2026
27b1e1c
Merge branch 'dev' into apps/support
madster456 May 6, 2026
883aa07
merge dev into
madster456 May 7, 2026
1565174
Merge branch 'dev' into apps/support
madster456 May 7, 2026
6d65c95
Merge branch 'dev' into apps/support
madster456 May 7, 2026
22e4348
run codegen and update
madster456 May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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,
"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,
"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 "ConversationEntryPoint" (
"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 "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_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 "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");
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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<ReturnType<typeof preMigration>>) => {
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 "ConversationMessage" (
"id",
"tenancyId",
"conversationId",
"messageType",
"senderType",
"createdAt"
)
VALUES (
${randomUUID()}::uuid,
${ctx.tenancyId}::uuid,
${conversationId}::uuid,
'message',
'invalid',
NOW()
)
`).rejects.toThrow(/ConversationMessage_senderType_check/);
Comment thread
madster456 marked this conversation as resolved.
};
90 changes: 90 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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?
Expand Down Expand Up @@ -185,6 +188,7 @@ model Team {

teamMembers TeamMember[]
projectApiKey ProjectApiKey[]
conversations Conversation[]

@@id([tenancyId, teamId])
@@unique([mirroredProjectId, mirroredBranchId, teamId])
Expand Down Expand Up @@ -323,6 +327,7 @@ model ProjectUser {
projectId String?
userNotificationPreference UserNotificationPreference[]
sessionReplays SessionReplay[]
conversations Conversation[]

@@id([tenancyId, projectUserId])
@@unique([mirroredProjectId, mirroredBranchId, projectUserId])
Expand Down Expand Up @@ -1134,6 +1139,91 @@ 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)
projectUser ProjectUser? @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
team Team? @relation(fields: [tenancyId, teamId], references: [tenancyId, teamId], onDelete: Cascade)
messages ConversationMessage[]
entryPoints ConversationEntryPoint[]
Comment thread
madster456 marked this conversation as resolved.

@@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])

@@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
Expand Down
Loading
Loading