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

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[]
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 {

View File

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

View File

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

View File

@@ -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<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) {
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<string, typeof omniMessagesRaw>();
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),
};

View File

@@ -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("|");
}

View File

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

View File

@@ -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",

View File

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