feat(chat): add contact inbox sources with per-user hide filters
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation SetContactInboxHidden($inboxId: ID!, $hidden: Boolean!) {
|
||||
setContactInboxHidden(inboxId: $inboxId, hidden: $hidden) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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("|");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user