feat(chat): add contact inbox sources with per-user hide filters

This commit is contained in:
Ruslan Bakiev
2026-02-23 10:41:02 +07:00
parent 6bc154a1e6
commit 95fd9a64ce
11 changed files with 538 additions and 29 deletions

View File

@@ -16,6 +16,9 @@ query DashboardQuery {
at at
contactId contactId
contact contact
contactInboxId
sourceExternalId
sourceTitle
channel channel
kind kind
direction direction
@@ -25,6 +28,17 @@ query DashboardQuery {
transcript transcript
deliveryStatus deliveryStatus
} }
contactInboxes {
id
contactId
contactName
channel
sourceExternalId
title
isHidden
lastMessageAt
updatedAt
}
calendar { calendar {
id id
title title

View File

@@ -0,0 +1,5 @@
mutation SetContactInboxHidden($inboxId: ID!, $hidden: Boolean!) {
setContactInboxHidden(inboxId: $inboxId, hidden: $hidden) {
ok
}
}

View File

@@ -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;

View File

@@ -79,6 +79,8 @@ model Team {
feedCards FeedCard[] feedCards FeedCard[]
contactPins ContactPin[] contactPins ContactPin[]
documents WorkspaceDocument[] documents WorkspaceDocument[]
contactInboxes ContactInbox[]
contactInboxPreferences ContactInboxPreference[]
} }
model User { model User {
@@ -93,6 +95,7 @@ model User {
memberships TeamMember[] memberships TeamMember[]
aiConversations AiConversation[] @relation("ConversationCreator") aiConversations AiConversation[] @relation("ConversationCreator")
aiMessages AiMessage[] @relation("ChatAuthor") aiMessages AiMessage[] @relation("ChatAuthor")
contactInboxPreferences ContactInboxPreference[]
} }
model TeamMember { model TeamMember {
@@ -133,6 +136,7 @@ model Contact {
omniThreads OmniThread[] omniThreads OmniThread[]
omniMessages OmniMessage[] omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[] omniIdentities OmniContactIdentity[]
contactInboxes ContactInbox[]
@@index([teamId, updatedAt]) @@index([teamId, updatedAt])
} }
@@ -150,6 +154,7 @@ model ContactNote {
model ContactMessage { model ContactMessage {
id String @id @default(cuid()) id String @id @default(cuid())
contactId String contactId String
contactInboxId String?
kind ContactMessageKind @default(MESSAGE) kind ContactMessageKind @default(MESSAGE)
direction MessageDirection direction MessageDirection
channel MessageChannel channel MessageChannel
@@ -161,8 +166,47 @@ model ContactMessage {
createdAt 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([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 { model OmniContactIdentity {

View File

@@ -97,6 +97,7 @@ type SnapshotOptions = {
teamId: string; teamId: string;
contact?: string; contact?: string;
contactsLimit?: number; contactsLimit?: number;
messageWhere?: any;
}; };
function makeId(prefix: string) { function makeId(prefix: string) {
@@ -136,6 +137,7 @@ async function buildCrmSnapshot(input: SnapshotOptions) {
include: { include: {
note: { select: { content: true, updatedAt: true } }, note: { select: { content: true, updatedAt: true } },
messages: { messages: {
where: input.messageWhere,
select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true }, select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true },
orderBy: { occurredAt: "desc" }, orderBy: { occurredAt: "desc" },
take: 4, take: 4,
@@ -389,6 +391,23 @@ export async function runLangGraphCrmAgentFor(input: {
// Keep the dataset fresh so the "CRM filesystem" stays in sync with DB. // Keep the dataset fresh so the "CRM filesystem" stays in sync with DB.
await ensureDataset({ teamId: input.teamId, userId: input.userId }); 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 toolsUsed: string[] = [];
const dbWrites: Array<{ kind: string; detail: string }> = []; const dbWrites: Array<{ kind: string; detail: string }> = [];
@@ -543,6 +562,7 @@ export async function runLangGraphCrmAgentFor(input: {
include: { include: {
note: { select: { content: true, updatedAt: true } }, note: { select: { content: true, updatedAt: true } },
messages: { messages: {
where: visibleContactMessageWhere,
select: { occurredAt: true, channel: true, direction: true, kind: true, content: true }, select: { occurredAt: true, channel: true, direction: true, kind: true, content: true },
orderBy: { occurredAt: "desc" }, orderBy: { occurredAt: "desc" },
take: 1, take: 1,
@@ -646,6 +666,7 @@ export async function runLangGraphCrmAgentFor(input: {
include: { include: {
note: { select: { content: true, updatedAt: true } }, note: { select: { content: true, updatedAt: true } },
messages: { messages: {
where: visibleContactMessageWhere,
select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true, durationSec: true, transcriptJson: true }, select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true, durationSec: true, transcriptJson: true },
orderBy: { occurredAt: "desc" }, orderBy: { occurredAt: "desc" },
take: messagesLimit, take: messagesLimit,
@@ -1098,6 +1119,7 @@ export async function runLangGraphCrmAgentFor(input: {
const snapshot = await buildCrmSnapshot({ const snapshot = await buildCrmSnapshot({
teamId: input.teamId, teamId: input.teamId,
messageWhere: visibleContactMessageWhere,
...(focusedContact ? { contact: focusedContact } : {}), ...(focusedContact ? { contact: focusedContact } : {}),
}); });
const snapshotJson = JSON.stringify(snapshot, null, 2); const snapshotJson = JSON.stringify(snapshot, null, 2);

View File

@@ -27,6 +27,23 @@ export async function exportDatasetFromPrisma() {
export async function exportDatasetFromPrismaFor(input: { teamId: string; userId: string }) { export async function exportDatasetFromPrismaFor(input: { teamId: string; userId: string }) {
const root = datasetRoot(input); const root = datasetRoot(input);
const tmp = root + ".tmp"; 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 fs.rm(tmp, { recursive: true, force: true });
await ensureDir(tmp); await ensureDir(tmp);
@@ -50,6 +67,7 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId
include: { include: {
note: { select: { content: true, updatedAt: true } }, note: { select: { content: true, updatedAt: true } },
messages: { messages: {
where: messageWhere,
select: { select: {
kind: true, kind: true,
direction: true, direction: true,

View File

@@ -1,4 +1,5 @@
import { buildSchema } from "graphql"; import { buildSchema } from "graphql";
import fs from "node:fs/promises";
import type { H3Event } from "h3"; import type { H3Event } from "h3";
import type { AuthContext } from "../utils/auth"; import type { AuthContext } from "../utils/auth";
import { clearAuthSession, setSession } 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 { buildChangeSet, captureSnapshot, rollbackChangeSet, rollbackChangeSetItems } from "../utils/changeSet";
import type { ChangeSet } from "../utils/changeSet"; import type { ChangeSet } from "../utils/changeSet";
import { enqueueTelegramSend } from "../queues/telegramSend"; import { enqueueTelegramSend } from "../queues/telegramSend";
import { datasetRoot } from "../dataset/paths";
type GraphQLContext = { type GraphQLContext = {
auth: AuthContext | null; 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) { async function loginWithPassword(event: H3Event, phoneInput: string, passwordInput: string) {
const phone = normalizePhone(phoneInput); const phone = normalizePhone(phoneInput);
const password = (passwordInput ?? "").trim(); const password = (passwordInput ?? "").trim();
@@ -322,10 +370,21 @@ async function getDashboard(auth: AuthContext | null) {
const ctx = requireAuth(auth); const ctx = requireAuth(auth);
const from = new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); const from = new Date(Date.now() - 1000 * 60 * 60 * 24 * 30);
const to = new Date(Date.now() + 1000 * 60 * 60 * 24 * 60); 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 [ const [
contactsRaw, contactsRaw,
communicationsRaw, communicationsRaw,
contactInboxesRaw,
calendarRaw, calendarRaw,
dealsRaw, dealsRaw,
feedRaw, feedRaw,
@@ -342,10 +401,30 @@ async function getDashboard(auth: AuthContext | null) {
take: 500, take: 500,
}), }),
prisma.contactMessage.findMany({ prisma.contactMessage.findMany({
where: { contact: { teamId: ctx.teamId } }, where: {
contact: { teamId: ctx.teamId },
...(messageWhere ?? {}),
},
orderBy: { occurredAt: "asc" }, orderBy: { occurredAt: "asc" },
take: 2000, 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({ prisma.calendarEvent.findMany({
where: { teamId: ctx.teamId, startsAt: { gte: from, lte: to } }, 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<string, Set<string>>(); const channelsByContactId = new Map<string, Set<string>>();
const totalInboxesByContactId = new Map<string, number>();
const visibleInboxesByContactId = new Map<string, number>();
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) { for (const item of communicationsRaw) {
if (!channelsByContactId.has(item.contactId)) { if (!channelsByContactId.has(item.contactId)) {
channelsByContactId.set(item.contactId, new Set()); channelsByContactId.set(item.contactId, new Set());
@@ -433,7 +526,13 @@ async function getDashboard(auth: AuthContext | null) {
channelsByContactId.get(item.contactId)?.add(mapChannel(item.channel)); channelsByContactId.get(item.contactId)?.add(mapChannel(item.channel));
} }
const contacts = contactsRaw.map((c) => ({ 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, id: c.id,
name: c.name, name: c.name,
avatar: c.avatarUrl ?? "", avatar: c.avatarUrl ?? "",
@@ -495,6 +594,9 @@ async function getDashboard(auth: AuthContext | null) {
at: m.occurredAt.toISOString(), at: m.occurredAt.toISOString(),
contactId: m.contactId, contactId: m.contactId,
contact: m.contact.name, contact: m.contact.name,
contactInboxId: m.contactInboxId ?? "",
sourceExternalId: m.contactInbox?.sourceExternalId ?? "",
sourceTitle: m.contactInbox?.title ?? "",
channel: mapChannel(m.channel), channel: mapChannel(m.channel),
kind: m.kind === "CALL" ? "call" : "message", kind: m.kind === "CALL" ? "call" : "message",
direction: m.direction === "IN" ? "in" : "out", direction: m.direction === "IN" ? "in" : "out",
@@ -505,6 +607,19 @@ async function getDashboard(auth: AuthContext | null) {
deliveryStatus: resolveDeliveryStatus(m), 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) => ({ const calendar = calendarRaw.map((e) => ({
id: e.id, id: e.id,
title: e.title, title: e.title,
@@ -573,6 +688,7 @@ async function getDashboard(auth: AuthContext | null) {
return { return {
contacts, contacts,
communications, communications,
contactInboxes,
calendar, calendar,
deals, deals,
feed, feed,
@@ -703,6 +819,7 @@ async function createCommunication(auth: AuthContext | null, input: {
const direction = input?.direction === "in" ? "IN" : "OUT"; const direction = input?.direction === "in" ? "IN" : "OUT";
const channel = toDbChannel(input?.channel ?? "Phone") as any; const channel = toDbChannel(input?.channel ?? "Phone") as any;
const content = (input?.text ?? "").trim(); const content = (input?.text ?? "").trim();
let contactInboxId: string | null = null;
if (kind === "MESSAGE" && channel === "TELEGRAM" && direction === "OUT") { if (kind === "MESSAGE" && channel === "TELEGRAM" && direction === "OUT") {
const thread = await prisma.omniThread.findFirst({ const thread = await prisma.omniThread.findFirst({
@@ -712,12 +829,21 @@ async function createCommunication(auth: AuthContext | null, input: {
channel: "TELEGRAM", channel: "TELEGRAM",
}, },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
select: { id: true }, select: { id: true, externalChatId: true, title: true },
}); });
if (!thread) { if (!thread) {
throw new Error("telegram thread not found for contact"); 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({ const omniMessage = await prisma.omniMessage.create({
data: { data: {
teamId: ctx.teamId, teamId: ctx.teamId,
@@ -775,11 +901,23 @@ async function createCommunication(auth: AuthContext | null, input: {
}).catch(() => undefined); }).catch(() => undefined);
throw new Error(`telegram enqueue failed: ${message}`); 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({ const created = await prisma.contactMessage.create({
data: { data: {
contactId: contact.id, contactId: contact.id,
contactInboxId,
kind, kind,
direction, direction,
channel, 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[]) { async function updateCommunicationTranscript(auth: AuthContext | null, id: string, transcript: string[]) {
const ctx = requireAuth(auth); const ctx = requireAuth(auth);
const messageId = String(id ?? "").trim(); const messageId = String(id ?? "").trim();
@@ -1189,6 +1371,7 @@ export const crmGraphqlSchema = buildSchema(`
createWorkspaceDocument(input: CreateWorkspaceDocumentInput!): WorkspaceDocument! createWorkspaceDocument(input: CreateWorkspaceDocumentInput!): WorkspaceDocument!
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult! updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult! updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
} }
type MutationResult { type MutationResult {
@@ -1307,6 +1490,7 @@ export const crmGraphqlSchema = buildSchema(`
type DashboardPayload { type DashboardPayload {
contacts: [Contact!]! contacts: [Contact!]!
communications: [CommItem!]! communications: [CommItem!]!
contactInboxes: [ContactInbox!]!
calendar: [CalendarEvent!]! calendar: [CalendarEvent!]!
deals: [Deal!]! deals: [Deal!]!
feed: [FeedCard!]! feed: [FeedCard!]!
@@ -1331,6 +1515,9 @@ export const crmGraphqlSchema = buildSchema(`
at: String! at: String!
contactId: String! contactId: String!
contact: String! contact: String!
contactInboxId: String!
sourceExternalId: String!
sourceTitle: String!
channel: String! channel: String!
kind: String! kind: String!
direction: String! direction: String!
@@ -1341,6 +1528,18 @@ export const crmGraphqlSchema = buildSchema(`
deliveryStatus: String deliveryStatus: String
} }
type ContactInbox {
id: ID!
contactId: String!
contactName: String!
channel: String!
sourceExternalId: String!
title: String!
isHidden: Boolean!
lastMessageAt: String!
updatedAt: String!
}
type CalendarEvent { type CalendarEvent {
id: ID! id: ID!
title: String! title: String!
@@ -1499,4 +1698,9 @@ export const crmGraphqlRoot = {
args: { id: string; decision: "accepted" | "rejected" | "pending"; decisionNote?: string }, args: { id: string; decision: "accepted" | "rejected" | "pending"; decisionNote?: string },
context: GraphQLContext, context: GraphQLContext,
) => updateFeedDecision(context.auth, args.id, args.decision, args.decisionNote), ) => updateFeedDecision(context.auth, args.id, args.decision, args.decisionNote),
setContactInboxHidden: async (
args: { inboxId: string; hidden: boolean },
context: GraphQLContext,
) => setContactInboxHidden(context.auth, args),
}; };

View File

@@ -77,7 +77,7 @@ async function validateSessionFromPeer(peer: any) {
} }
async function computeTeamSignature(teamId: string) { 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({ prisma.omniMessage.aggregate({
where: { teamId }, where: { teamId },
_max: { updatedAt: true }, _max: { updatedAt: true },
@@ -94,6 +94,14 @@ async function computeTeamSignature(teamId: string) {
where: { teamId }, where: { teamId },
_max: { updatedAt: true }, _max: { updatedAt: true },
}), }),
prisma.contactInbox.aggregate({
where: { teamId },
_max: { updatedAt: true },
}),
prisma.contactInboxPreference.aggregate({
where: { teamId },
_max: { updatedAt: true },
}),
]); ]);
return [ return [
@@ -101,6 +109,8 @@ async function computeTeamSignature(teamId: string) {
contactMax._max.updatedAt?.toISOString() ?? "", contactMax._max.updatedAt?.toISOString() ?? "",
contactMessageMax._max.createdAt?.toISOString() ?? "", contactMessageMax._max.createdAt?.toISOString() ?? "",
telegramConnectionMax._max.updatedAt?.toISOString() ?? "", telegramConnectionMax._max.updatedAt?.toISOString() ?? "",
contactInboxMax._max.updatedAt?.toISOString() ?? "",
inboxPrefMax._max.updatedAt?.toISOString() ?? "",
].join("|"); ].join("|");
} }

View File

@@ -79,6 +79,8 @@ model Team {
feedCards FeedCard[] feedCards FeedCard[]
contactPins ContactPin[] contactPins ContactPin[]
documents WorkspaceDocument[] documents WorkspaceDocument[]
contactInboxes ContactInbox[]
contactInboxPreferences ContactInboxPreference[]
} }
model User { model User {
@@ -93,6 +95,7 @@ model User {
memberships TeamMember[] memberships TeamMember[]
aiConversations AiConversation[] @relation("ConversationCreator") aiConversations AiConversation[] @relation("ConversationCreator")
aiMessages AiMessage[] @relation("ChatAuthor") aiMessages AiMessage[] @relation("ChatAuthor")
contactInboxPreferences ContactInboxPreference[]
} }
model TeamMember { model TeamMember {
@@ -133,6 +136,7 @@ model Contact {
omniThreads OmniThread[] omniThreads OmniThread[]
omniMessages OmniMessage[] omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[] omniIdentities OmniContactIdentity[]
contactInboxes ContactInbox[]
@@index([teamId, updatedAt]) @@index([teamId, updatedAt])
} }
@@ -150,6 +154,7 @@ model ContactNote {
model ContactMessage { model ContactMessage {
id String @id @default(cuid()) id String @id @default(cuid())
contactId String contactId String
contactInboxId String?
kind ContactMessageKind @default(MESSAGE) kind ContactMessageKind @default(MESSAGE)
direction MessageDirection direction MessageDirection
channel MessageChannel channel MessageChannel
@@ -161,8 +166,47 @@ model ContactMessage {
createdAt 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([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 { model OmniContactIdentity {

View File

@@ -242,7 +242,7 @@ async function upsertThread(input: {
if (existing) { if (existing) {
const data: Prisma.OmniThreadUpdateInput = { const data: Prisma.OmniThreadUpdateInput = {
contactId: input.contactId, contact: { connect: { id: input.contactId } },
}; };
if (input.title && !existing.title) { if (input.title && !existing.title) {
data.title = input.title; data.title = input.title;
@@ -283,12 +283,42 @@ async function upsertThread(input: {
await prisma.omniThread.update({ await prisma.omniThread.update({
where: { id: concurrentThread.id }, where: { id: concurrentThread.id },
data: { contactId: input.contactId }, data: { contact: { connect: { id: input.contactId } } },
}); });
return concurrentThread; 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) { async function ingestInbound(env: OmniInboundEnvelopeV1) {
if (env.channel !== "TELEGRAM") return; if (env.channel !== "TELEGRAM") return;
@@ -325,6 +355,13 @@ async function ingestInbound(env: OmniInboundEnvelopeV1) {
businessConnectionId, businessConnectionId,
title: asString(n.chatTitle), title: asString(n.chatTitle),
}); });
const inbox = await upsertContactInbox({
teamId,
contactId,
channel: "TELEGRAM",
sourceExternalId: externalChatId,
title: asString(n.chatTitle),
});
const rawEnvelope = { const rawEnvelope = {
version: env.version, version: env.version,
source: "omni_chat.receiver", source: "omni_chat.receiver",
@@ -337,7 +374,7 @@ async function ingestInbound(env: OmniInboundEnvelopeV1) {
normalized: { normalized: {
text, text,
threadExternalId: externalChatId, threadExternalId: externalChatId,
contactExternalId, contactExternalId: externalContactId,
businessConnectionId, businessConnectionId,
}, },
payloadNormalized: n, payloadNormalized: n,
@@ -393,6 +430,7 @@ async function ingestInbound(env: OmniInboundEnvelopeV1) {
await prisma.contactMessage.create({ await prisma.contactMessage.create({
data: { data: {
contactId, contactId,
contactInboxId: inbox.id,
kind: "MESSAGE", kind: "MESSAGE",
direction, direction,
channel: "TELEGRAM", channel: "TELEGRAM",

View File

@@ -79,6 +79,8 @@ model Team {
feedCards FeedCard[] feedCards FeedCard[]
contactPins ContactPin[] contactPins ContactPin[]
documents WorkspaceDocument[] documents WorkspaceDocument[]
contactInboxes ContactInbox[]
contactInboxPreferences ContactInboxPreference[]
} }
model User { model User {
@@ -93,6 +95,7 @@ model User {
memberships TeamMember[] memberships TeamMember[]
aiConversations AiConversation[] @relation("ConversationCreator") aiConversations AiConversation[] @relation("ConversationCreator")
aiMessages AiMessage[] @relation("ChatAuthor") aiMessages AiMessage[] @relation("ChatAuthor")
contactInboxPreferences ContactInboxPreference[]
} }
model TeamMember { model TeamMember {
@@ -133,6 +136,7 @@ model Contact {
omniThreads OmniThread[] omniThreads OmniThread[]
omniMessages OmniMessage[] omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[] omniIdentities OmniContactIdentity[]
contactInboxes ContactInbox[]
@@index([teamId, updatedAt]) @@index([teamId, updatedAt])
} }
@@ -150,6 +154,7 @@ model ContactNote {
model ContactMessage { model ContactMessage {
id String @id @default(cuid()) id String @id @default(cuid())
contactId String contactId String
contactInboxId String?
kind ContactMessageKind @default(MESSAGE) kind ContactMessageKind @default(MESSAGE)
direction MessageDirection direction MessageDirection
channel MessageChannel channel MessageChannel
@@ -161,8 +166,47 @@ model ContactMessage {
createdAt 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([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 { model OmniContactIdentity {