feat(chat): add contact inbox sources with per-user hide filters
This commit is contained in:
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user