feat(chat): add contact inbox sources with per-user hide filters
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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[]
|
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
|
||||||
@@ -160,9 +165,48 @@ model ContactMessage {
|
|||||||
occurredAt DateTime @default(now())
|
occurredAt DateTime @default(now())
|
||||||
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 {
|
||||||
|
|||||||
@@ -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 }> = [];
|
||||||
@@ -542,10 +561,11 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
take: limit,
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
note: { select: { content: true, updatedAt: true } },
|
note: { select: { content: true, updatedAt: true } },
|
||||||
messages: {
|
messages: {
|
||||||
select: { occurredAt: true, channel: true, direction: true, kind: true, content: true },
|
where: visibleContactMessageWhere,
|
||||||
orderBy: { occurredAt: "desc" },
|
select: { occurredAt: true, channel: true, direction: true, kind: true, content: true },
|
||||||
take: 1,
|
orderBy: { occurredAt: "desc" },
|
||||||
|
take: 1,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
select: { id: true, title: true, startsAt: true, endsAt: true, isArchived: true },
|
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 },
|
where: { id: target.id, teamId: input.teamId },
|
||||||
include: {
|
include: {
|
||||||
note: { select: { content: true, updatedAt: true } },
|
note: { select: { content: true, updatedAt: true } },
|
||||||
messages: {
|
messages: {
|
||||||
select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true, durationSec: true, transcriptJson: true },
|
where: visibleContactMessageWhere,
|
||||||
orderBy: { occurredAt: "desc" },
|
select: { id: true, occurredAt: true, channel: true, direction: true, kind: true, content: true, durationSec: true, transcriptJson: true },
|
||||||
take: messagesLimit,
|
orderBy: { occurredAt: "desc" },
|
||||||
|
take: messagesLimit,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
select: { id: true, title: true, startsAt: true, endsAt: true, note: true, isArchived: true },
|
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({
|
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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,17 +526,23 @@ 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
|
||||||
id: c.id,
|
.filter((c) => {
|
||||||
name: c.name,
|
const total = totalInboxesByContactId.get(c.id) ?? 0;
|
||||||
avatar: c.avatarUrl ?? "",
|
if (total === 0) return true;
|
||||||
company: c.company ?? "",
|
return (visibleInboxesByContactId.get(c.id) ?? 0) > 0;
|
||||||
country: c.country ?? "",
|
})
|
||||||
location: c.location ?? "",
|
.map((c) => ({
|
||||||
channels: Array.from(channelsByContactId.get(c.id) ?? []),
|
id: c.id,
|
||||||
lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(),
|
name: c.name,
|
||||||
description: c.note?.content ?? "",
|
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>();
|
const omniByKey = new Map<string, typeof omniMessagesRaw>();
|
||||||
for (const row of omniMessagesRaw) {
|
for (const row of omniMessagesRaw) {
|
||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -160,9 +165,48 @@ model ContactMessage {
|
|||||||
occurredAt DateTime @default(now())
|
occurredAt DateTime @default(now())
|
||||||
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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -160,9 +165,48 @@ model ContactMessage {
|
|||||||
occurredAt DateTime @default(now())
|
occurredAt DateTime @default(now())
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user