Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
355 lines
10 KiB
TypeScript
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)
|
|
}
|
|
}
|