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

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