diff --git a/frontend/app/components/workspace/CrmWorkspaceApp.vue b/frontend/app/components/workspace/CrmWorkspaceApp.vue index 8a03702..8842f2f 100644 --- a/frontend/app/components/workspace/CrmWorkspaceApp.vue +++ b/frontend/app/components/workspace/CrmWorkspaceApp.vue @@ -10,7 +10,14 @@ import CrmPilotSidebar from "~~/app/components/workspace/pilot/CrmPilotSidebar.v import CrmChangeReviewOverlay from "~~/app/components/workspace/review/CrmChangeReviewOverlay.vue"; import meQuery from "~~/graphql/operations/me.graphql?raw"; import chatMessagesQuery from "~~/graphql/operations/chat-messages.graphql?raw"; -import dashboardQuery from "~~/graphql/operations/dashboard.graphql?raw"; +import contactsQuery from "~~/graphql/operations/contacts.graphql?raw"; +import communicationsQuery from "~~/graphql/operations/communications.graphql?raw"; +import contactInboxesQuery from "~~/graphql/operations/contact-inboxes.graphql?raw"; +import calendarQuery from "~~/graphql/operations/calendar.graphql?raw"; +import dealsQuery from "~~/graphql/operations/deals.graphql?raw"; +import feedQuery from "~~/graphql/operations/feed.graphql?raw"; +import pinsQuery from "~~/graphql/operations/pins.graphql?raw"; +import documentsQuery from "~~/graphql/operations/documents.graphql?raw"; import getClientTimelineQuery from "~~/graphql/operations/get-client-timeline.graphql?raw"; import logoutMutation from "~~/graphql/operations/logout.graphql?raw"; import logPilotNoteMutation from "~~/graphql/operations/log-pilot-note.graphql?raw"; @@ -982,27 +989,34 @@ async function logout() { } async function refreshCrmData() { - const data = await gqlFetch<{ - dashboard: { - contacts: Contact[]; - communications: CommItem[]; - contactInboxes: ContactInbox[]; - calendar: CalendarEvent[]; - deals: Deal[]; - feed: FeedCard[]; - pins: CommPin[]; - documents: WorkspaceDocument[]; - }; - }>(dashboardQuery); + const [ + contactsData, + communicationsData, + contactInboxesData, + calendarData, + dealsData, + feedData, + pinsData, + documentsData, + ] = await Promise.all([ + gqlFetch<{ contacts: Contact[] }>(contactsQuery), + gqlFetch<{ communications: CommItem[] }>(communicationsQuery), + gqlFetch<{ contactInboxes: ContactInbox[] }>(contactInboxesQuery), + gqlFetch<{ calendar: CalendarEvent[] }>(calendarQuery), + gqlFetch<{ deals: Deal[] }>(dealsQuery), + gqlFetch<{ feed: FeedCard[] }>(feedQuery), + gqlFetch<{ pins: CommPin[] }>(pinsQuery), + gqlFetch<{ documents: WorkspaceDocument[] }>(documentsQuery), + ]); - contacts.value = data.dashboard.contacts ?? []; - commItems.value = data.dashboard.communications ?? []; - contactInboxes.value = data.dashboard.contactInboxes ?? []; - calendarEvents.value = data.dashboard.calendar ?? []; - deals.value = data.dashboard.deals ?? []; - feedCards.value = data.dashboard.feed ?? []; - commPins.value = data.dashboard.pins ?? []; - documents.value = data.dashboard.documents ?? []; + contacts.value = contactsData.contacts ?? []; + commItems.value = communicationsData.communications ?? []; + contactInboxes.value = contactInboxesData.contactInboxes ?? []; + calendarEvents.value = calendarData.calendar ?? []; + deals.value = dealsData.deals ?? []; + feedCards.value = feedData.feed ?? []; + commPins.value = pinsData.pins ?? []; + documents.value = documentsData.documents ?? []; // Derive channels per contact from communication items. const byName = new Map>(); diff --git a/frontend/graphql/operations/calendar.graphql b/frontend/graphql/operations/calendar.graphql new file mode 100644 index 0000000..273f501 --- /dev/null +++ b/frontend/graphql/operations/calendar.graphql @@ -0,0 +1,14 @@ +query CalendarQuery { + calendar { + id + title + start + end + contact + note + isArchived + createdAt + archiveNote + archivedAt + } +} diff --git a/frontend/graphql/operations/communications.graphql b/frontend/graphql/operations/communications.graphql new file mode 100644 index 0000000..5661b64 --- /dev/null +++ b/frontend/graphql/operations/communications.graphql @@ -0,0 +1,19 @@ +query CommunicationsQuery { + communications { + id + at + contactId + contact + contactInboxId + sourceExternalId + sourceTitle + channel + kind + direction + text + audioUrl + duration + transcript + deliveryStatus + } +} diff --git a/frontend/graphql/operations/contact-inboxes.graphql b/frontend/graphql/operations/contact-inboxes.graphql new file mode 100644 index 0000000..58333a9 --- /dev/null +++ b/frontend/graphql/operations/contact-inboxes.graphql @@ -0,0 +1,13 @@ +query ContactInboxesQuery { + contactInboxes { + id + contactId + contactName + channel + sourceExternalId + title + isHidden + lastMessageAt + updatedAt + } +} diff --git a/frontend/graphql/operations/contacts.graphql b/frontend/graphql/operations/contacts.graphql new file mode 100644 index 0000000..a48d284 --- /dev/null +++ b/frontend/graphql/operations/contacts.graphql @@ -0,0 +1,13 @@ +query ContactsQuery { + contacts { + id + name + avatar + company + country + location + channels + lastContactAt + description + } +} diff --git a/frontend/graphql/operations/dashboard.graphql b/frontend/graphql/operations/dashboard.graphql deleted file mode 100644 index 067c5b3..0000000 --- a/frontend/graphql/operations/dashboard.graphql +++ /dev/null @@ -1,103 +0,0 @@ -query DashboardQuery { - dashboard { - contacts { - id - name - avatar - company - country - location - channels - lastContactAt - description - } - communications { - id - at - contactId - contact - contactInboxId - sourceExternalId - sourceTitle - channel - kind - direction - text - audioUrl - duration - transcript - deliveryStatus - } - contactInboxes { - id - contactId - contactName - channel - sourceExternalId - title - isHidden - lastMessageAt - updatedAt - } - calendar { - id - title - start - end - contact - note - isArchived - createdAt - archiveNote - archivedAt - } - deals { - id - contact - title - company - stage - amount - nextStep - summary - currentStepId - steps { - id - title - description - status - dueAt - order - completedAt - } - } - feed { - id - at - contact - text - proposal { - title - details - key - } - decision - decisionNote - } - pins { - id - contact - text - } - documents { - id - title - type - owner - scope - updatedAt - summary - body - } - } -} diff --git a/frontend/graphql/operations/deals.graphql b/frontend/graphql/operations/deals.graphql new file mode 100644 index 0000000..bf4f804 --- /dev/null +++ b/frontend/graphql/operations/deals.graphql @@ -0,0 +1,22 @@ +query DealsQuery { + deals { + id + contact + title + company + stage + amount + nextStep + summary + currentStepId + steps { + id + title + description + status + dueAt + order + completedAt + } + } +} diff --git a/frontend/graphql/operations/documents.graphql b/frontend/graphql/operations/documents.graphql new file mode 100644 index 0000000..58d5f25 --- /dev/null +++ b/frontend/graphql/operations/documents.graphql @@ -0,0 +1,12 @@ +query DocumentsQuery { + documents { + id + title + type + owner + scope + updatedAt + summary + body + } +} diff --git a/frontend/graphql/operations/feed.graphql b/frontend/graphql/operations/feed.graphql new file mode 100644 index 0000000..e2855dc --- /dev/null +++ b/frontend/graphql/operations/feed.graphql @@ -0,0 +1,15 @@ +query FeedQuery { + feed { + id + at + contact + text + proposal { + title + details + key + } + decision + decisionNote + } +} diff --git a/frontend/graphql/operations/pins.graphql b/frontend/graphql/operations/pins.graphql new file mode 100644 index 0000000..460ad5d --- /dev/null +++ b/frontend/graphql/operations/pins.graphql @@ -0,0 +1,7 @@ +query PinsQuery { + pins { + id + contact + text + } +} diff --git a/frontend/server/graphql/schema.ts b/frontend/server/graphql/schema.ts index a0d3d17..0fbb479 100644 --- a/frontend/server/graphql/schema.ts +++ b/frontend/server/graphql/schema.ts @@ -441,31 +441,21 @@ async function getChatMessages(auth: AuthContext | null) { }); } -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); +async function getHiddenInboxIds(teamId: string, userId: string) { const hiddenPrefRows = await prisma.contactInboxPreference.findMany({ - where: { - teamId: ctx.teamId, - userId: ctx.userId, - isHidden: true, - }, + where: { teamId, userId, isHidden: true }, select: { contactInboxId: true }, }); - const hiddenInboxIds = hiddenPrefRows.map((row) => row.contactInboxId); - const messageWhere = visibleMessageWhere(hiddenInboxIds); + return hiddenPrefRows.map((row) => row.contactInboxId); +} - const [ - contactsRaw, - communicationsRaw, - contactInboxesRaw, - calendarRaw, - dealsRaw, - feedRaw, - pinsRaw, - documentsRaw, - ] = await Promise.all([ +async function getContacts(auth: AuthContext | null) { + const ctx = requireAuth(auth); + const hiddenInboxIds = await getHiddenInboxIds(ctx.teamId, ctx.userId); + const messageWhere = visibleMessageWhere(hiddenInboxIds); + const hiddenInboxIdSet = new Set(hiddenInboxIds); + + const [contactsRaw, contactInboxesRaw, communicationsRaw] = await Promise.all([ prisma.contact.findMany({ where: { teamId: ctx.teamId }, include: { @@ -475,66 +465,77 @@ async function getDashboard(auth: AuthContext | null) { orderBy: { updatedAt: "desc" }, take: 500, }), + prisma.contactInbox.findMany({ + where: { teamId: ctx.teamId }, + select: { id: true, contactId: true, channel: true }, + orderBy: { updatedAt: "desc" }, + take: 5000, + }), prisma.contactMessage.findMany({ where: { contact: { teamId: ctx.teamId }, ...(messageWhere ?? {}), }, + select: { contactId: true, channel: true }, orderBy: { occurredAt: "asc" }, take: 2000, - 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 } }, - include: { contact: { select: { name: true } } }, - orderBy: { startsAt: "asc" }, - take: 500, - }), - prisma.deal.findMany({ - where: { teamId: ctx.teamId }, - include: { - contact: { select: { name: true, company: true } }, - steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] }, - }, - orderBy: { updatedAt: "desc" }, - take: 500, - }), - prisma.feedCard.findMany({ - where: { teamId: ctx.teamId }, - include: { contact: { select: { name: true } } }, - orderBy: { happenedAt: "desc" }, - take: 200, - }), - prisma.contactPin.findMany({ - where: { teamId: ctx.teamId }, - include: { contact: { select: { name: true } } }, - orderBy: { updatedAt: "desc" }, - take: 500, - }), - prisma.workspaceDocument.findMany({ - where: { teamId: ctx.teamId }, - orderBy: { updatedAt: "desc" }, - take: 200, }), ]); + const channelsByContactId = new Map>(); + const totalInboxesByContactId = new Map(); + const visibleInboxesByContactId = new Map(); + + 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()); + channelsByContactId.get(item.contactId)?.add(mapChannel(item.channel)); + } + + return 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 ?? "", + })); +} + +async function getCommunications(auth: AuthContext | null) { + const ctx = requireAuth(auth); + const hiddenInboxIds = await getHiddenInboxIds(ctx.teamId, ctx.userId); + const messageWhere = visibleMessageWhere(hiddenInboxIds); + + const communicationsRaw = await prisma.contactMessage.findMany({ + where: { + contact: { teamId: ctx.teamId }, + ...(messageWhere ?? {}), + }, + orderBy: { occurredAt: "asc" }, + take: 2000, + include: { + contact: { select: { id: true, name: true } }, + contactInbox: { select: { id: true, sourceExternalId: true, title: true } }, + }, + }); + let omniMessagesRaw: Array<{ id: string; contactId: string; @@ -558,10 +559,7 @@ async function getDashboard(auth: AuthContext | null) { where: { teamId: ctx.teamId, contactId: { in: contactIds }, - occurredAt: { - gte: fromOccurredAt, - lte: toOccurredAt, - }, + occurredAt: { gte: fromOccurredAt, lte: toOccurredAt }, }, select: { id: true, @@ -579,46 +577,6 @@ async function getDashboard(auth: AuthContext | null) { }); } - const hiddenInboxIdSet = new Set(hiddenInboxIds); - const channelsByContactId = new Map>(); - const totalInboxesByContactId = new Map(); - const visibleInboxesByContactId = new Map(); - - 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()); - } - channelsByContactId.get(item.contactId)?.add(mapChannel(item.channel)); - } - - 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(); for (const row of omniMessagesRaw) { const normalizedText = extractOmniNormalizedText(row.rawJson, row.text); @@ -664,7 +622,7 @@ async function getDashboard(auth: AuthContext | null) { return best.status; }; - const communications = communicationsRaw.map((m) => ({ + return communicationsRaw.map((m) => ({ id: m.id, at: m.occurredAt.toISOString(), contactId: m.contactId, @@ -681,21 +639,55 @@ async function getDashboard(auth: AuthContext | null) { transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [], 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(), - })); +async function getContactInboxes(auth: AuthContext | null) { + const ctx = requireAuth(auth); + const hiddenInboxIds = await getHiddenInboxIds(ctx.teamId, ctx.userId); + const messageWhere = visibleMessageWhere(hiddenInboxIds); + const hiddenInboxIdSet = new Set(hiddenInboxIds); - const calendar = calendarRaw.map((e) => ({ + const contactInboxesRaw = await 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, + }); + + return 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(), + })); +} + +async function getCalendar(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 calendarRaw = await prisma.calendarEvent.findMany({ + where: { teamId: ctx.teamId, startsAt: { gte: from, lte: to } }, + include: { contact: { select: { name: true } } }, + orderBy: { startsAt: "asc" }, + take: 500, + }); + + return calendarRaw.map((e) => ({ id: e.id, title: e.title, start: e.startsAt.toISOString(), @@ -707,8 +699,21 @@ async function getDashboard(auth: AuthContext | null) { archiveNote: e.archiveNote ?? "", archivedAt: e.archivedAt?.toISOString() ?? "", })); +} - const deals = dealsRaw.map((d) => ({ +async function getDeals(auth: AuthContext | null) { + const ctx = requireAuth(auth); + const dealsRaw = await prisma.deal.findMany({ + where: { teamId: ctx.teamId }, + include: { + contact: { select: { name: true, company: true } }, + steps: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] }, + }, + orderBy: { updatedAt: "desc" }, + take: 500, + }); + + return dealsRaw.map((d) => ({ id: d.id, contact: d.contact.name, title: d.title, @@ -728,8 +733,18 @@ async function getDashboard(auth: AuthContext | null) { completedAt: step.completedAt?.toISOString() ?? "", })), })); +} - const feed = feedRaw.map((c) => ({ +async function getFeed(auth: AuthContext | null) { + const ctx = requireAuth(auth); + const feedRaw = await prisma.feedCard.findMany({ + where: { teamId: ctx.teamId }, + include: { contact: { select: { name: true } } }, + orderBy: { happenedAt: "desc" }, + take: 200, + }); + + return feedRaw.map((c) => ({ id: c.id, at: c.happenedAt.toISOString(), contact: c.contact?.name ?? "", @@ -742,14 +757,33 @@ async function getDashboard(auth: AuthContext | null) { decision: c.decision === "ACCEPTED" ? "accepted" : c.decision === "REJECTED" ? "rejected" : "pending", decisionNote: c.decisionNote ?? "", })); +} - const pins = pinsRaw.map((p) => ({ +async function getPins(auth: AuthContext | null) { + const ctx = requireAuth(auth); + const pinsRaw = await prisma.contactPin.findMany({ + where: { teamId: ctx.teamId }, + include: { contact: { select: { name: true } } }, + orderBy: { updatedAt: "desc" }, + take: 500, + }); + + return pinsRaw.map((p) => ({ id: p.id, contact: p.contact.name, text: p.text, })); +} - const documents = documentsRaw.map((d) => ({ +async function getDocuments(auth: AuthContext | null) { + const ctx = requireAuth(auth); + const documentsRaw = await prisma.workspaceDocument.findMany({ + where: { teamId: ctx.teamId }, + orderBy: { updatedAt: "desc" }, + take: 200, + }); + + return documentsRaw.map((d) => ({ id: d.id, title: d.title, type: d.type, @@ -759,17 +793,6 @@ async function getDashboard(auth: AuthContext | null) { summary: d.summary, body: d.body, })); - - return { - contacts, - communications, - contactInboxes, - calendar, - deals, - feed, - pins, - documents, - }; } async function getClientTimeline(auth: AuthContext | null, contactIdInput: string, limitInput?: number) { @@ -1779,7 +1802,14 @@ export const crmGraphqlSchema = buildSchema(` me: MePayload! chatMessages: [PilotMessage!]! chatConversations: [Conversation!]! - dashboard: DashboardPayload! + contacts: [Contact!]! + communications: [CommItem!]! + contactInboxes: [ContactInbox!]! + calendar: [CalendarEvent!]! + deals: [Deal!]! + feed: [FeedCard!]! + pins: [CommPin!]! + documents: [WorkspaceDocument!]! getClientTimeline(contactId: ID!, limit: Int): [ClientTimelineItem!]! } @@ -1917,17 +1947,6 @@ export const crmGraphqlSchema = buildSchema(` at: String! } - type DashboardPayload { - contacts: [Contact!]! - communications: [CommItem!]! - contactInboxes: [ContactInbox!]! - calendar: [CalendarEvent!]! - deals: [Deal!]! - feed: [FeedCard!]! - pins: [CommPin!]! - documents: [WorkspaceDocument!]! - } - type ClientTimelineItem { id: ID! contactId: String! @@ -2056,7 +2075,14 @@ export const crmGraphqlRoot = { me: async (_args: unknown, context: GraphQLContext) => getAuthPayload(context.auth), chatMessages: async (_args: unknown, context: GraphQLContext) => getChatMessages(context.auth), chatConversations: async (_args: unknown, context: GraphQLContext) => getChatConversations(context.auth), - dashboard: async (_args: unknown, context: GraphQLContext) => getDashboard(context.auth), + contacts: async (_args: unknown, context: GraphQLContext) => getContacts(context.auth), + communications: async (_args: unknown, context: GraphQLContext) => getCommunications(context.auth), + contactInboxes: async (_args: unknown, context: GraphQLContext) => getContactInboxes(context.auth), + calendar: async (_args: unknown, context: GraphQLContext) => getCalendar(context.auth), + deals: async (_args: unknown, context: GraphQLContext) => getDeals(context.auth), + feed: async (_args: unknown, context: GraphQLContext) => getFeed(context.auth), + pins: async (_args: unknown, context: GraphQLContext) => getPins(context.auth), + documents: async (_args: unknown, context: GraphQLContext) => getDocuments(context.auth), getClientTimeline: async ( args: { contactId: string; limit?: number }, context: GraphQLContext,