feat: add support to freight backoffice

This commit is contained in:
Nathnael
2026-07-17 11:00:09 +00:00
parent 1fd46afaaa
commit f278756d76
4 changed files with 670 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
import type {
SendSupportMessageDto,
SupportConversationDto,
SupportConversationListResult,
SupportMessageDto,
} from "@edr/types";
import { api } from "@/auth/http";
export interface ListConversationsParams {
search?: string;
unreadOnly?: boolean;
page?: number;
limit?: number;
}
/**
* Backoffice (agent) support-chat REST calls. The backoffice axios `api`
* response interceptor already unwraps the `{ success, data }` envelope, so
* `.data` here is the payload itself.
*/
export const supportApi = {
listConversations: async (
params: ListConversationsParams = {},
): Promise<SupportConversationListResult> => {
const { data } = await api.get<SupportConversationListResult>(
"/support/agent/conversations",
{ params },
);
return data;
},
listMessages: async (id: string): Promise<SupportMessageDto[]> => {
const { data } = await api.get<SupportMessageDto[]>(
`/support/agent/conversations/${id}/messages`,
);
return data;
},
sendMessage: async (
id: string,
body: SendSupportMessageDto,
): Promise<SupportMessageDto> => {
const { data } = await api.post<SupportMessageDto>(
`/support/agent/conversations/${id}/messages`,
body,
);
return data;
},
/** Open the thread with a company, or hand back the existing one. */
startConversation: async (
companyId: string,
): Promise<SupportConversationDto> => {
const { data } = await api.post<SupportConversationDto>(
"/support/agent/conversations",
{ companyId },
);
return data;
},
markRead: async (id: string): Promise<{ unreadCount: number }> => {
const { data } = await api.post<{ unreadCount: number }>(
`/support/agent/conversations/${id}/read`,
);
return data;
},
unreadCount: async (): Promise<number> => {
const { data } = await api.get<{ unreadCount: number }>(
"/support/agent/unread-count",
);
return data.unreadCount;
},
};

View File

@@ -0,0 +1,71 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supportApi, type ListConversationsParams } from "./supportApi";
export const SUPPORT_KEY = ["support"] as const;
export const SUPPORT_CONVERSATIONS_KEY = ["support", "conversations"] as const;
export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
export const supportMessagesKey = (id: string) =>
["support", "messages", id] as const;
/** Shared inbox: every thread, filterable by unread + company-name search. */
export function useConversations(params: ListConversationsParams = {}) {
return useQuery({
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
queryFn: () => supportApi.listConversations({ limit: 100, ...params }),
});
}
export function useMessages(conversationId: string | null) {
return useQuery({
queryKey: supportMessagesKey(conversationId ?? ""),
queryFn: () => supportApi.listMessages(conversationId as string),
enabled: !!conversationId,
});
}
export function useSupportUnreadCount(enabled = true) {
return useQuery({
queryKey: SUPPORT_UNREAD_KEY,
queryFn: () => supportApi.unreadCount(),
enabled,
refetchInterval: 60_000,
});
}
export function useSendMessage(conversationId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: string) =>
supportApi.sendMessage(conversationId, { body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});
}
/**
* Start chatting with a company. Idempotent server-side, so picking a company
* that already has a thread just selects it.
*/
export function useStartConversation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (companyId: string) => supportApi.startConversation(companyId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
},
});
}
export function useMarkConversationRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => supportApi.markRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
},
});
}

View File

@@ -0,0 +1,70 @@
import {
SUPPORT_CHAT_WS_EVENTS,
SUPPORT_CHAT_WS_NAMESPACE,
type SupportConversationDto,
type SupportMessageEvent,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { io } from "socket.io-client";
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
SUPPORT_CONVERSATIONS_KEY,
SUPPORT_UNREAD_KEY,
supportMessagesKey,
} from "./useSupport";
// The socket namespace lives at the server root, not under the `/api` REST
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
/**
* Subscribes the signed-in agent to live support-chat pushes for the whole
* shared inbox. Any new message or conversation change refreshes the affected
* thread, the inbox list, and the unread badge; `onMessage` fires for toasts.
*/
export function useSupportSocket(
enabled: boolean,
onMessage?: (event: SupportMessageEvent) => void,
) {
const qc = useQueryClient();
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
useEffect(() => {
if (!enabled) return;
const token = getCookie(AUTH_TOKEN_COOKIE);
if (!token) return;
const socket = io(`${SOCKET_ORIGIN}/${SUPPORT_CHAT_WS_NAMESPACE}`, {
auth: { token },
transports: ["websocket"],
withCredentials: true,
});
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
qc.invalidateQueries({
queryKey: supportMessagesKey(event.message.conversationId),
});
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
onMessageRef.current?.(event);
});
socket.on(
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
(_conversation: SupportConversationDto) => {
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
},
);
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}