admin/services/chat.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

355 lines
10 KiB
TypeScript

import type {
ConversationParticipantResponseDto,
ConversationResponseDto,
MessageReplyToDto,
MessageResponseDto,
SendMessageDto,
} from '@/api/generated/models'
import {
createServiceError,
errorResult,
handleServiceError,
type ServiceCallOptions,
type ServiceResult,
shouldBubbleErrorToParent,
successResult,
} from '@/services/errorHandler'
import { unwrapApiData } from '@/services/apiResponse'
import { getChat } from '@/api/generated/chat/chat'
import { texts } from '@/texts'
import { parseRemittanceList } from '@/helpers/listResponse'
export type Conversation = ConversationResponseDto
export type ChatMessage = MessageResponseDto
export type ChatMessageReplyTo = MessageReplyToDto
export type ConversationTypeFilter = 'direct' | 'event_group'
export const CONVERSATIONS_PAGE_SIZE = 20
/** Direction-based history page (same field meaning in before / after / around modes). */
export interface ChatMessagesPage {
items: ChatMessage[]
olderCursor: string | null
newerCursor: string | null
hasMoreBefore: boolean
hasMoreAfter: boolean
aroundMessageId: string | null
}
export interface ListMessagesParams {
before?: string
after?: string
around?: string
pageSize?: number
}
export type SendChatMessagePayload = Pick<SendMessageDto, 'body' | 'imageUrl' | 'replyToMessageId'>
export interface ConversationsPage {
items: Conversation[]
totalItemsCount: number
}
const chatApi = getChat()
export const LIST_CONVERSATIONS = async (
params?: { type?: ConversationTypeFilter; page?: number; pageSize?: number },
options?: ServiceCallOptions
): Promise<ServiceResult<ConversationsPage>> => {
const page = params?.page ?? 1
const pageSize = params?.pageSize ?? CONVERSATIONS_PAGE_SIZE
try {
const res = await chatApi.chatControllerListMine({
page,
pageSize,
sort: '-lastMessageAt',
...(params?.type ? { 'filters[type]': params.type } : {}),
})
const payload = res.data
if (!payload.success) {
throw createServiceError(payload.message || texts.chats.listConversationsFailed)
}
const parsed = parseRemittanceList<Conversation>(unwrapApiData(payload) ?? {}, 'items', page, pageSize)
return successResult({ items: parsed.items, totalItemsCount: parsed.pagination.totalItemsCount })
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
export const GET_CONVERSATION = async (conversationId: string, options?: ServiceCallOptions): Promise<ServiceResult<Conversation>> => {
try {
const res = await chatApi.chatControllerGetOne(conversationId)
const payload = res.data
if (!payload.success) {
throw createServiceError(payload.message || texts.chats.getConversationFailed)
}
const conversation = unwrapApiData(payload)
if (!conversation?.id) {
throw createServiceError(texts.common.invalidServerResponse)
}
return successResult(conversation)
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
export const GET_UNREAD_CHAT_COUNT = async (options?: ServiceCallOptions): Promise<ServiceResult<{ totalUnread: number }>> => {
try {
const res = await chatApi.chatControllerUnreadCount()
const payload = res.data
if (!payload.success) {
throw createServiceError(payload.message || texts.chats.unreadCountFailed)
}
const data = unwrapApiData(payload)
return successResult({ totalUnread: data?.totalUnread ?? 0 })
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
export const MARK_CONVERSATION_READ = async (
conversationId: string,
lastSeenMessageId: string,
options?: ServiceCallOptions
): Promise<ServiceResult<{ totalUnread: number }>> => {
try {
const res = await chatApi.chatControllerMarkRead(conversationId, { lastSeenMessageId })
const payload = res.data
if (!payload.success) {
throw createServiceError(payload.message || texts.chats.markReadFailed)
}
const data = unwrapApiData(payload)
return successResult({ totalUnread: data?.totalUnread ?? 0 })
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
const MESSAGES_PAGE_SIZE = 50
/**
* Fetches one page of conversation history.
*
* Cursor modes are mutually exclusive (`before` | `after` | `around`). Response
* cursors are direction-based: `olderCursor` → next `before`, `newerCursor` →
* next `after`, regardless of which mode produced the page.
*/
export const LIST_MESSAGES = async (
conversationId: string,
params?: ListMessagesParams,
options?: ServiceCallOptions
): Promise<ServiceResult<ChatMessagesPage>> => {
try {
const pageSize = params?.pageSize ?? MESSAGES_PAGE_SIZE
const res = await chatApi.chatControllerListMessages(conversationId, {
pageSize,
...(params?.before ? { before: params.before } : {}),
...(params?.after ? { after: params.after } : {}),
...(params?.around ? { around: params.around } : {}),
})
const payload = res.data
if (!payload.success) {
throw createServiceError(payload.message || texts.chats.listMessagesFailed)
}
const data = unwrapApiData(payload)
const parsed = parseRemittanceList<ChatMessage>(data ?? {}, 'items', 1, pageSize)
const meta = data?.response
return successResult({
items: parsed.items,
olderCursor: meta?.olderCursor ?? null,
newerCursor: meta?.newerCursor ?? null,
hasMoreBefore: meta?.hasMoreBefore ?? false,
hasMoreAfter: meta?.hasMoreAfter ?? false,
aroundMessageId: meta?.aroundMessageId ?? null,
})
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
export const SEND_MESSAGE = async (
conversationId: string,
payload: SendChatMessagePayload,
options?: ServiceCallOptions
): Promise<ServiceResult<ChatMessage>> => {
try {
const res = await chatApi.chatControllerSendGroup(conversationId, payload)
const responsePayload = res.data
if (!responsePayload.success) {
throw createServiceError(responsePayload.message || texts.chats.sendFailed, responsePayload.code)
}
const message = unwrapApiData(responsePayload)
if (!message?.id) {
throw createServiceError(texts.common.invalidServerResponse)
}
return successResult(message)
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
/**
* Sends the first (or a subsequent) message of a direct conversation by the
* other participant's user id, without needing a conversationId up front.
* Backend resolves/creates the canonical direct conversation for the pair
* (`get_or_create_direct_conversation`) and inserts the message in one call —
* see docs/workflows/fa/chat.md, "جریان A — پیام مستقیم".
*/
export const SEND_DIRECT_MESSAGE = async (
otherUserId: string,
payload: SendChatMessagePayload,
options?: ServiceCallOptions
): Promise<ServiceResult<ChatMessage>> => {
try {
const res = await chatApi.chatControllerSendDirect(otherUserId, payload)
const responsePayload = res.data
if (!responsePayload.success) {
throw createServiceError(responsePayload.message || texts.chats.sendFailed, responsePayload.code)
}
const message = unwrapApiData(responsePayload)
if (!message?.id) {
throw createServiceError(texts.common.invalidServerResponse)
}
return successResult(message)
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
export const ENSURE_DIRECT_CONVERSATION = async (
otherUserId: string,
options?: ServiceCallOptions
): Promise<ServiceResult<Conversation>> => {
try {
const res = await chatApi.chatControllerEnsureDirect(otherUserId)
const payload = res.data
if (!payload.success) {
throw createServiceError(payload.message || texts.chats.openConversationFailed, payload.code)
}
const conversation = unwrapApiData(payload)
if (!conversation?.id) {
throw createServiceError(texts.common.invalidServerResponse)
}
return successResult(conversation)
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}
export type ChatParticipant = ConversationParticipantResponseDto
export const LIST_PARTICIPANTS = async (
conversationId: string,
params?: { page?: number; pageSize?: number },
options?: ServiceCallOptions
): Promise<ServiceResult<{ items: ChatParticipant[] }>> => {
try {
const res = await chatApi.chatControllerListParticipants(conversationId, {
page: params?.page ?? 1,
pageSize: params?.pageSize ?? 100,
sort: 'joinedAt',
})
const payload = res.data
if (!payload.success) {
throw createServiceError(payload.message || texts.chats.listParticipantsFailed)
}
const parsed = parseRemittanceList<ChatParticipant>(unwrapApiData(payload) ?? {}, 'items')
return successResult({ items: parsed.items })
} catch (error) {
const normalizedError = handleServiceError(error, options)
if (shouldBubbleErrorToParent(options)) {
throw normalizedError
}
return errorResult(normalizedError)
}
}