diff --git a/frontend/graphql/operations/dashboard.graphql b/frontend/graphql/operations/dashboard.graphql index 0823882..067c5b3 100644 --- a/frontend/graphql/operations/dashboard.graphql +++ b/frontend/graphql/operations/dashboard.graphql @@ -16,6 +16,9 @@ query DashboardQuery { at contactId contact + contactInboxId + sourceExternalId + sourceTitle channel kind direction @@ -25,6 +28,17 @@ query DashboardQuery { transcript deliveryStatus } + contactInboxes { + id + contactId + contactName + channel + sourceExternalId + title + isHidden + lastMessageAt + updatedAt + } calendar { id title diff --git a/frontend/graphql/operations/set-contact-inbox-hidden.graphql b/frontend/graphql/operations/set-contact-inbox-hidden.graphql new file mode 100644 index 0000000..0e32d6a --- /dev/null +++ b/frontend/graphql/operations/set-contact-inbox-hidden.graphql @@ -0,0 +1,5 @@ +mutation SetContactInboxHidden($inboxId: ID!, $hidden: Boolean!) { + setContactInboxHidden(inboxId: $inboxId, hidden: $hidden) { + ok + } +} diff --git a/frontend/prisma/migrations/1_contact_inboxes_and_hide_prefs/migration.sql b/frontend/prisma/migrations/1_contact_inboxes_and_hide_prefs/migration.sql new file mode 100644 index 0000000..a02602c --- /dev/null +++ b/frontend/prisma/migrations/1_contact_inboxes_and_hide_prefs/migration.sql @@ -0,0 +1,66 @@ +-- AlterTable +ALTER TABLE "ContactMessage" ADD COLUMN "contactInboxId" TEXT; + +-- CreateTable +CREATE TABLE "ContactInbox" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "contactId" TEXT NOT NULL, + "channel" "MessageChannel" NOT NULL, + "sourceExternalId" TEXT NOT NULL, + "title" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContactInbox_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContactInboxPreference" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "contactInboxId" TEXT NOT NULL, + "isHidden" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContactInboxPreference_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ContactInbox_contactId_updatedAt_idx" ON "ContactInbox"("contactId", "updatedAt"); + +-- CreateIndex +CREATE INDEX "ContactInbox_teamId_updatedAt_idx" ON "ContactInbox"("teamId", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContactInbox_teamId_channel_sourceExternalId_key" ON "ContactInbox"("teamId", "channel", "sourceExternalId"); + +-- CreateIndex +CREATE INDEX "ContactInboxPreference_teamId_userId_isHidden_idx" ON "ContactInboxPreference"("teamId", "userId", "isHidden"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContactInboxPreference_userId_contactInboxId_key" ON "ContactInboxPreference"("userId", "contactInboxId"); + +-- CreateIndex +CREATE INDEX "ContactMessage_contactInboxId_occurredAt_idx" ON "ContactMessage"("contactInboxId", "occurredAt"); + +-- AddForeignKey +ALTER TABLE "ContactMessage" ADD CONSTRAINT "ContactMessage_contactInboxId_fkey" FOREIGN KEY ("contactInboxId") REFERENCES "ContactInbox"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContactInbox" ADD CONSTRAINT "ContactInbox_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContactInbox" ADD CONSTRAINT "ContactInbox_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContactInboxPreference" ADD CONSTRAINT "ContactInboxPreference_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContactInboxPreference" ADD CONSTRAINT "ContactInboxPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContactInboxPreference" ADD CONSTRAINT "ContactInboxPreference_contactInboxId_fkey" FOREIGN KEY ("contactInboxId") REFERENCES "ContactInbox"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/frontend/prisma/schema.prisma b/frontend/prisma/schema.prisma index da6d02f..daab2a8 100644 --- a/frontend/prisma/schema.prisma +++ b/frontend/prisma/schema.prisma @@ -79,6 +79,8 @@ model Team { feedCards FeedCard[] contactPins ContactPin[] documents WorkspaceDocument[] + contactInboxes ContactInbox[] + contactInboxPreferences ContactInboxPreference[] } model User { @@ -93,6 +95,7 @@ model User { memberships TeamMember[] aiConversations AiConversation[] @relation("ConversationCreator") aiMessages AiMessage[] @relation("ChatAuthor") + contactInboxPreferences ContactInboxPreference[] } model TeamMember { @@ -133,6 +136,7 @@ model Contact { omniThreads OmniThread[] omniMessages OmniMessage[] omniIdentities OmniContactIdentity[] + contactInboxes ContactInbox[] @@index([teamId, updatedAt]) } @@ -150,6 +154,7 @@ model ContactNote { model ContactMessage { id String @id @default(cuid()) contactId String + contactInboxId String? kind ContactMessageKind @default(MESSAGE) direction MessageDirection channel MessageChannel @@ -160,9 +165,48 @@ model ContactMessage { occurredAt DateTime @default(now()) createdAt DateTime @default(now()) - contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + contactInbox ContactInbox? @relation(fields: [contactInboxId], references: [id], onDelete: SetNull) @@index([contactId, occurredAt]) + @@index([contactInboxId, occurredAt]) +} + +model ContactInbox { + id String @id @default(cuid()) + teamId String + contactId String + channel MessageChannel + sourceExternalId String + title String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + messages ContactMessage[] + preferences ContactInboxPreference[] + + @@unique([teamId, channel, sourceExternalId]) + @@index([contactId, updatedAt]) + @@index([teamId, updatedAt]) +} + +model ContactInboxPreference { + id String @id @default(cuid()) + teamId String + userId String + contactInboxId String + isHidden Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + contactInbox ContactInbox @relation(fields: [contactInboxId], references: [id], onDelete: Cascade) + + @@unique([userId, contactInboxId]) + @@index([teamId, userId, isHidden]) } model OmniContactIdentity { diff --git a/frontend/server/agent/langgraphCrmAgent.ts b/frontend/server/agent/langgraphCrmAgent.ts index 59ee8cc..924cd18 100644 --- a/frontend/server/agent/langgraphCrmAgent.ts +++ b/frontend/server/agent/langgraphCrmAgent.ts @@ -97,6 +97,7 @@ type SnapshotOptions = { teamId: string; contact?: string; contactsLimit?: number; + messageWhere?: any; }; function makeId(prefix: string) { @@ -136,6 +137,7 @@ async function buildCrmSnapshot(input: SnapshotOptions) { include: { note: { select: { content: true, updatedAt: true } }, messages: { + where: input.messageWhere, select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true }, orderBy: { occurredAt: "desc" }, take: 4, @@ -389,6 +391,23 @@ export async function runLangGraphCrmAgentFor(input: { // Keep the dataset fresh so the "CRM filesystem" stays in sync with DB. await ensureDataset({ teamId: input.teamId, userId: input.userId }); + const hiddenInboxRows = await prisma.contactInboxPreference.findMany({ + where: { + teamId: input.teamId, + userId: input.userId, + isHidden: true, + }, + select: { contactInboxId: true }, + }); + const hiddenInboxIds = hiddenInboxRows.map((row) => row.contactInboxId); + const visibleContactMessageWhere = hiddenInboxIds.length + ? { + OR: [ + { contactInboxId: null }, + { contactInboxId: { notIn: hiddenInboxIds } }, + ], + } + : undefined; const toolsUsed: string[] = []; const dbWrites: Array<{ kind: string; detail: string }> = []; @@ -542,10 +561,11 @@ export async function runLangGraphCrmAgentFor(input: { take: limit, include: { note: { select: { content: true, updatedAt: true } }, - messages: { - select: { occurredAt: true, channel: true, direction: true, kind: true, content: true }, - orderBy: { occurredAt: "desc" }, - take: 1, + messages: { + where: visibleContactMessageWhere, + select: { occurredAt: true, channel: true, direction: true, kind: true, content: true }, + orderBy: { occurredAt: "desc" }, + take: 1, }, events: { select: { id: true, title: true, startsAt: true, endsAt: true, isArchived: true }, @@ -645,10 +665,11 @@ export async function runLangGraphCrmAgentFor(input: { where: { id: target.id, teamId: input.teamId }, include: { note: { select: { content: true, updatedAt: true } }, - messages: { - select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true, durationSec: true, transcriptJson: true }, - orderBy: { occurredAt: "desc" }, - take: messagesLimit, + messages: { + where: visibleContactMessageWhere, + select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true, durationSec: true, transcriptJson: true }, + orderBy: { occurredAt: "desc" }, + take: messagesLimit, }, events: { select: { id: true, title: true, startsAt: true, endsAt: true, note: true, isArchived: true }, @@ -1098,6 +1119,7 @@ export async function runLangGraphCrmAgentFor(input: { const snapshot = await buildCrmSnapshot({ teamId: input.teamId, + messageWhere: visibleContactMessageWhere, ...(focusedContact ? { contact: focusedContact } : {}), }); const snapshotJson = JSON.stringify(snapshot, null, 2); diff --git a/frontend/server/dataset/exporter.ts b/frontend/server/dataset/exporter.ts index 753bcd8..49c1f56 100644 --- a/frontend/server/dataset/exporter.ts +++ b/frontend/server/dataset/exporter.ts @@ -27,6 +27,23 @@ export async function exportDatasetFromPrisma() { export async function exportDatasetFromPrismaFor(input: { teamId: string; userId: string }) { const root = datasetRoot(input); const tmp = root + ".tmp"; + const hiddenRows = await prisma.contactInboxPreference.findMany({ + where: { + teamId: input.teamId, + userId: input.userId, + isHidden: true, + }, + select: { contactInboxId: true }, + }); + const hiddenInboxIds = hiddenRows.map((row) => row.contactInboxId); + const messageWhere = hiddenInboxIds.length + ? { + OR: [ + { contactInboxId: null }, + { contactInboxId: { notIn: hiddenInboxIds } }, + ], + } + : undefined; await fs.rm(tmp, { recursive: true, force: true }); await ensureDir(tmp); @@ -50,6 +67,7 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId include: { note: { select: { content: true, updatedAt: true } }, messages: { + where: messageWhere, select: { kind: true, direction: true, diff --git a/frontend/server/graphql/schema.ts b/frontend/server/graphql/schema.ts index f7eef95..f58564a 100644 --- a/frontend/server/graphql/schema.ts +++ b/frontend/server/graphql/schema.ts @@ -1,4 +1,5 @@ import { buildSchema } from "graphql"; +import fs from "node:fs/promises"; import type { H3Event } from "h3"; import type { AuthContext } from "../utils/auth"; import { clearAuthSession, setSession } from "../utils/auth"; @@ -8,6 +9,7 @@ import { persistAiMessage, runCrmAgentFor } from "../agent/crmAgent"; import { buildChangeSet, captureSnapshot, rollbackChangeSet, rollbackChangeSetItems } from "../utils/changeSet"; import type { ChangeSet } from "../utils/changeSet"; import { enqueueTelegramSend } from "../queues/telegramSend"; +import { datasetRoot } from "../dataset/paths"; type GraphQLContext = { auth: AuthContext | null; @@ -62,6 +64,52 @@ function extractOmniNormalizedText(rawJson: unknown, fallbackText = "") { ); } +function normalizeSourceExternalId(channel: string, sourceExternalId: string | null | undefined) { + const raw = String(sourceExternalId ?? "").trim(); + if (raw) return raw; + return `${channel.toLowerCase()}:unknown`; +} + +function visibleMessageWhere(hiddenInboxIds: string[]) { + if (!hiddenInboxIds.length) return undefined; + return { + OR: [ + { contactInboxId: null }, + { contactInboxId: { notIn: hiddenInboxIds } }, + ], + }; +} + +async function upsertContactInbox(input: { + teamId: string; + contactId: string; + channel: "TELEGRAM" | "WHATSAPP" | "INSTAGRAM" | "PHONE" | "EMAIL" | "INTERNAL"; + sourceExternalId: string; + title?: string | null; +}) { + return prisma.contactInbox.upsert({ + where: { + teamId_channel_sourceExternalId: { + teamId: input.teamId, + channel: input.channel, + sourceExternalId: normalizeSourceExternalId(input.channel, input.sourceExternalId), + }, + }, + create: { + teamId: input.teamId, + contactId: input.contactId, + channel: input.channel, + sourceExternalId: normalizeSourceExternalId(input.channel, input.sourceExternalId), + title: (input.title ?? "").trim() || null, + }, + update: { + contactId: input.contactId, + title: (input.title ?? "").trim() || undefined, + }, + select: { id: true }, + }); +} + async function loginWithPassword(event: H3Event, phoneInput: string, passwordInput: string) { const phone = normalizePhone(phoneInput); const password = (passwordInput ?? "").trim(); @@ -322,10 +370,21 @@ async function getDashboard(auth: AuthContext | null) { const ctx = requireAuth(auth); const from = new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); const to = new Date(Date.now() + 1000 * 60 * 60 * 24 * 60); + const hiddenPrefRows = await prisma.contactInboxPreference.findMany({ + where: { + teamId: ctx.teamId, + userId: ctx.userId, + isHidden: true, + }, + select: { contactInboxId: true }, + }); + const hiddenInboxIds = hiddenPrefRows.map((row) => row.contactInboxId); + const messageWhere = visibleMessageWhere(hiddenInboxIds); const [ contactsRaw, communicationsRaw, + contactInboxesRaw, calendarRaw, dealsRaw, feedRaw, @@ -342,10 +401,30 @@ async function getDashboard(auth: AuthContext | null) { take: 500, }), prisma.contactMessage.findMany({ - where: { contact: { teamId: ctx.teamId } }, + where: { + contact: { teamId: ctx.teamId }, + ...(messageWhere ?? {}), + }, orderBy: { occurredAt: "asc" }, take: 2000, - include: { contact: { select: { id: true, name: true } } }, + include: { + contact: { select: { id: true, name: true } }, + contactInbox: { select: { id: true, sourceExternalId: true, title: true } }, + }, + }), + prisma.contactInbox.findMany({ + where: { teamId: ctx.teamId }, + orderBy: { updatedAt: "desc" }, + include: { + contact: { select: { name: true } }, + messages: { + where: messageWhere, + select: { occurredAt: true }, + orderBy: { occurredAt: "desc" }, + take: 1, + }, + }, + take: 5000, }), prisma.calendarEvent.findMany({ where: { teamId: ctx.teamId, startsAt: { gte: from, lte: to } }, @@ -425,7 +504,21 @@ async function getDashboard(auth: AuthContext | null) { }); } + const hiddenInboxIdSet = new Set(hiddenInboxIds); const channelsByContactId = new Map>(); + const totalInboxesByContactId = new Map(); + const visibleInboxesByContactId = new Map(); + + for (const inbox of contactInboxesRaw) { + totalInboxesByContactId.set(inbox.contactId, (totalInboxesByContactId.get(inbox.contactId) ?? 0) + 1); + if (hiddenInboxIdSet.has(inbox.id)) continue; + visibleInboxesByContactId.set(inbox.contactId, (visibleInboxesByContactId.get(inbox.contactId) ?? 0) + 1); + if (!channelsByContactId.has(inbox.contactId)) { + channelsByContactId.set(inbox.contactId, new Set()); + } + channelsByContactId.get(inbox.contactId)?.add(mapChannel(inbox.channel)); + } + for (const item of communicationsRaw) { if (!channelsByContactId.has(item.contactId)) { channelsByContactId.set(item.contactId, new Set()); @@ -433,17 +526,23 @@ async function getDashboard(auth: AuthContext | null) { channelsByContactId.get(item.contactId)?.add(mapChannel(item.channel)); } - const contacts = contactsRaw.map((c) => ({ - id: c.id, - name: c.name, - avatar: c.avatarUrl ?? "", - company: c.company ?? "", - country: c.country ?? "", - location: c.location ?? "", - channels: Array.from(channelsByContactId.get(c.id) ?? []), - lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(), - description: c.note?.content ?? "", - })); + const contacts = contactsRaw + .filter((c) => { + const total = totalInboxesByContactId.get(c.id) ?? 0; + if (total === 0) return true; + return (visibleInboxesByContactId.get(c.id) ?? 0) > 0; + }) + .map((c) => ({ + id: c.id, + name: c.name, + avatar: c.avatarUrl ?? "", + company: c.company ?? "", + country: c.country ?? "", + location: c.location ?? "", + channels: Array.from(channelsByContactId.get(c.id) ?? []), + lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(), + description: c.note?.content ?? "", + })); const omniByKey = new Map(); for (const row of omniMessagesRaw) { @@ -495,6 +594,9 @@ async function getDashboard(auth: AuthContext | null) { at: m.occurredAt.toISOString(), contactId: m.contactId, contact: m.contact.name, + contactInboxId: m.contactInboxId ?? "", + sourceExternalId: m.contactInbox?.sourceExternalId ?? "", + sourceTitle: m.contactInbox?.title ?? "", channel: mapChannel(m.channel), kind: m.kind === "CALL" ? "call" : "message", direction: m.direction === "IN" ? "in" : "out", @@ -505,6 +607,19 @@ async function getDashboard(auth: AuthContext | null) { deliveryStatus: resolveDeliveryStatus(m), })); + const contactInboxes = contactInboxesRaw + .map((inbox) => ({ + id: inbox.id, + contactId: inbox.contactId, + contactName: inbox.contact.name, + channel: mapChannel(inbox.channel), + sourceExternalId: inbox.sourceExternalId, + title: inbox.title ?? "", + isHidden: hiddenInboxIdSet.has(inbox.id), + lastMessageAt: inbox.messages[0]?.occurredAt?.toISOString?.() ?? "", + updatedAt: inbox.updatedAt.toISOString(), + })); + const calendar = calendarRaw.map((e) => ({ id: e.id, title: e.title, @@ -573,6 +688,7 @@ async function getDashboard(auth: AuthContext | null) { return { contacts, communications, + contactInboxes, calendar, deals, feed, @@ -703,6 +819,7 @@ async function createCommunication(auth: AuthContext | null, input: { const direction = input?.direction === "in" ? "IN" : "OUT"; const channel = toDbChannel(input?.channel ?? "Phone") as any; const content = (input?.text ?? "").trim(); + let contactInboxId: string | null = null; if (kind === "MESSAGE" && channel === "TELEGRAM" && direction === "OUT") { const thread = await prisma.omniThread.findFirst({ @@ -712,12 +829,21 @@ async function createCommunication(auth: AuthContext | null, input: { channel: "TELEGRAM", }, orderBy: { updatedAt: "desc" }, - select: { id: true }, + select: { id: true, externalChatId: true, title: true }, }); if (!thread) { throw new Error("telegram thread not found for contact"); } + const inbox = await upsertContactInbox({ + teamId: ctx.teamId, + contactId: contact.id, + channel: "TELEGRAM", + sourceExternalId: thread.externalChatId, + title: thread.title ?? null, + }); + contactInboxId = inbox.id; + const omniMessage = await prisma.omniMessage.create({ data: { teamId: ctx.teamId, @@ -775,11 +901,23 @@ async function createCommunication(auth: AuthContext | null, input: { }).catch(() => undefined); throw new Error(`telegram enqueue failed: ${message}`); } + } else { + const existingInbox = await prisma.contactInbox.findFirst({ + where: { + teamId: ctx.teamId, + contactId: contact.id, + channel, + }, + orderBy: { updatedAt: "desc" }, + select: { id: true }, + }); + contactInboxId = existingInbox?.id ?? null; } const created = await prisma.contactMessage.create({ data: { contactId: contact.id, + contactInboxId, kind, direction, channel, @@ -835,6 +973,50 @@ async function createWorkspaceDocument(auth: AuthContext | null, input: { }; } +async function setContactInboxHidden( + auth: AuthContext | null, + input: { inboxId: string; hidden: boolean }, +) { + const ctx = requireAuth(auth); + const inboxId = String(input?.inboxId ?? "").trim(); + if (!inboxId) throw new Error("inboxId is required"); + + const inbox = await prisma.contactInbox.findFirst({ + where: { + id: inboxId, + teamId: ctx.teamId, + }, + select: { id: true }, + }); + if (!inbox) throw new Error("inbox not found"); + + const hidden = Boolean(input?.hidden); + await prisma.contactInboxPreference.upsert({ + where: { + userId_contactInboxId: { + userId: ctx.userId, + contactInboxId: inbox.id, + }, + }, + create: { + teamId: ctx.teamId, + userId: ctx.userId, + contactInboxId: inbox.id, + isHidden: hidden, + }, + update: { + isHidden: hidden, + }, + }); + + await fs.rm(datasetRoot({ teamId: ctx.teamId, userId: ctx.userId }), { + recursive: true, + force: true, + }).catch(() => undefined); + + return { ok: true }; +} + async function updateCommunicationTranscript(auth: AuthContext | null, id: string, transcript: string[]) { const ctx = requireAuth(auth); const messageId = String(id ?? "").trim(); @@ -1189,6 +1371,7 @@ export const crmGraphqlSchema = buildSchema(` createWorkspaceDocument(input: CreateWorkspaceDocumentInput!): WorkspaceDocument! updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult! updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult! + setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult! } type MutationResult { @@ -1307,6 +1490,7 @@ export const crmGraphqlSchema = buildSchema(` type DashboardPayload { contacts: [Contact!]! communications: [CommItem!]! + contactInboxes: [ContactInbox!]! calendar: [CalendarEvent!]! deals: [Deal!]! feed: [FeedCard!]! @@ -1331,6 +1515,9 @@ export const crmGraphqlSchema = buildSchema(` at: String! contactId: String! contact: String! + contactInboxId: String! + sourceExternalId: String! + sourceTitle: String! channel: String! kind: String! direction: String! @@ -1341,6 +1528,18 @@ export const crmGraphqlSchema = buildSchema(` deliveryStatus: String } + type ContactInbox { + id: ID! + contactId: String! + contactName: String! + channel: String! + sourceExternalId: String! + title: String! + isHidden: Boolean! + lastMessageAt: String! + updatedAt: String! + } + type CalendarEvent { id: ID! title: String! @@ -1499,4 +1698,9 @@ export const crmGraphqlRoot = { args: { id: string; decision: "accepted" | "rejected" | "pending"; decisionNote?: string }, context: GraphQLContext, ) => updateFeedDecision(context.auth, args.id, args.decision, args.decisionNote), + + setContactInboxHidden: async ( + args: { inboxId: string; hidden: boolean }, + context: GraphQLContext, + ) => setContactInboxHidden(context.auth, args), }; diff --git a/frontend/server/routes/ws/crm-updates.ts b/frontend/server/routes/ws/crm-updates.ts index 900b23c..cdd258e 100644 --- a/frontend/server/routes/ws/crm-updates.ts +++ b/frontend/server/routes/ws/crm-updates.ts @@ -77,7 +77,7 @@ async function validateSessionFromPeer(peer: any) { } async function computeTeamSignature(teamId: string) { - const [omniMessageMax, contactMax, contactMessageMax, telegramConnectionMax] = await Promise.all([ + const [omniMessageMax, contactMax, contactMessageMax, telegramConnectionMax, contactInboxMax, inboxPrefMax] = await Promise.all([ prisma.omniMessage.aggregate({ where: { teamId }, _max: { updatedAt: true }, @@ -94,6 +94,14 @@ async function computeTeamSignature(teamId: string) { where: { teamId }, _max: { updatedAt: true }, }), + prisma.contactInbox.aggregate({ + where: { teamId }, + _max: { updatedAt: true }, + }), + prisma.contactInboxPreference.aggregate({ + where: { teamId }, + _max: { updatedAt: true }, + }), ]); return [ @@ -101,6 +109,8 @@ async function computeTeamSignature(teamId: string) { contactMax._max.updatedAt?.toISOString() ?? "", contactMessageMax._max.createdAt?.toISOString() ?? "", telegramConnectionMax._max.updatedAt?.toISOString() ?? "", + contactInboxMax._max.updatedAt?.toISOString() ?? "", + inboxPrefMax._max.updatedAt?.toISOString() ?? "", ].join("|"); } diff --git a/omni_chat/prisma/schema.prisma b/omni_chat/prisma/schema.prisma index da6d02f..daab2a8 100644 --- a/omni_chat/prisma/schema.prisma +++ b/omni_chat/prisma/schema.prisma @@ -79,6 +79,8 @@ model Team { feedCards FeedCard[] contactPins ContactPin[] documents WorkspaceDocument[] + contactInboxes ContactInbox[] + contactInboxPreferences ContactInboxPreference[] } model User { @@ -93,6 +95,7 @@ model User { memberships TeamMember[] aiConversations AiConversation[] @relation("ConversationCreator") aiMessages AiMessage[] @relation("ChatAuthor") + contactInboxPreferences ContactInboxPreference[] } model TeamMember { @@ -133,6 +136,7 @@ model Contact { omniThreads OmniThread[] omniMessages OmniMessage[] omniIdentities OmniContactIdentity[] + contactInboxes ContactInbox[] @@index([teamId, updatedAt]) } @@ -150,6 +154,7 @@ model ContactNote { model ContactMessage { id String @id @default(cuid()) contactId String + contactInboxId String? kind ContactMessageKind @default(MESSAGE) direction MessageDirection channel MessageChannel @@ -160,9 +165,48 @@ model ContactMessage { occurredAt DateTime @default(now()) createdAt DateTime @default(now()) - contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + contactInbox ContactInbox? @relation(fields: [contactInboxId], references: [id], onDelete: SetNull) @@index([contactId, occurredAt]) + @@index([contactInboxId, occurredAt]) +} + +model ContactInbox { + id String @id @default(cuid()) + teamId String + contactId String + channel MessageChannel + sourceExternalId String + title String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + messages ContactMessage[] + preferences ContactInboxPreference[] + + @@unique([teamId, channel, sourceExternalId]) + @@index([contactId, updatedAt]) + @@index([teamId, updatedAt]) +} + +model ContactInboxPreference { + id String @id @default(cuid()) + teamId String + userId String + contactInboxId String + isHidden Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + contactInbox ContactInbox @relation(fields: [contactInboxId], references: [id], onDelete: Cascade) + + @@unique([userId, contactInboxId]) + @@index([teamId, userId, isHidden]) } model OmniContactIdentity { diff --git a/omni_chat/src/worker.ts b/omni_chat/src/worker.ts index 74b3505..c98d78e 100644 --- a/omni_chat/src/worker.ts +++ b/omni_chat/src/worker.ts @@ -242,7 +242,7 @@ async function upsertThread(input: { if (existing) { const data: Prisma.OmniThreadUpdateInput = { - contactId: input.contactId, + contact: { connect: { id: input.contactId } }, }; if (input.title && !existing.title) { data.title = input.title; @@ -283,12 +283,42 @@ async function upsertThread(input: { await prisma.omniThread.update({ where: { id: concurrentThread.id }, - data: { contactId: input.contactId }, + data: { contact: { connect: { id: input.contactId } } }, }); return concurrentThread; } } +async function upsertContactInbox(input: { + teamId: string; + contactId: string; + channel: "TELEGRAM"; + sourceExternalId: string; + title: string | null; +}) { + return prisma.contactInbox.upsert({ + where: { + teamId_channel_sourceExternalId: { + teamId: input.teamId, + channel: input.channel, + sourceExternalId: input.sourceExternalId, + }, + }, + create: { + teamId: input.teamId, + contactId: input.contactId, + channel: input.channel, + sourceExternalId: input.sourceExternalId, + title: input.title, + }, + update: { + contactId: input.contactId, + ...(input.title ? { title: input.title } : {}), + }, + select: { id: true }, + }); +} + async function ingestInbound(env: OmniInboundEnvelopeV1) { if (env.channel !== "TELEGRAM") return; @@ -325,6 +355,13 @@ async function ingestInbound(env: OmniInboundEnvelopeV1) { businessConnectionId, title: asString(n.chatTitle), }); + const inbox = await upsertContactInbox({ + teamId, + contactId, + channel: "TELEGRAM", + sourceExternalId: externalChatId, + title: asString(n.chatTitle), + }); const rawEnvelope = { version: env.version, source: "omni_chat.receiver", @@ -337,7 +374,7 @@ async function ingestInbound(env: OmniInboundEnvelopeV1) { normalized: { text, threadExternalId: externalChatId, - contactExternalId, + contactExternalId: externalContactId, businessConnectionId, }, payloadNormalized: n, @@ -393,6 +430,7 @@ async function ingestInbound(env: OmniInboundEnvelopeV1) { await prisma.contactMessage.create({ data: { contactId, + contactInboxId: inbox.id, kind: "MESSAGE", direction, channel: "TELEGRAM", diff --git a/omni_outbound/prisma/schema.prisma b/omni_outbound/prisma/schema.prisma index da6d02f..daab2a8 100644 --- a/omni_outbound/prisma/schema.prisma +++ b/omni_outbound/prisma/schema.prisma @@ -79,6 +79,8 @@ model Team { feedCards FeedCard[] contactPins ContactPin[] documents WorkspaceDocument[] + contactInboxes ContactInbox[] + contactInboxPreferences ContactInboxPreference[] } model User { @@ -93,6 +95,7 @@ model User { memberships TeamMember[] aiConversations AiConversation[] @relation("ConversationCreator") aiMessages AiMessage[] @relation("ChatAuthor") + contactInboxPreferences ContactInboxPreference[] } model TeamMember { @@ -133,6 +136,7 @@ model Contact { omniThreads OmniThread[] omniMessages OmniMessage[] omniIdentities OmniContactIdentity[] + contactInboxes ContactInbox[] @@index([teamId, updatedAt]) } @@ -150,6 +154,7 @@ model ContactNote { model ContactMessage { id String @id @default(cuid()) contactId String + contactInboxId String? kind ContactMessageKind @default(MESSAGE) direction MessageDirection channel MessageChannel @@ -160,9 +165,48 @@ model ContactMessage { occurredAt DateTime @default(now()) createdAt DateTime @default(now()) - contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + contactInbox ContactInbox? @relation(fields: [contactInboxId], references: [id], onDelete: SetNull) @@index([contactId, occurredAt]) + @@index([contactInboxId, occurredAt]) +} + +model ContactInbox { + id String @id @default(cuid()) + teamId String + contactId String + channel MessageChannel + sourceExternalId String + title String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + messages ContactMessage[] + preferences ContactInboxPreference[] + + @@unique([teamId, channel, sourceExternalId]) + @@index([contactId, updatedAt]) + @@index([teamId, updatedAt]) +} + +model ContactInboxPreference { + id String @id @default(cuid()) + teamId String + userId String + contactInboxId String + isHidden Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + contactInbox ContactInbox @relation(fields: [contactInboxId], references: [id], onDelete: Cascade) + + @@unique([userId, contactInboxId]) + @@index([teamId, userId, isHidden]) } model OmniContactIdentity {