refactor: migrate CRM data layer from manual gqlFetch to Apollo Client

Replace custom gqlFetch() with proper Apollo useQuery/useMutation hooks
powered by codegen-generated TypedDocumentNode types. Key changes:

- Add GraphQL SDL schema file and codegen config for typescript-vue-apollo
- Replace all 28 raw .graphql imports with generated typed documents
- Add 12 useQuery() hooks with cache-and-network fetch policy
- Add 17 useMutation() hooks with surgical refetchQueries per mutation
- Optimistic cache update for setContactInboxHidden (instant archive UX)
- Fix contact list subtitle: show lastText instead of channel name
- Migrate login page from gqlFetch to useMutation
- WebSocket realtime now calls Apollo refetch instead of full data reload

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-02-24 10:06:29 +07:00
parent 3e711a5533
commit 947ef4d56d
7 changed files with 2300 additions and 211 deletions

View File

@@ -9,35 +9,38 @@ import CrmDocumentsPanel from "~~/app/components/workspace/documents/CrmDocument
import CrmWorkspaceTopbar from "~~/app/components/workspace/header/CrmWorkspaceTopbar.vue";
import CrmPilotSidebar from "~~/app/components/workspace/pilot/CrmPilotSidebar.vue";
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 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";
import createCalendarEventMutation from "~~/graphql/operations/create-calendar-event.graphql?raw";
import archiveCalendarEventMutation from "~~/graphql/operations/archive-calendar-event.graphql?raw";
import createCommunicationMutation from "~~/graphql/operations/create-communication.graphql?raw";
import createWorkspaceDocumentMutation from "~~/graphql/operations/create-workspace-document.graphql?raw";
import deleteWorkspaceDocumentMutation from "~~/graphql/operations/delete-workspace-document.graphql?raw";
import updateCommunicationTranscriptMutation from "~~/graphql/operations/update-communication-transcript.graphql?raw";
import updateFeedDecisionMutation from "~~/graphql/operations/update-feed-decision.graphql?raw";
import chatConversationsQuery from "~~/graphql/operations/chat-conversations.graphql?raw";
import createChatConversationMutation from "~~/graphql/operations/create-chat-conversation.graphql?raw";
import selectChatConversationMutation from "~~/graphql/operations/select-chat-conversation.graphql?raw";
import archiveChatConversationMutation from "~~/graphql/operations/archive-chat-conversation.graphql?raw";
import toggleContactPinMutation from "~~/graphql/operations/toggle-contact-pin.graphql?raw";
import setContactInboxHiddenMutation from "~~/graphql/operations/set-contact-inbox-hidden.graphql?raw";
import confirmLatestChangeSetMutation from "~~/graphql/operations/confirm-latest-change-set.graphql?raw";
import rollbackLatestChangeSetMutation from "~~/graphql/operations/rollback-latest-change-set.graphql?raw";
import rollbackChangeSetItemsMutation from "~~/graphql/operations/rollback-change-set-items.graphql?raw";
import { useQuery, useMutation } from "@vue/apollo-composable";
import {
MeQueryDocument,
ChatMessagesQueryDocument,
ChatConversationsQueryDocument,
ContactsQueryDocument,
CommunicationsQueryDocument,
ContactInboxesQueryDocument,
CalendarQueryDocument,
DealsQueryDocument,
FeedQueryDocument,
PinsQueryDocument,
DocumentsQueryDocument,
GetClientTimelineQueryDocument,
LogoutMutationDocument,
LogPilotNoteMutationDocument,
CreateCalendarEventMutationDocument,
ArchiveCalendarEventMutationDocument,
CreateCommunicationMutationDocument,
CreateWorkspaceDocumentDocument,
DeleteWorkspaceDocumentDocument,
UpdateCommunicationTranscriptMutationDocument,
UpdateFeedDecisionMutationDocument,
CreateChatConversationMutationDocument,
SelectChatConversationMutationDocument,
ArchiveChatConversationMutationDocument,
ToggleContactPinMutationDocument,
SetContactInboxHiddenDocument,
ConfirmLatestChangeSetMutationDocument,
RollbackLatestChangeSetMutationDocument,
RollbackChangeSetItemsMutationDocument,
} from "~~/graphql/generated";
import {
buildContactDocumentScope,
formatDocumentScope,
@@ -515,7 +518,7 @@ const pilotChat = new AiChat<UIMessage>({
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData()]);
await Promise.all([refetchChatMessages(), refetchChatConversations(), refetchAllCrmQueries()]);
},
onError: () => {
if (livePilotUserText.value) {
@@ -552,6 +555,250 @@ let crmRealtimeRefreshInFlight = false;
let crmRealtimeReconnectAttempt = 0;
let clientTimelineRequestToken = 0;
// ---------------------------------------------------------------------------
// Apollo Queries
// ---------------------------------------------------------------------------
const apolloAuthReady = computed(() => !!authMe.value);
const { result: meResult, refetch: refetchMe, loading: meLoading } = useQuery(
MeQueryDocument,
null,
{ fetchPolicy: "network-only" },
);
const { result: chatMessagesResult, refetch: refetchChatMessages } = useQuery(
ChatMessagesQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: chatConversationsResult, refetch: refetchChatConversations } = useQuery(
ChatConversationsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: contactsResult, refetch: refetchContacts } = useQuery(
ContactsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: communicationsResult, refetch: refetchCommunications } = useQuery(
CommunicationsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: contactInboxesResult, refetch: refetchContactInboxes } = useQuery(
ContactInboxesQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: calendarResult, refetch: refetchCalendar } = useQuery(
CalendarQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: dealsResult, refetch: refetchDeals } = useQuery(
DealsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: feedResult, refetch: refetchFeed } = useQuery(
FeedQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: pinsResult, refetch: refetchPins } = useQuery(
PinsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const { result: documentsResult, refetch: refetchDocuments } = useQuery(
DocumentsQueryDocument,
null,
{ enabled: apolloAuthReady },
);
const timelineContactId = ref("");
const timelineLimit = ref(500);
const { result: timelineResult, refetch: refetchTimeline } = useQuery(
GetClientTimelineQueryDocument,
() => ({ contactId: timelineContactId.value, limit: timelineLimit.value }),
{ enabled: computed(() => !!timelineContactId.value && apolloAuthReady.value) },
);
// ---------------------------------------------------------------------------
// Apollo Mutations
// ---------------------------------------------------------------------------
const allCrmQueryDocs = [
{ query: ContactsQueryDocument },
{ query: CommunicationsQueryDocument },
{ query: ContactInboxesQueryDocument },
{ query: CalendarQueryDocument },
{ query: DealsQueryDocument },
{ query: FeedQueryDocument },
{ query: PinsQueryDocument },
{ query: DocumentsQueryDocument },
];
const { mutate: doLogout } = useMutation(LogoutMutationDocument);
const { mutate: doLogPilotNote } = useMutation(LogPilotNoteMutationDocument);
const { mutate: doCreateCalendarEvent } = useMutation(CreateCalendarEventMutationDocument, {
refetchQueries: [{ query: CalendarQueryDocument }],
});
const { mutate: doArchiveCalendarEvent } = useMutation(ArchiveCalendarEventMutationDocument, {
refetchQueries: [{ query: CalendarQueryDocument }],
});
const { mutate: doCreateCommunication } = useMutation(CreateCommunicationMutationDocument, {
refetchQueries: [{ query: CommunicationsQueryDocument }, { query: ContactInboxesQueryDocument }],
});
const { mutate: doCreateWorkspaceDocument } = useMutation(CreateWorkspaceDocumentDocument, {
refetchQueries: [{ query: DocumentsQueryDocument }],
});
const { mutate: doDeleteWorkspaceDocument } = useMutation(DeleteWorkspaceDocumentDocument, {
refetchQueries: [{ query: DocumentsQueryDocument }],
});
const { mutate: doUpdateCommunicationTranscript } = useMutation(UpdateCommunicationTranscriptMutationDocument, {
refetchQueries: [{ query: CommunicationsQueryDocument }],
});
const { mutate: doUpdateFeedDecision } = useMutation(UpdateFeedDecisionMutationDocument, {
refetchQueries: [{ query: FeedQueryDocument }],
});
const { mutate: doCreateChatConversation } = useMutation(CreateChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doSelectChatConversation } = useMutation(SelectChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doArchiveChatConversation } = useMutation(ArchiveChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doToggleContactPin } = useMutation(ToggleContactPinMutationDocument, {
refetchQueries: [{ query: PinsQueryDocument }],
});
const { mutate: doSetContactInboxHidden } = useMutation(SetContactInboxHiddenDocument, {
refetchQueries: [{ query: ContactInboxesQueryDocument }],
update: (cache, _result, { variables }) => {
if (!variables) return;
const existing = cache.readQuery({ query: ContactInboxesQueryDocument }) as { contactInboxes?: ContactInbox[] } | null;
if (!existing?.contactInboxes) return;
cache.writeQuery({
query: ContactInboxesQueryDocument,
data: {
contactInboxes: existing.contactInboxes.map((inbox) =>
inbox.id === variables.inboxId ? { ...inbox, isHidden: variables.hidden } : inbox,
),
},
});
},
});
const { mutate: doConfirmLatestChangeSet } = useMutation(ConfirmLatestChangeSetMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }],
});
const { mutate: doRollbackLatestChangeSet } = useMutation(RollbackLatestChangeSetMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
});
const { mutate: doRollbackChangeSetItems } = useMutation(RollbackChangeSetItemsMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
});
// ---------------------------------------------------------------------------
// Apollo → Ref Watchers (bridge Apollo reactive results to existing refs)
// ---------------------------------------------------------------------------
function syncPilotChatFromHistoryBridge(messages: PilotMessage[]) {
// forward-declared; actual function is defined later
syncPilotChatFromHistory(messages);
}
watch(() => meResult.value?.me, (me) => {
if (me) authMe.value = me as typeof authMe.value;
}, { immediate: true });
watch(() => chatMessagesResult.value?.chatMessages, (v) => {
if (v) {
pilotMessages.value = v as PilotMessage[];
syncPilotChatFromHistoryBridge(pilotMessages.value);
}
}, { immediate: true });
watch(() => chatConversationsResult.value?.chatConversations, (v) => {
if (v) chatConversations.value = v as ChatConversation[];
}, { immediate: true });
watch(
[() => contactsResult.value?.contacts, () => communicationsResult.value?.communications],
([rawContacts, rawComms]) => {
if (!rawContacts) return;
const contactsList = [...rawContacts] as Contact[];
const commsList = (rawComms ?? []) as CommItem[];
const byName = new Map<string, Set<string>>();
for (const item of commsList) {
if (!byName.has(item.contact)) byName.set(item.contact, new Set());
byName.get(item.contact)?.add(item.channel);
}
contacts.value = contactsList.map((c) => ({
...c,
channels: Array.from(byName.get(c.name) ?? c.channels ?? []),
}));
commItems.value = commsList;
},
{ immediate: true },
);
watch(() => contactInboxesResult.value?.contactInboxes, (v) => {
if (v) contactInboxes.value = v as ContactInbox[];
}, { immediate: true });
watch(() => calendarResult.value?.calendar, (v) => {
if (v) calendarEvents.value = v as CalendarEvent[];
}, { immediate: true });
watch(() => dealsResult.value?.deals, (v) => {
if (v) deals.value = v as Deal[];
}, { immediate: true });
watch(() => feedResult.value?.feed, (v) => {
if (v) feedCards.value = v as FeedCard[];
}, { immediate: true });
watch(() => pinsResult.value?.pins, (v) => {
if (v) commPins.value = v as CommPin[];
}, { immediate: true });
watch(() => documentsResult.value?.documents, (v) => {
if (v) documents.value = v as WorkspaceDocument[];
}, { immediate: true });
watch(() => timelineResult.value?.getClientTimeline, (v) => {
if (v) clientTimelineItems.value = v as ClientTimelineItem[];
}, { immediate: true });
// ---------------------------------------------------------------------------
// Refetch helpers (replace old gqlFetch-based functions)
// ---------------------------------------------------------------------------
async function refetchAllCrmQueries() {
await Promise.all([
refetchContacts(),
refetchCommunications(),
refetchContactInboxes(),
refetchCalendar(),
refetchDeals(),
refetchFeed(),
refetchPins(),
refetchDocuments(),
]);
await refreshSelectedClientTimeline();
}
watch(
() => pilotLiveLogs.value.length,
(len) => {
@@ -851,52 +1098,23 @@ const renderedPilotMessages = computed<PilotMessage[]>(() => {
return items;
});
async function gqlFetch<TData>(query: string, variables?: Record<string, unknown>) {
const headers = process.server ? useRequestHeaders(["cookie"]) : undefined;
const result = await $fetch<{ data?: TData; errors?: Array<{ message: string }> }>("/api/graphql", {
method: "POST",
headers,
body: { query, variables },
});
if (result.errors?.length) {
throw new Error(result.errors[0]?.message || "GraphQL request failed");
}
if (!result.data) {
throw new Error("GraphQL returned empty payload");
}
return result.data;
}
async function loadPilotMessages() {
const data = await gqlFetch<{ chatMessages: PilotMessage[] }>(chatMessagesQuery);
pilotMessages.value = data.chatMessages ?? [];
syncPilotChatFromHistory(pilotMessages.value);
await refetchChatMessages();
}
async function loadChatConversations() {
chatThreadsLoading.value = true;
try {
const data = await gqlFetch<{ chatConversations: ChatConversation[] }>(chatConversationsQuery);
chatConversations.value = data.chatConversations ?? [];
await refetchChatConversations();
} finally {
chatThreadsLoading.value = false;
}
}
async function loadMe() {
const data = await gqlFetch<{
me: {
user: { id: string; phone: string; name: string };
team: { id: string; name: string };
conversation: { id: string; title: string };
};
}>(
meQuery,
);
authMe.value = data.me;
const result = await refetchMe();
const me = result?.data?.me;
if (me) authMe.value = me as typeof authMe.value;
}
const authResolved = ref(false);
@@ -941,8 +1159,7 @@ async function createNewChatConversation() {
chatThreadPickerOpen.value = false;
chatCreating.value = true;
try {
await gqlFetch<{ createChatConversation: ChatConversation }>(createChatConversationMutation);
await Promise.all([loadMe(), loadPilotMessages(), loadChatConversations()]);
await doCreateChatConversation();
} finally {
chatCreating.value = false;
}
@@ -953,8 +1170,7 @@ async function switchChatConversation(id: string) {
chatThreadPickerOpen.value = false;
chatSwitching.value = true;
try {
await gqlFetch<{ selectChatConversation: { ok: boolean } }>(selectChatConversationMutation, { id });
await Promise.all([loadMe(), loadPilotMessages(), loadChatConversations()]);
await doSelectChatConversation({ id });
} finally {
chatSwitching.value = false;
}
@@ -964,15 +1180,14 @@ async function archiveChatConversation(id: string) {
if (!id || chatArchivingId.value) return;
chatArchivingId.value = id;
try {
await gqlFetch<{ archiveChatConversation: { ok: boolean } }>(archiveChatConversationMutation, { id });
await Promise.all([loadMe(), loadPilotMessages(), loadChatConversations()]);
await doArchiveChatConversation({ id });
} finally {
chatArchivingId.value = "";
}
}
async function logout() {
await gqlFetch<{ logout: { ok: boolean } }>(logoutMutation);
await doLogout();
stopCrmRealtime();
stopPilotBackgroundPolling();
authMe.value = null;
@@ -991,63 +1206,20 @@ async function logout() {
}
async function refreshCrmData() {
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 = 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<string, Set<string>>();
for (const item of commItems.value) {
if (!byName.has(item.contact)) byName.set(item.contact, new Set());
byName.get(item.contact)?.add(item.channel);
}
contacts.value = contacts.value.map((c) => ({
...c,
channels: Array.from(byName.get(c.name) ?? []),
}));
await refreshSelectedClientTimeline();
await refetchAllCrmQueries();
}
async function loadClientTimeline(contactId: string, limit = 500) {
const normalizedContactId = String(contactId ?? "").trim();
if (!normalizedContactId) {
clientTimelineItems.value = [];
timelineContactId.value = "";
return;
}
const requestToken = ++clientTimelineRequestToken;
const data = await gqlFetch<{ getClientTimeline: ClientTimelineItem[] }>(getClientTimelineQuery, {
contactId: normalizedContactId,
limit,
});
if (requestToken !== clientTimelineRequestToken) return;
clientTimelineItems.value = data.getClientTimeline ?? [];
timelineContactId.value = normalizedContactId;
timelineLimit.value = limit;
await refetchTimeline();
}
async function refreshSelectedClientTimeline() {
@@ -1075,7 +1247,7 @@ async function runCrmRealtimeRefresh() {
if (!authMe.value || crmRealtimeRefreshInFlight) return;
crmRealtimeRefreshInFlight = true;
try {
await Promise.all([refreshCrmData(), loadTelegramConnectStatus()]);
await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]);
} catch {
// ignore transient realtime refresh errors
} finally {
@@ -1211,7 +1383,7 @@ async function sendPilotText(rawText: string) {
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotSending.value = false;
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData()]);
await Promise.all([refetchChatMessages(), refetchChatConversations(), refetchAllCrmQueries()]);
}
}
@@ -1962,8 +2134,7 @@ async function confirmLatestChangeSet() {
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
changeActionBusy.value = true;
try {
await gqlFetch<{ confirmLatestChangeSet: { ok: boolean } }>(confirmLatestChangeSetMutation);
await loadPilotMessages();
await doConfirmLatestChangeSet();
} finally {
changeActionBusy.value = false;
}
@@ -1973,8 +2144,7 @@ async function rollbackLatestChangeSet() {
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
changeActionBusy.value = true;
try {
await gqlFetch<{ rollbackLatestChangeSet: { ok: boolean } }>(rollbackLatestChangeSetMutation);
await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]);
await doRollbackLatestChangeSet();
activeChangeSetId.value = "";
activeChangeStep.value = 0;
setPeopleLeftMode("contacts");
@@ -1990,11 +2160,7 @@ async function rollbackSelectedChangeItems() {
changeActionBusy.value = true;
try {
await gqlFetch<{ rollbackChangeSetItems: { ok: boolean } }>(rollbackChangeSetItemsMutation, {
changeSetId: targetChangeSetId,
itemIds,
});
await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]);
await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds });
} finally {
changeActionBusy.value = false;
}
@@ -2007,11 +2173,7 @@ async function rollbackChangeItemById(itemId: string) {
changeActionBusy.value = true;
try {
await gqlFetch<{ rollbackChangeSetItems: { ok: boolean } }>(rollbackChangeSetItemsMutation, {
changeSetId: targetChangeSetId,
itemIds: [itemId],
});
await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]);
await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds: [itemId] });
} finally {
changeActionBusy.value = false;
}
@@ -3172,9 +3334,7 @@ async function deleteWorkspaceDocumentById(documentIdInput: string) {
documentDeletingId.value = documentId;
try {
await gqlFetch<{ deleteWorkspaceDocument: { ok: boolean; id: string } }>(deleteWorkspaceDocumentMutation, {
id: documentId,
});
await doDeleteWorkspaceDocument({ id: documentId });
documents.value = documents.value.filter((doc) => doc.id !== documentId);
clientTimelineItems.value = clientTimelineItems.value.filter((item) => {
const isDocumentEntry = String(item.contentType).toLowerCase() === "document";
@@ -3794,13 +3954,12 @@ async function archiveEventManually(event: CalendarEvent) {
eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: true };
eventCloseError.value = { ...eventCloseError.value, [eventId]: "" };
try {
await gqlFetch<{ archiveCalendarEvent: CalendarEvent }>(archiveCalendarEventMutation, {
await doArchiveCalendarEvent({
input: {
id: eventId,
archiveNote: archiveNote || undefined,
},
});
await refreshCrmData();
eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: false };
eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" };
} catch (error: any) {
@@ -3817,11 +3976,7 @@ async function togglePinnedText(contact: string, value: string) {
if (!contactName || !text) return;
commPinToggling.value = true;
try {
await gqlFetch<{ toggleContactPin: { ok: boolean; pinned: boolean } }>(toggleContactPinMutation, {
contact: contactName,
text,
});
await refreshCrmData();
await doToggleContactPin({ contact: contactName, text });
} finally {
commPinToggling.value = false;
}
@@ -4149,11 +4304,7 @@ async function transcribeCallItem(item: CommItem) {
});
const text = await transcribeAudioBlob(audioBlob);
callTranscriptText.value[itemId] = text || "(empty transcript)";
await gqlFetch<{ updateCommunicationTranscript: { ok: boolean; id: string } }>(updateCommunicationTranscriptMutation, {
id: itemId,
transcript: text ? [text] : [],
});
await refreshCrmData();
await doUpdateCommunicationTranscript({ id: itemId, transcript: text ? [text] : [] });
} catch (error: any) {
callTranscriptError.value[itemId] = String(error?.message ?? error ?? "Transcription failed");
} finally {
@@ -4215,11 +4366,7 @@ async function setInboxHidden(inboxId: string, hidden: boolean) {
if (!id || isInboxToggleLoading(id)) return;
inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: true };
try {
await gqlFetch<{ setContactInboxHidden: { ok: boolean } }>(setContactInboxHiddenMutation, {
inboxId: id,
hidden,
});
await refreshCrmData();
await doSetContactInboxHidden({ inboxId: id, hidden });
} finally {
inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: false };
}
@@ -4249,8 +4396,8 @@ function makeId(prefix: string) {
function pushPilotNote(text: string) {
// Fire-and-forget: log assistant note to the same conversation.
gqlFetch<{ logPilotNote: { ok: boolean } }>(logPilotNoteMutation, { text })
.then(() => Promise.all([loadPilotMessages(), loadChatConversations()]))
doLogPilotNote({ text })
.then(() => Promise.all([refetchChatMessages(), refetchChatConversations()]))
.catch(() => {});
}
@@ -4409,7 +4556,7 @@ async function createCommEvent() {
commEventSaving.value = true;
commEventError.value = "";
try {
const res = await gqlFetch<{ createCalendarEvent: CalendarEvent }>(createCalendarEventMutation, {
const res = await doCreateCalendarEvent({
input: {
title,
start: start.toISOString(),
@@ -4420,7 +4567,9 @@ async function createCommEvent() {
archiveNote: commEventMode.value === "logged" ? note : undefined,
},
});
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
if (res?.data?.createCalendarEvent) {
calendarEvents.value = [res.data.createCalendarEvent as CalendarEvent, ...calendarEvents.value];
}
selectedDateKey.value = dayKey(start);
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
commDraft.value = "";
@@ -4449,7 +4598,7 @@ async function createCommDocument() {
commEventSaving.value = true;
commEventError.value = "";
try {
const res = await gqlFetch<{ createWorkspaceDocument: WorkspaceDocument }>(createWorkspaceDocumentMutation, {
const res = await doCreateWorkspaceDocument({
input: {
title,
owner: authDisplayName.value,
@@ -4459,8 +4608,13 @@ async function createCommDocument() {
},
});
documents.value = [res.createWorkspaceDocument, ...documents.value.filter((doc) => doc.id !== res.createWorkspaceDocument.id)];
selectedDocumentId.value = res.createWorkspaceDocument.id;
const created = res?.data?.createWorkspaceDocument;
if (created) {
documents.value = [created as WorkspaceDocument, ...documents.value.filter((doc) => doc.id !== created.id)];
selectedDocumentId.value = created.id;
} else {
selectedDocumentId.value = "";
}
contactRightPanelMode.value = "documents";
commDraft.value = "";
commComposerMode.value = "message";
@@ -4482,7 +4636,7 @@ async function sendCommMessage() {
const channel = commSendChannel.value;
if (!channel) return;
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
await doCreateCommunication({
input: {
contact: selectedCommThread.value.contact,
channel,
@@ -4493,7 +4647,6 @@ async function sendCommMessage() {
});
commDraft.value = "";
await refreshCrmData();
openCommunicationThread(selectedCommThread.value.contact);
} finally {
commSending.value = false;
@@ -4535,7 +4688,7 @@ async function executeFeedAction(card: FeedCard) {
const end = new Date(start);
end.setMinutes(end.getMinutes() + 30);
const res = await gqlFetch<{ createCalendarEvent: CalendarEvent }>(createCalendarEventMutation, {
const res = await doCreateCalendarEvent({
input: {
title: `Follow-up: ${card.contact.split(" ")[0] ?? "Contact"}`,
start: start.toISOString(),
@@ -4544,7 +4697,9 @@ async function executeFeedAction(card: FeedCard) {
note: "Created from feed action.",
},
});
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
if (res?.data?.createCalendarEvent) {
calendarEvents.value = [res.data.createCalendarEvent as CalendarEvent, ...calendarEvents.value];
}
selectedDateKey.value = dayKey(start);
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
@@ -4558,7 +4713,7 @@ async function executeFeedAction(card: FeedCard) {
}
if (key === "call") {
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
await doCreateCommunication({
input: {
contact: card.contact,
channel: "Phone",
@@ -4568,13 +4723,12 @@ async function executeFeedAction(card: FeedCard) {
durationSec: 0,
},
});
await refreshCrmData();
openCommunicationThread(card.contact);
return `Call event created and ${card.contact} chat opened.`;
}
if (key === "draft_message") {
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
await doCreateCommunication({
input: {
contact: card.contact,
channel: "Email",
@@ -4583,7 +4737,6 @@ async function executeFeedAction(card: FeedCard) {
text: "Draft: onboarding plan + two slots for tomorrow.",
},
});
await refreshCrmData();
openCommunicationThread(card.contact);
return `Draft message added to ${card.contact} communications.`;
}
@@ -4593,7 +4746,7 @@ async function executeFeedAction(card: FeedCard) {
}
if (key === "prepare_question") {
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
await doCreateCommunication({
input: {
contact: card.contact,
channel: "Telegram",
@@ -4602,7 +4755,6 @@ async function executeFeedAction(card: FeedCard) {
text: "Draft: can you confirm your decision date for this cycle?",
},
});
await refreshCrmData();
openCommunicationThread(card.contact);
return `Question about decision date added to ${card.contact} chat.`;
}
@@ -4616,22 +4768,14 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
if (decision === "rejected") {
const note = "Rejected. Nothing created.";
card.decisionNote = note;
await gqlFetch<{ updateFeedDecision: { ok: boolean; id: string } }>(updateFeedDecisionMutation, {
id: card.id,
decision: "rejected",
decisionNote: note,
});
await doUpdateFeedDecision({ id: card.id, decision: "rejected", decisionNote: note });
pushPilotNote(`[${card.contact}] recommendation rejected: ${card.proposal.title}`);
return;
}
const result = await executeFeedAction(card);
card.decisionNote = result;
await gqlFetch<{ updateFeedDecision: { ok: boolean; id: string } }>(updateFeedDecisionMutation, {
id: card.id,
decision: "accepted",
decisionNote: result,
});
await doUpdateFeedDecision({ id: card.id, decision: "accepted", decisionNote: result });
pushPilotNote(`[${card.contact}] ${result}`);
}

View File

@@ -146,7 +146,7 @@ function onSearchInput(event: Event) {
<span class="shrink-0 text-[10px] text-base-content/55">{{ formatThreadTime(thread.lastAt) }}</span>
</div>
<p class="mt-0.5 min-w-0 truncate text-[11px] text-base-content/75">
{{ threadChannelLabel(thread) }}
{{ thread.lastText || threadChannelLabel(thread) }}
</p>
</div>
</div>

View File

@@ -1,39 +1,20 @@
<script setup lang="ts">
import CrmAuthLoginForm from "~~/app/components/workspace/auth/CrmAuthLoginForm.vue";
import loginMutation from "~~/graphql/operations/login.graphql?raw";
import { useMutation } from "@vue/apollo-composable";
import { LoginMutationDocument } from "~~/graphql/generated";
const phone = ref("");
const password = ref("");
const error = ref<string | null>(null);
const busy = ref(false);
async function gqlFetch<TData>(query: string, variables?: Record<string, unknown>) {
const headers = process.server ? useRequestHeaders(["cookie"]) : undefined;
const result = await $fetch<{ data?: TData; errors?: Array<{ message: string }> }>("/api/graphql", {
method: "POST",
headers,
body: { query, variables },
});
if (result.errors?.length) {
throw new Error(result.errors[0]?.message || "GraphQL request failed");
}
if (!result.data) {
throw new Error("GraphQL returned empty payload");
}
return result.data;
}
const { mutate: doLogin } = useMutation(LoginMutationDocument);
async function submit() {
error.value = null;
busy.value = true;
try {
await gqlFetch<{ login: { ok: boolean } }>(loginMutation, {
phone: phone.value,
password: password.value,
});
await doLogin({ phone: phone.value, password: password.value });
await navigateTo("/", { replace: true });
} catch (e: any) {
error.value = e?.data?.message || e?.message || "Login failed";

View File

@@ -1,16 +1,13 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const schemaUrl = process.env.GRAPHQL_SCHEMA_URL || process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql";
const config: CodegenConfig = {
schema: schemaUrl,
schema: "graphql/schema.graphql",
documents: ["graphql/operations/**/*.graphql"],
generates: {
"composables/graphql/generated.ts": {
"graphql/generated.ts": {
plugins: [
"typescript",
"typescript-operations",
"typed-document-node",
"typescript-vue-apollo",
],
config: {
@@ -18,6 +15,7 @@ const config: CodegenConfig = {
vueCompositionApiImportFrom: "vue",
dedupeFragments: true,
namingConvention: "keep",
useTypeImports: true,
},
},
},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
type Query {
me: MePayload!
chatMessages: [PilotMessage!]!
chatConversations: [Conversation!]!
contacts: [Contact!]!
communications: [CommItem!]!
contactInboxes: [ContactInbox!]!
calendar: [CalendarEvent!]!
deals: [Deal!]!
feed: [FeedCard!]!
pins: [CommPin!]!
documents: [WorkspaceDocument!]!
getClientTimeline(contactId: ID!, limit: Int): [ClientTimelineItem!]!
}
type Mutation {
login(phone: String!, password: String!): MutationResult!
logout: MutationResult!
createChatConversation(title: String): Conversation!
selectChatConversation(id: ID!): MutationResult!
archiveChatConversation(id: ID!): MutationResult!
sendPilotMessage(text: String!): MutationResult!
confirmLatestChangeSet: MutationResult!
rollbackLatestChangeSet: MutationResult!
rollbackChangeSetItems(changeSetId: ID!, itemIds: [ID!]!): MutationResult!
logPilotNote(text: String!): MutationResult!
toggleContactPin(contact: String!, text: String!): PinToggleResult!
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
archiveCalendarEvent(input: ArchiveCalendarEventInput!): CalendarEvent!
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
createWorkspaceDocument(input: CreateWorkspaceDocumentInput!): WorkspaceDocument!
deleteWorkspaceDocument(id: ID!): MutationWithIdResult!
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
setContactInboxHidden(inboxId: ID!, hidden: Boolean!): MutationResult!
}
type MutationResult {
ok: Boolean!
}
type MutationWithIdResult {
ok: Boolean!
id: ID!
}
type PinToggleResult {
ok: Boolean!
pinned: Boolean!
}
input CreateCalendarEventInput {
title: String!
start: String!
end: String
contact: String
note: String
archived: Boolean
archiveNote: String
}
input ArchiveCalendarEventInput {
id: ID!
archiveNote: String
}
input CreateCommunicationInput {
contact: String!
channel: String
kind: String
direction: String
text: String
audioUrl: String
at: String
durationSec: Int
transcript: [String!]
}
input CreateWorkspaceDocumentInput {
title: String!
owner: String
scope: String!
summary: String!
body: String
}
type MePayload {
user: MeUser!
team: MeTeam!
conversation: Conversation!
}
type MeUser {
id: ID!
phone: String!
name: String!
}
type MeTeam {
id: ID!
name: String!
}
type Conversation {
id: ID!
title: String!
createdAt: String!
updatedAt: String!
lastMessageAt: String
lastMessageText: String
}
type PilotMessage {
id: ID!
role: String!
text: String!
messageKind: String
requestId: String
eventType: String
phase: String
transient: Boolean
thinking: [String!]!
tools: [String!]!
toolRuns: [PilotToolRun!]!
changeSetId: String
changeStatus: String
changeSummary: String
changeItems: [PilotChangeItem!]!
createdAt: String!
}
type PilotChangeItem {
id: ID!
entity: String!
entityId: String
action: String!
title: String!
before: String!
after: String!
rolledBack: Boolean!
}
type PilotToolRun {
name: String!
status: String!
input: String!
output: String!
at: String!
}
type ClientTimelineItem {
id: ID!
contactId: String!
contentType: String!
contentId: String!
datetime: String!
message: CommItem
calendarEvent: CalendarEvent
recommendation: FeedCard
document: WorkspaceDocument
}
type Contact {
id: ID!
name: String!
avatar: String!
channels: [String!]!
lastContactAt: String!
description: String!
}
type CommItem {
id: ID!
at: String!
contactId: String!
contact: String!
contactInboxId: String!
sourceExternalId: String!
sourceTitle: String!
channel: String!
kind: String!
direction: String!
text: String!
audioUrl: String!
duration: String!
waveform: [Float!]!
transcript: [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 {
id: ID!
title: String!
start: String!
end: String!
contact: String!
note: String!
isArchived: Boolean!
createdAt: String!
archiveNote: String!
archivedAt: String!
}
type Deal {
id: ID!
contact: String!
title: String!
stage: String!
amount: String!
nextStep: String!
summary: String!
currentStepId: String!
steps: [DealStep!]!
}
type DealStep {
id: ID!
title: String!
description: String!
status: String!
dueAt: String!
order: Int!
completedAt: String!
}
type FeedCard {
id: ID!
at: String!
contact: String!
text: String!
proposal: FeedProposal!
decision: String!
decisionNote: String!
}
type FeedProposal {
title: String!
details: [String!]!
key: String!
}
type CommPin {
id: ID!
contact: String!
text: String!
}
type WorkspaceDocument {
id: ID!
title: String!
type: String!
owner: String!
scope: String!
updatedAt: String!
summary: String!
body: String!
}

View File

@@ -27,6 +27,14 @@ export default defineNuxtConfig({
default: {
httpEndpoint: process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql",
connectToDevTools: process.dev,
httpLinkOptions: {
credentials: "include",
},
defaultOptions: {
watchQuery: {
fetchPolicy: "cache-and-network",
},
},
},
},
},