chore: complete clean Agent Desk source snapshot
This commit is contained in:
2654
web/lib/api/admin.ts
Normal file
2654
web/lib/api/admin.ts
Normal file
File diff suppressed because it is too large
Load Diff
277
web/lib/api/agent.ts
Normal file
277
web/lib/api/agent.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { ConversationSatisfactionResponse } from "@/lib/api/im"
|
||||
|
||||
export type Paging = {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type PageResult<T> = {
|
||||
results: T[]
|
||||
page: Paging
|
||||
}
|
||||
|
||||
export type CursorResult<T> = {
|
||||
results: T[]
|
||||
cursor: string
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export type AgentConversationTag = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AgentConversationParticipant = {
|
||||
id: number
|
||||
participantType: string
|
||||
participantId: number
|
||||
externalParticipantId?: string
|
||||
joinedAt?: string
|
||||
leftAt?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export type AgentConversation = {
|
||||
id: number
|
||||
aiAgentId?: number
|
||||
channelId?: number
|
||||
customerId?: number
|
||||
customerName: string
|
||||
status: number
|
||||
serviceMode: number
|
||||
priority: number
|
||||
currentAssigneeId: number
|
||||
currentAssigneeName?: string
|
||||
currentTeamId: number
|
||||
currentTeamName?: string
|
||||
lastMessageId: number
|
||||
lastMessageAt?: string
|
||||
lastActiveAt?: string
|
||||
lastMessageSummary?: string
|
||||
customerUnreadCount: number
|
||||
agentUnreadCount: number
|
||||
customerLastReadMessageId: number
|
||||
customerLastReadAt?: string
|
||||
agentLastReadMessageId: number
|
||||
agentLastReadAt?: string
|
||||
customerOnline: boolean
|
||||
closedAt?: string
|
||||
tags?: AgentConversationTag[]
|
||||
participants?: AgentConversationParticipant[]
|
||||
satisfaction?: ConversationSatisfactionResponse | null
|
||||
}
|
||||
|
||||
export type AgentConversationDetail = AgentConversation & {
|
||||
participants?: AgentConversationParticipant[]
|
||||
}
|
||||
|
||||
export type AgentMessage = {
|
||||
id: number
|
||||
conversationId: number
|
||||
workflowRunId?: number
|
||||
clientMsgId?: string
|
||||
senderType: string
|
||||
senderId: number
|
||||
senderName?: string
|
||||
senderAvatar?: string
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
sendStatus: number
|
||||
sentAt?: string
|
||||
deliveredAt?: string
|
||||
readAt?: string
|
||||
customerRead: boolean
|
||||
customerReadAt?: string
|
||||
agentRead: boolean
|
||||
agentReadAt?: string
|
||||
recalledAt?: string
|
||||
quotedMessageId?: number
|
||||
}
|
||||
|
||||
export type AgentAsset = {
|
||||
id: number
|
||||
assetId: string
|
||||
provider: string
|
||||
storageKey: string
|
||||
filename: string
|
||||
fileSize: number
|
||||
mimeType: string
|
||||
status: number
|
||||
url: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createUserId: number
|
||||
createUserName: string
|
||||
updateUserId: number
|
||||
updateUserName: string
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchAgentConversations(
|
||||
query?: Record<string, string | number | undefined>
|
||||
) {
|
||||
return request<PageResult<AgentConversation>>(
|
||||
`/api/dashboard/conversation/conversations${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAgentConversationDetail(id: number) {
|
||||
return request<AgentConversationDetail>(`/api/dashboard/conversation/${id}`)
|
||||
}
|
||||
|
||||
export function fetchAgentMessages(
|
||||
query?: Record<string, string | number | undefined>
|
||||
) {
|
||||
return request<CursorResult<AgentMessage>>(
|
||||
`/api/dashboard/conversation/message_list${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function sendAgentMessage(payload: {
|
||||
conversationId: number
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
clientMsgId?: string
|
||||
}) {
|
||||
return request<AgentMessage>("/api/dashboard/conversation/send_message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function recallAgentMessage(messageId: number) {
|
||||
return request<AgentMessage>("/api/dashboard/conversation/recall_message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ messageId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function markAgentMessageRead(conversationId: number, messageId = 0) {
|
||||
return request<void>("/api/dashboard/conversation/read", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, messageId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadAgentConversationImage(conversationId: number, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set("conversationId", String(conversationId))
|
||||
formData.set("file", file)
|
||||
return request<AgentAsset>("/api/dashboard/conversation/upload_image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadAgentConversationAttachment(conversationId: number, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set("conversationId", String(conversationId))
|
||||
formData.set("file", file)
|
||||
return request<AgentAsset>("/api/dashboard/conversation/upload_attachment", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
export function closeAgentConversation(
|
||||
conversationId: number,
|
||||
closeReason: string
|
||||
) {
|
||||
return request<void>("/api/dashboard/conversation/close", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, closeReason }),
|
||||
})
|
||||
}
|
||||
|
||||
export function assignAgentConversation(
|
||||
conversationId: number,
|
||||
assigneeId: number,
|
||||
reason: string
|
||||
) {
|
||||
return request<void>("/api/dashboard/conversation/assign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, assigneeId, reason }),
|
||||
})
|
||||
}
|
||||
|
||||
export function transferAgentConversation(
|
||||
conversationId: number,
|
||||
toUserId: number,
|
||||
reason: string
|
||||
) {
|
||||
return request<void>("/api/dashboard/conversation/transfer", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, toUserId, reason }),
|
||||
})
|
||||
}
|
||||
|
||||
export function linkConversationToCustomer(payload: {
|
||||
conversationId: number
|
||||
customerId: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/conversation/link_customer", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function addConversationTag(payload: {
|
||||
conversationId: number
|
||||
tagId: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/conversation/add_tag", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function removeConversationTag(payload: {
|
||||
conversationId: number
|
||||
tagId: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/conversation/remove_tag", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function suggestAgentReply(conversationId: number): Promise<string> {
|
||||
const res = await request<{ suggestion: string }>(
|
||||
"/api/dashboard/conversation/suggest_reply",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId }),
|
||||
}
|
||||
)
|
||||
return res?.suggestion ?? ""
|
||||
}
|
||||
|
||||
export async function summarizeAgentConversation(conversationId: number): Promise<string> {
|
||||
const res = await request<{ summary: string }>(
|
||||
"/api/dashboard/conversation/summarize",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId }),
|
||||
}
|
||||
)
|
||||
return res?.summary ?? ""
|
||||
}
|
||||
75
web/lib/api/auth.ts
Normal file
75
web/lib/api/auth.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth"
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type LoginRequest = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type UpdateProfileRequest = {
|
||||
avatar: string
|
||||
email?: string
|
||||
nickname: string
|
||||
}
|
||||
|
||||
export type ProfileAvatarAsset = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export async function loginWithPassword(payload: LoginRequest) {
|
||||
const data = await request<AuthSession>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
skipAuth: true,
|
||||
})
|
||||
writeSession(data)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangeOIDCTicket(ticket: string) {
|
||||
const data = await request<AuthSession>("/api/auth/oidc_exchange", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticket }),
|
||||
skipAuth: true,
|
||||
})
|
||||
writeSession(data)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchProfile() {
|
||||
return request<AuthSession>("/api/auth/profile")
|
||||
}
|
||||
|
||||
export async function updateProfile(payload: UpdateProfileRequest) {
|
||||
const data = await request<AuthSession>("/api/auth/profile/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const stored = readSession()
|
||||
const nextSession: AuthSession = {
|
||||
...data,
|
||||
accessToken: data.accessToken || stored?.accessToken || "",
|
||||
expiresAt: data.expiresAt || stored?.expiresAt,
|
||||
}
|
||||
writeSession(nextSession)
|
||||
return nextSession
|
||||
}
|
||||
|
||||
export function uploadProfileAvatar(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set("file", file)
|
||||
return request<ProfileAvatarAsset>("/api/auth/profile/avatar/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
try {
|
||||
await request("/api/auth/logout", {
|
||||
method: "POST",
|
||||
})
|
||||
} finally {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
152
web/lib/api/client.ts
Normal file
152
web/lib/api/client.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { expireSession, readSession } from "@/lib/auth"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
|
||||
|
||||
type JsonResult<T> = {
|
||||
errorCode: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
}
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
skipAuth?: boolean
|
||||
baseUrl?: string
|
||||
onResponse?: (response: Response) => void
|
||||
}
|
||||
|
||||
export type BlobResponse = {
|
||||
blob: Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
errorCode?: number
|
||||
data?: unknown
|
||||
|
||||
constructor(message: string, errorCode?: number, data?: unknown, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = "ApiRequestError"
|
||||
this.errorCode = errorCode
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiRequestError(error: unknown): error is ApiRequestError {
|
||||
return error instanceof ApiRequestError
|
||||
}
|
||||
|
||||
function normalizeApiRequestError(error: unknown) {
|
||||
if (isApiRequestError(error)) {
|
||||
return error
|
||||
}
|
||||
return new ApiRequestError(
|
||||
translateCurrentMessage("api.requestFailed"),
|
||||
undefined,
|
||||
undefined,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
async function parseResult<T>(response: Response) {
|
||||
const payload = (await response.json()) as JsonResult<T>
|
||||
if (!response.ok || !payload.success) {
|
||||
if (payload.errorCode === 3000 || payload.errorCode === 3002) {
|
||||
expireSession()
|
||||
}
|
||||
throw new ApiRequestError(
|
||||
payload.message || translateCurrentMessage("api.requestFailed"),
|
||||
payload.errorCode,
|
||||
payload.data
|
||||
)
|
||||
}
|
||||
return payload.data
|
||||
}
|
||||
|
||||
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
|
||||
const session = readSession()
|
||||
const authHeaders = new Headers(headers)
|
||||
|
||||
if (!skipAuth && session?.accessToken) {
|
||||
authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
|
||||
}
|
||||
if (
|
||||
!authHeaders.has("Content-Type") &&
|
||||
body &&
|
||||
!(typeof FormData !== "undefined" && body instanceof FormData)
|
||||
) {
|
||||
authHeaders.set("Content-Type", "application/json")
|
||||
}
|
||||
return authHeaders
|
||||
}
|
||||
|
||||
function parseFilename(contentDisposition: string | null) {
|
||||
if (!contentDisposition) {
|
||||
return ""
|
||||
}
|
||||
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
|
||||
if (utf8Match?.[1]) {
|
||||
return decodeURIComponent(utf8Match[1])
|
||||
}
|
||||
const match = contentDisposition.match(/filename="?([^";]+)"?/i)
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : ""
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||
delete (rest as RequestOptions).baseUrl
|
||||
delete (rest as RequestOptions).onResponse
|
||||
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
|
||||
|
||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||
try {
|
||||
const response = await fetch(`${requestBaseUrl}${path}`, {
|
||||
...rest,
|
||||
headers: authHeaders,
|
||||
cache: "no-store",
|
||||
})
|
||||
onResponse?.(response)
|
||||
return await parseResult<T>(response)
|
||||
} catch (error) {
|
||||
throw normalizeApiRequestError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestBlob(
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<BlobResponse> {
|
||||
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||
delete (rest as RequestOptions).baseUrl
|
||||
delete (rest as RequestOptions).onResponse
|
||||
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
|
||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||
const response = await fetch(`${requestBaseUrl}${path}`, {
|
||||
...rest,
|
||||
headers: authHeaders,
|
||||
cache: "no-store",
|
||||
})
|
||||
onResponse?.(response)
|
||||
|
||||
const contentType = response.headers.get("Content-Type") ?? ""
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
await parseResult<never>(response)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
throw new ApiRequestError(translateCurrentMessage("api.requestFailed"))
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(response.statusText || translateCurrentMessage("api.requestFailed"))
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename: parseFilename(response.headers.get("Content-Disposition")),
|
||||
}
|
||||
}
|
||||
77
web/lib/api/company.ts
Normal file
77
web/lib/api/company.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { PageResult } from "@/lib/api/admin"
|
||||
|
||||
export type AdminCompany = {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
customerCount: number
|
||||
status: number
|
||||
remark: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CreateAdminCompanyPayload = {
|
||||
name: string
|
||||
code: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateAdminCompanyPayload = CreateAdminCompanyPayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchCompanies(query?: Record<string, string | number | undefined>) {
|
||||
return request<PageResult<AdminCompany>>(
|
||||
`/api/dashboard/company/list${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchCompany(id: number) {
|
||||
return request<AdminCompany>(`/api/dashboard/company/${id}`)
|
||||
}
|
||||
|
||||
export function createCompany(payload: CreateAdminCompanyPayload) {
|
||||
return request<AdminCompany>("/api/dashboard/company/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCompany(payload: UpdateAdminCompanyPayload) {
|
||||
return request<void>("/api/dashboard/company/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCompanyStatus(id: number, status: number) {
|
||||
return request<void>("/api/dashboard/company/update_status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id, status }),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCompany(id: number) {
|
||||
return request<void>("/api/dashboard/company/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
21
web/lib/api/config.ts
Normal file
21
web/lib/api/config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type PublicConfig = {
|
||||
language: string
|
||||
companyName?: string
|
||||
companyLogoUrl?: string
|
||||
passwordLoginEnabled?: boolean
|
||||
oidcEnabled: boolean
|
||||
}
|
||||
|
||||
let publicConfigPromise: Promise<PublicConfig> | null = null
|
||||
|
||||
export function fetchPublicConfig() {
|
||||
publicConfigPromise ??= request<PublicConfig>("/api/config", {
|
||||
skipAuth: true,
|
||||
}).catch((error) => {
|
||||
publicConfigPromise = null
|
||||
throw error
|
||||
})
|
||||
return publicConfigPromise
|
||||
}
|
||||
77
web/lib/api/customer-contact.ts
Normal file
77
web/lib/api/customer-contact.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { ContactType } from "@/lib/generated/enums"
|
||||
|
||||
export type AdminCustomerContact = {
|
||||
id: number
|
||||
customerId: number
|
||||
contactType: ContactType | string
|
||||
contactValue: string
|
||||
isPrimary: boolean
|
||||
isVerified: boolean
|
||||
verifiedAt?: string
|
||||
source: string
|
||||
status: number
|
||||
remark: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CreateCustomerContactPayload = {
|
||||
customerId: number
|
||||
contactType: ContactType | string
|
||||
contactValue: string
|
||||
isPrimary: boolean
|
||||
isVerified: boolean
|
||||
source: string
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateCustomerContactPayload = Omit<
|
||||
CreateCustomerContactPayload,
|
||||
"customerId"
|
||||
> & {
|
||||
id: number
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchCustomerContacts(customerId: number) {
|
||||
return request<AdminCustomerContact[]>(
|
||||
`/api/dashboard/customer-contact/list${toQueryString({ customerId })}`
|
||||
)
|
||||
}
|
||||
|
||||
export function createCustomerContact(payload: CreateCustomerContactPayload) {
|
||||
return request<AdminCustomerContact>("/api/dashboard/customer-contact/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCustomerContact(payload: UpdateCustomerContactPayload) {
|
||||
return request<void>("/api/dashboard/customer-contact/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCustomerContact(id: number) {
|
||||
return request<void>("/api/dashboard/customer-contact/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
108
web/lib/api/customer.ts
Normal file
108
web/lib/api/customer.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { PageResult } from "@/lib/api/admin"
|
||||
import type { ContactType } from "@/lib/generated/enums"
|
||||
import { AdminCompany } from "./company"
|
||||
|
||||
export type AdminCustomer = {
|
||||
id: number
|
||||
name: string
|
||||
gender: number
|
||||
companyId: number
|
||||
company?: AdminCompany
|
||||
lastActiveAt?: string
|
||||
primaryMobile: string
|
||||
primaryEmail: string
|
||||
status: number
|
||||
remark: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CreateAdminCustomerPayload = {
|
||||
name: string
|
||||
gender: number
|
||||
companyId: number
|
||||
primaryMobile: string
|
||||
primaryEmail: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateAdminCustomerPayload = CreateAdminCustomerPayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
/** Matches POST /customer/save/profile request body. */
|
||||
export type SaveCustomerProfileContactLine = {
|
||||
id?: number
|
||||
contactType: ContactType | string
|
||||
contactValue: string
|
||||
remark: string
|
||||
isPrimary: boolean
|
||||
}
|
||||
|
||||
export type SaveCustomerProfilePayload = {
|
||||
id?: number
|
||||
name: string
|
||||
gender: number
|
||||
companyId: number
|
||||
remark: string
|
||||
contacts: SaveCustomerProfileContactLine[]
|
||||
}
|
||||
|
||||
/** Matches POST /customer/list JSON body. */
|
||||
export type CustomerListRequest = {
|
||||
page: number
|
||||
limit: number
|
||||
status?: number
|
||||
gender?: number
|
||||
companyId?: number
|
||||
/** Fuzzy match against customer name, primary phone, primary email, contacts, and company name. */
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export function fetchCustomers(body: CustomerListRequest) {
|
||||
return request<PageResult<AdminCustomer>>("/api/dashboard/customer/list", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCustomer(id: number) {
|
||||
return request<AdminCustomer | null>(`/api/dashboard/customer/${id}`)
|
||||
}
|
||||
|
||||
export function createCustomer(payload: CreateAdminCustomerPayload) {
|
||||
return request<AdminCustomer>("/api/dashboard/customer/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
/** Saves the customer profile and the full contact list in one request and transaction. */
|
||||
export function saveCustomerProfile(payload: SaveCustomerProfilePayload) {
|
||||
return request<AdminCustomer>("/api/dashboard/customer/save_profile", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCustomer(payload: UpdateAdminCustomerPayload) {
|
||||
return request<void>("/api/dashboard/customer/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCustomerStatus(id: number, status: number) {
|
||||
return request<void>("/api/dashboard/customer/update_status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id, status }),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCustomer(id: number) {
|
||||
return request<void>("/api/dashboard/customer/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
81
web/lib/api/dashboard.ts
Normal file
81
web/lib/api/dashboard.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type DashboardRange = "today" | "7d" | "30d"
|
||||
|
||||
export type DashboardStatusDistributionItem = {
|
||||
status: number
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export type DashboardTrendItem = {
|
||||
date: string
|
||||
newCount: number
|
||||
closedCount: number
|
||||
}
|
||||
|
||||
export type DashboardTeamLoad = {
|
||||
teamId: number
|
||||
teamName: string
|
||||
totalAgents: number
|
||||
onlineAgents: number
|
||||
busyAgents: number
|
||||
offlineAgents: number
|
||||
waitingConversations: number
|
||||
processingConversations: number
|
||||
maxConcurrentCapacity: number
|
||||
loadRate: number
|
||||
hasScheduleNow: boolean
|
||||
}
|
||||
|
||||
export type DashboardAlert = {
|
||||
id: string
|
||||
level: "info" | "warning" | "error"
|
||||
title: string
|
||||
description: string
|
||||
count: number
|
||||
link: string
|
||||
}
|
||||
|
||||
export type DashboardQuickLink = {
|
||||
title: string
|
||||
description: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export type DashboardOverview = {
|
||||
range: DashboardRange
|
||||
generatedAt: string
|
||||
summary: {
|
||||
todayNewConversations: number
|
||||
processingConversations: number
|
||||
pendingDispatchConversations: number
|
||||
onlineAgents: number
|
||||
aiServiceRate: number
|
||||
}
|
||||
conversationStats: {
|
||||
statusDistribution: DashboardStatusDistributionItem[]
|
||||
trend: DashboardTrendItem[]
|
||||
}
|
||||
agentStats: {
|
||||
onlineAgents: number
|
||||
busyAgents: number
|
||||
offlineAgents: number
|
||||
teamLoads: DashboardTeamLoad[]
|
||||
}
|
||||
aiStats: {
|
||||
enabledAiAgents: number
|
||||
enabledChannels: number
|
||||
todayKnowledgeRetrieves: number
|
||||
todayKnowledgeRetrieveFailCount: number
|
||||
todayKnowledgeRetrieveFailRate: number
|
||||
todayAgentRunFailCount: number
|
||||
todayAiHandoffCount: number
|
||||
}
|
||||
alerts: DashboardAlert[]
|
||||
quickLinks: DashboardQuickLink[]
|
||||
}
|
||||
|
||||
export function fetchDashboardOverview(range: DashboardRange) {
|
||||
return request<DashboardOverview>(`/api/dashboard/dashboard/overview?range=${range}`)
|
||||
}
|
||||
595
web/lib/api/im.ts
Normal file
595
web/lib/api/im.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
|
||||
import { generateUUID, parseApiTimestamp } from "@/lib/utils"
|
||||
|
||||
export type Paging = {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type PageResult<T> = {
|
||||
results?: T[] | null
|
||||
page: Paging
|
||||
cursor?: string
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
export type ImConversationTag = {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type ImConversationParticipant = {
|
||||
id: number
|
||||
participantType: string
|
||||
participantId: number
|
||||
externalParticipantId?: string
|
||||
joinedAt?: string
|
||||
leftAt?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface SubmitSatisfactionParams {
|
||||
conversationId: number
|
||||
score: number
|
||||
feedback?: string
|
||||
}
|
||||
|
||||
export interface ConversationSatisfactionResponse {
|
||||
id: number
|
||||
conversationId: number
|
||||
score: number
|
||||
feedback: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type ImConversation = {
|
||||
id: number
|
||||
channelId: number
|
||||
customerName: string
|
||||
subject?: string
|
||||
status: number
|
||||
serviceMode: number
|
||||
priority: number
|
||||
currentAssigneeId: number
|
||||
currentAssigneeName?: string
|
||||
currentTeamId?: number
|
||||
lastMessageId: number
|
||||
lastMessageAt?: string
|
||||
lastActiveAt?: string
|
||||
lastMessageSummary?: string
|
||||
customerUnreadCount: number
|
||||
agentUnreadCount: number
|
||||
customerLastReadMessageId: number
|
||||
customerLastReadAt?: string
|
||||
agentLastReadMessageId: number
|
||||
agentLastReadAt?: string
|
||||
closedAt?: string
|
||||
tags?: ImConversationTag[]
|
||||
participants?: ImConversationParticipant[]
|
||||
satisfaction?: ConversationSatisfactionResponse | null
|
||||
}
|
||||
|
||||
export type ImConversationDetail = ImConversation
|
||||
export type ConversationResponse = ImConversation
|
||||
|
||||
export type ImMessage = {
|
||||
id: number
|
||||
conversationId: number
|
||||
workflowRunId?: number
|
||||
clientMsgId?: string
|
||||
senderType: string
|
||||
senderId: number
|
||||
senderName?: string
|
||||
senderAvatar?: string
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
sendStatus: number
|
||||
sentAt?: string
|
||||
deliveredAt?: string
|
||||
readAt?: string
|
||||
customerRead: boolean
|
||||
customerReadAt?: string
|
||||
agentRead: boolean
|
||||
agentReadAt?: string
|
||||
recalledAt?: string
|
||||
quotedMessageId?: number
|
||||
}
|
||||
|
||||
export type ImAsset = {
|
||||
id: number
|
||||
assetId: string
|
||||
provider: string
|
||||
storageKey: string
|
||||
filename: string
|
||||
fileSize: number
|
||||
mimeType: string
|
||||
status: number
|
||||
url: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createUserId: number
|
||||
createUserName: string
|
||||
updateUserId: number
|
||||
updateUserName: string
|
||||
}
|
||||
|
||||
export type WidgetConfigResponse = {
|
||||
channelId?: string
|
||||
channelType?: string
|
||||
userToken?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
themeColor?: string
|
||||
position?: "left" | "right"
|
||||
width?: string
|
||||
promptStarters?: string[]
|
||||
}
|
||||
|
||||
export type ImWidgetConfig = WidgetConfigResponse
|
||||
|
||||
export type ImCustomerSessionCustomer = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ImCustomerSessionExchangeResponse = {
|
||||
customerSessionToken: string
|
||||
expiresAt: string
|
||||
identityKey: string
|
||||
customer: ImCustomerSessionCustomer
|
||||
}
|
||||
|
||||
export type ImCustomerSession = ImCustomerSessionExchangeResponse & {
|
||||
channelId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
const GUEST_STORAGE_KEY = "cs_ai_agent_im_guest_id"
|
||||
const CUSTOMER_SESSION_STORAGE_KEY = "cs_ai_agent_customer_session"
|
||||
const CUSTOMER_SESSION_TOKEN_HEADER = "X-Customer-Session-Token"
|
||||
const CUSTOMER_SESSION_EXPIRES_HEADER = "X-Customer-Session-Expires-At"
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
|
||||
const OPEN_IM_CHANNEL_ID =
|
||||
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
|
||||
|
||||
let entryIdentityExchangeKey = ""
|
||||
|
||||
function buildGuestId() {
|
||||
return `guest_${generateUUID()}`
|
||||
}
|
||||
|
||||
export function getGuestId() {
|
||||
if (typeof window === "undefined") {
|
||||
return ""
|
||||
}
|
||||
const existing = window.localStorage.getItem(GUEST_STORAGE_KEY)?.trim()
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const guestId = buildGuestId()
|
||||
window.localStorage.setItem(GUEST_STORAGE_KEY, guestId)
|
||||
return guestId
|
||||
}
|
||||
|
||||
function getRuntimeImConfig() {
|
||||
const widgetConfig = readSupportChatRuntimeConfig()
|
||||
const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL)
|
||||
.trim()
|
||||
.replace(/\/$/, "")
|
||||
return {
|
||||
baseUrl,
|
||||
channelId: widgetConfig.channelId || OPEN_IM_CHANNEL_ID,
|
||||
externalId: (widgetConfig.externalId || "").trim(),
|
||||
externalIdSignature: (widgetConfig.externalIdSignature || "").trim(),
|
||||
externalIdSignedAt: Number(widgetConfig.externalIdSignedAt) || 0,
|
||||
externalName: (widgetConfig.externalName || "").trim(),
|
||||
userToken: (widgetConfig.userToken || "").trim(),
|
||||
requireUserToken: widgetConfig.requireUserToken === true,
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeImConfig = ReturnType<typeof getRuntimeImConfig>
|
||||
|
||||
function identityContextKey(config: RuntimeImConfig) {
|
||||
return JSON.stringify([
|
||||
config.baseUrl,
|
||||
config.channelId,
|
||||
config.externalId,
|
||||
config.userToken,
|
||||
config.externalIdSignature,
|
||||
config.externalIdSignedAt,
|
||||
config.requireUserToken,
|
||||
])
|
||||
}
|
||||
|
||||
function requireEntryToken(config: RuntimeImConfig) {
|
||||
if (config.requireUserToken && !config.userToken) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
}
|
||||
}
|
||||
|
||||
function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImConfig) {
|
||||
if (typeof session.identityKey !== "string") return false
|
||||
if (session.baseUrl !== config.baseUrl || session.channelId !== config.channelId) return false
|
||||
if (config.userToken || config.requireUserToken) {
|
||||
return (
|
||||
!!config.userToken &&
|
||||
(config.externalId
|
||||
? session.identityKey === `user:${config.externalId}`
|
||||
: session.identityKey.startsWith("user:"))
|
||||
)
|
||||
}
|
||||
const externalId = config.externalId || getGuestId()
|
||||
return (
|
||||
session.identityKey === `guest:${externalId}` ||
|
||||
(!!config.externalIdSignature && session.identityKey === `user:${externalId}`)
|
||||
)
|
||||
}
|
||||
|
||||
function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) {
|
||||
if (!session || !isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) return false
|
||||
// A prior iframe's session, especially a prior guest, cannot replace this
|
||||
// entry's JWT verification. Signed HMAC entries likewise re-exchange first.
|
||||
if ((config.userToken || config.externalIdSignature) && entryIdentityExchangeKey !== identityContextKey(config)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string } | null {
|
||||
try {
|
||||
const payload = token.split(".")[1]
|
||||
if (!payload) return null
|
||||
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const bytes = Uint8Array.from(
|
||||
atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")),
|
||||
(c) => c.charCodeAt(0)
|
||||
)
|
||||
return JSON.parse(new TextDecoder().decode(bytes))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseExpiresAt(value: string) {
|
||||
return parseApiTimestamp(value)
|
||||
}
|
||||
|
||||
function isCustomerSessionValid(
|
||||
session: ImCustomerSession | null,
|
||||
channelId?: string,
|
||||
identityKey?: string
|
||||
) {
|
||||
if (!session?.customerSessionToken || !session.expiresAt) {
|
||||
return false
|
||||
}
|
||||
if (channelId && session.channelId !== channelId) {
|
||||
return false
|
||||
}
|
||||
if (identityKey && session.identityKey !== identityKey) {
|
||||
return false
|
||||
}
|
||||
return parseExpiresAt(session.expiresAt) > Date.now() + 5000
|
||||
}
|
||||
|
||||
export function readCustomerSession(): ImCustomerSession | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
const raw = window.sessionStorage.getItem(CUSTOMER_SESSION_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as ImCustomerSession
|
||||
} catch {
|
||||
window.sessionStorage.removeItem(CUSTOMER_SESSION_STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCustomerSession(session: ImCustomerSession) {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
window.sessionStorage.setItem(CUSTOMER_SESSION_STORAGE_KEY, JSON.stringify(session))
|
||||
}
|
||||
|
||||
export function getCustomerSessionToken() {
|
||||
const config = getRuntimeImConfig()
|
||||
const session = readCustomerSession()
|
||||
return usableCustomerSession(session, config)
|
||||
? session?.customerSessionToken ?? ""
|
||||
: ""
|
||||
}
|
||||
|
||||
export function applyCustomerSessionRefresh(payload?: {
|
||||
customerSessionToken?: string
|
||||
expiresAt?: string
|
||||
}) {
|
||||
const token = payload?.customerSessionToken?.trim()
|
||||
const expiresAt = payload?.expiresAt?.trim()
|
||||
if (!token || !expiresAt) {
|
||||
return
|
||||
}
|
||||
const current = readCustomerSession()
|
||||
const config = getRuntimeImConfig()
|
||||
if (!current || !usableCustomerSession(current, config)) return
|
||||
// Decode only for cache consistency, never as proof of authenticity. The
|
||||
// server still verifies signatures. A late old-account websocket/HTTP refresh
|
||||
// must not overwrite the current account's stored customer session.
|
||||
const claims = sessionTokenClaims(token)
|
||||
if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId) return
|
||||
writeCustomerSession({
|
||||
...current,
|
||||
customerSessionToken: token,
|
||||
expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
function applyCustomerSessionHeaders(response: Response) {
|
||||
applyCustomerSessionRefresh({
|
||||
customerSessionToken: response.headers.get(CUSTOMER_SESSION_TOKEN_HEADER) ?? "",
|
||||
expiresAt: response.headers.get(CUSTOMER_SESSION_EXPIRES_HEADER) ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
function createChannelHeaders() {
|
||||
const config = getRuntimeImConfig()
|
||||
return {
|
||||
"X-Channel-Id": config.channelId,
|
||||
}
|
||||
}
|
||||
|
||||
function createExchangeHeaders() {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const headers: Record<string, string> = {
|
||||
"X-Channel-Id": config.channelId,
|
||||
}
|
||||
if (config.userToken) {
|
||||
headers.Authorization = `Bearer ${config.userToken}`
|
||||
} else {
|
||||
headers["X-External-Id"] = config.externalId || getGuestId()
|
||||
// Only meaningful together with a host-supplied externalId: the browser-local
|
||||
// guest id cannot be signed by the host. Without a valid signature the
|
||||
// server treats the visitor as anonymous.
|
||||
if (config.externalId && config.externalIdSignature && config.externalIdSignedAt) {
|
||||
headers["X-External-Id-Signature"] = config.externalIdSignature
|
||||
headers["X-External-Id-Signed-At"] = String(config.externalIdSignedAt)
|
||||
}
|
||||
if (config.externalName) {
|
||||
headers["X-External-Name"] = encodeURIComponent(config.externalName)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...headers,
|
||||
}
|
||||
}
|
||||
|
||||
function createImHeaders(explicitToken?: string) {
|
||||
const sessionToken = explicitToken || getCustomerSessionToken()
|
||||
if (!sessionToken) {
|
||||
throw new Error(translateCurrentMessage("api.customerSessionNotReady"))
|
||||
}
|
||||
return {
|
||||
...createChannelHeaders(),
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
...(explicitToken ? { "X-Customer-Session-Token": explicitToken } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestOptions(
|
||||
init?: RequestInit,
|
||||
explicitToken?: string
|
||||
): RequestInit & {
|
||||
baseUrl?: string
|
||||
skipAuth?: boolean
|
||||
onResponse?: (response: Response) => void
|
||||
} {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const contextKey = identityContextKey(config)
|
||||
return {
|
||||
...init,
|
||||
skipAuth: true,
|
||||
headers: {
|
||||
...createImHeaders(explicitToken),
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
onResponse: (response) => {
|
||||
if (contextKey === identityContextKey(getRuntimeImConfig())) {
|
||||
applyCustomerSessionHeaders(response)
|
||||
}
|
||||
},
|
||||
baseUrl: config.baseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export async function exchangeCustomerSession() {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const contextKey = identityContextKey(config)
|
||||
const result = await request<ImCustomerSessionExchangeResponse>(
|
||||
"/api/customer/session_exchange",
|
||||
{
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
baseUrl: config.baseUrl,
|
||||
headers: createExchangeHeaders(),
|
||||
}
|
||||
)
|
||||
if (contextKey !== identityContextKey(getRuntimeImConfig())) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
}
|
||||
const session: ImCustomerSession = {
|
||||
...result,
|
||||
channelId: config.channelId,
|
||||
baseUrl: config.baseUrl,
|
||||
}
|
||||
if (!isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
}
|
||||
writeCustomerSession(session)
|
||||
entryIdentityExchangeKey = contextKey
|
||||
return session
|
||||
}
|
||||
|
||||
export async function ensureCustomerSession() {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const cached = readCustomerSession()
|
||||
if (usableCustomerSession(cached, config)) return cached!
|
||||
return exchangeCustomerSession()
|
||||
}
|
||||
|
||||
export function fetchImConversationDetail(id: number) {
|
||||
return request<ImConversationDetail>(`/api/conversation/${id}`, {
|
||||
...createRequestOptions(),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchImMessages(
|
||||
query?: Record<string, string | number | undefined>
|
||||
) {
|
||||
return request<PageResult<ImMessage>>(
|
||||
`/api/message/list${toQueryString(query)}`,
|
||||
createRequestOptions()
|
||||
)
|
||||
}
|
||||
|
||||
export function createOrMatchImConversation() {
|
||||
return request<ImConversation>("/api/conversation/create_or_match", {
|
||||
...createRequestOptions({ method: "POST" }),
|
||||
})
|
||||
}
|
||||
|
||||
// Starts a new request instead of rejoining the unfinished one. The server caps
|
||||
// how many open requests one visitor may have and answers with a localized
|
||||
// message when the cap is reached.
|
||||
export function createImConversation(subject?: string) {
|
||||
return request<ImConversation>("/api/conversation/create", {
|
||||
...createRequestOptions({ method: "POST" }),
|
||||
body: JSON.stringify({ subject: subject?.trim() ?? "" }),
|
||||
})
|
||||
}
|
||||
|
||||
// The visitor's own requests, newest activity first. Scoping comes from the
|
||||
// external identity the request already carries.
|
||||
export function fetchImConversationList(
|
||||
query?: Record<string, string | number | undefined>
|
||||
) {
|
||||
return request<PageResult<ImConversation>>(
|
||||
`/api/conversation/list${toQueryString(query)}`,
|
||||
createRequestOptions()
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchImWidgetConfig() {
|
||||
return request<ImWidgetConfig>(
|
||||
`/api/channel/config${toQueryString({
|
||||
channelId: getRuntimeImConfig().channelId,
|
||||
})}`,
|
||||
{
|
||||
skipAuth: true,
|
||||
baseUrl: getRuntimeImConfig().baseUrl,
|
||||
headers: createChannelHeaders(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function closeImConversation(conversationId: number) {
|
||||
return request<void>("/api/conversation/close", {
|
||||
...createRequestOptions({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function sendImMessage(payload: {
|
||||
conversationId: number
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
clientMsgId?: string
|
||||
}) {
|
||||
return request<ImMessage>("/api/message/send", {
|
||||
...createRequestOptions({
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function markImMessageRead(conversationId: number, messageId = 0) {
|
||||
return request<void>("/api/message/read", {
|
||||
...createRequestOptions({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, messageId }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadImImage(conversationId: number, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set("conversationId", String(conversationId))
|
||||
formData.set("file", file)
|
||||
return request<ImAsset>("/api/message/upload_image", {
|
||||
...createRequestOptions({
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadImAttachment(conversationId: number, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set("conversationId", String(conversationId))
|
||||
formData.set("file", file)
|
||||
return request<ImAsset>("/api/message/upload_attachment", {
|
||||
...createRequestOptions({
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function submitConversationSatisfaction(
|
||||
params: SubmitSatisfactionParams,
|
||||
token?: string
|
||||
): Promise<ConversationSatisfactionResponse> {
|
||||
return request<ConversationSatisfactionResponse>(
|
||||
"/api/conversation/satisfaction",
|
||||
createRequestOptions(
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
token
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
82
web/lib/api/notification.ts
Normal file
82
web/lib/api/notification.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { request } from "@/lib/api/client"
|
||||
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||
import type { PageResult } from "@/lib/api/admin"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
export type NotificationReadStatus = "all" | "unread" | "read"
|
||||
|
||||
export type NotificationItem = {
|
||||
id: number
|
||||
recipientUserId: number
|
||||
title: string
|
||||
content: string
|
||||
notificationType: string
|
||||
bizType: string
|
||||
bizId: number
|
||||
actionUrl: string
|
||||
readAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export type NotificationUnreadCount = {
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export type NotificationListQuery = {
|
||||
page?: number
|
||||
limit?: number
|
||||
readStatus?: NotificationReadStatus
|
||||
type?: string
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchNotifications(query?: NotificationListQuery) {
|
||||
return request<PageResult<NotificationItem>>(
|
||||
`/api/dashboard/notification/list${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchNotificationUnreadCount() {
|
||||
return request<NotificationUnreadCount>("/api/dashboard/notification/unread_count")
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: number) {
|
||||
return request<void>("/api/dashboard/notification/mark_read", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
export function markAllNotificationsRead() {
|
||||
return request<void>("/api/dashboard/notification/mark_all_read", {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
export function createNotificationWebSocketUrl() {
|
||||
const session = readSession()
|
||||
if (!session?.accessToken) {
|
||||
throw new Error(translateCurrentMessage("api.authExpired"))
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
accessToken: session.accessToken,
|
||||
})
|
||||
return `${createWebSocketBaseUrl()}/api/ws/dashboard/notification?${params.toString()}`
|
||||
}
|
||||
31
web/lib/api/organization.ts
Normal file
31
web/lib/api/organization.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type OrganizationItem = {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
logo: string
|
||||
plan: string
|
||||
status: number
|
||||
role?: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type UserOrganizationListResponse = {
|
||||
currentOrganizationId: number
|
||||
organizations: OrganizationItem[]
|
||||
}
|
||||
|
||||
export async function listMyOrganizations() {
|
||||
return request<UserOrganizationListResponse>("/api/dashboard/organization/my_list", {
|
||||
method: "GET",
|
||||
})
|
||||
}
|
||||
|
||||
export async function switchOrganization(organizationId: number) {
|
||||
return request<OrganizationItem>("/api/dashboard/organization/switch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ organizationId }),
|
||||
})
|
||||
}
|
||||
134
web/lib/api/setup-readiness.ts
Normal file
134
web/lib/api/setup-readiness.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type SetupStepStatus = "ok" | "warning" | "error"
|
||||
|
||||
export type SetupQuickFixAction =
|
||||
| "quick_setup_all"
|
||||
| "ensure_team_and_schedule"
|
||||
| "enable_my_operator_profile"
|
||||
| "fix_ai_agent_handoff"
|
||||
| "ensure_default_channel"
|
||||
|
||||
export type SetupIssueItem = {
|
||||
code: string
|
||||
severity: "warning" | "error"
|
||||
entity_type: "team" | "schedule" | "operator" | "ai_agent" | "channel" | "system"
|
||||
entity_id: number
|
||||
entity_name: string
|
||||
quick_fix_action?: SetupQuickFixAction
|
||||
}
|
||||
|
||||
export type SetupStepItem = {
|
||||
key: "teams" | "schedules" | "operators" | "ai_agents" | "channels" | "end_to_end"
|
||||
step_number: number
|
||||
status: SetupStepStatus
|
||||
summary_code: string
|
||||
total_count: number
|
||||
ready_count: number
|
||||
issues: SetupIssueItem[]
|
||||
quick_fix_actions: SetupQuickFixAction[]
|
||||
manage_path: string
|
||||
}
|
||||
|
||||
export type CurrentOperatorDiagnostic = {
|
||||
user_id: number
|
||||
username: string
|
||||
nickname: string
|
||||
has_profile: boolean
|
||||
profile_status_ok: boolean
|
||||
team_id: number
|
||||
team_name: string
|
||||
team_active: boolean
|
||||
team_has_schedule_now: boolean
|
||||
service_status: number
|
||||
auto_assign_enabled: boolean
|
||||
active_conversation_count: number
|
||||
max_concurrent_count: number
|
||||
can_receive_now: boolean
|
||||
blocker_codes: string[]
|
||||
}
|
||||
|
||||
export type SetupChannelBrief = {
|
||||
id: number
|
||||
name: string
|
||||
channel_type: string
|
||||
channel_id: string
|
||||
ai_agent_id: number
|
||||
rollout_percent: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export type SetupReadinessResponse = {
|
||||
overall_status: SetupStepStatus
|
||||
ready_steps: number
|
||||
total_steps: number
|
||||
can_receive_requests_now: boolean
|
||||
steps: SetupStepItem[]
|
||||
current_operator: CurrentOperatorDiagnostic
|
||||
channels: SetupChannelBrief[]
|
||||
}
|
||||
|
||||
export type SimulatedRouteHop = {
|
||||
hop_key: "channel" | "ai_agent" | "teams" | "schedules" | "operators"
|
||||
status: SetupStepStatus
|
||||
reason_code: string
|
||||
entity_id?: number
|
||||
entity_name?: string
|
||||
quick_fix_action?: SetupQuickFixAction
|
||||
}
|
||||
|
||||
export type SimulatedOperatorCandidate = {
|
||||
user_id: number
|
||||
display_name: string
|
||||
team_id: number
|
||||
team_name: string
|
||||
eligible: boolean
|
||||
skip_reason_code: string
|
||||
active_count: number
|
||||
max_concurrent_count: number
|
||||
service_status: number
|
||||
auto_assign_enabled: boolean
|
||||
}
|
||||
|
||||
export type SetupSimulateRouteResponse = {
|
||||
channel_id: number
|
||||
channel_name: string
|
||||
channel_type: string
|
||||
will_reach_operator: boolean
|
||||
final_outcome_code: string
|
||||
matched_operator?: SimulatedOperatorCandidate
|
||||
hops: SimulatedRouteHop[]
|
||||
candidate_operators: SimulatedOperatorCandidate[]
|
||||
}
|
||||
|
||||
export type SetupQuickFixResponse = {
|
||||
applied_actions: string[]
|
||||
readiness: SetupReadinessResponse
|
||||
}
|
||||
|
||||
export function fetchSetupReadiness() {
|
||||
return request<SetupReadinessResponse>("/api/dashboard/setup/readiness")
|
||||
}
|
||||
|
||||
export function simulateSetupRoute(payload: {
|
||||
channel_id?: number
|
||||
bypass_ai?: boolean
|
||||
}) {
|
||||
return request<SetupSimulateRouteResponse>(
|
||||
"/api/dashboard/setup/simulate_route",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function applySetupQuickFix(payload: {
|
||||
action: SetupQuickFixAction
|
||||
entity_id?: number
|
||||
}) {
|
||||
return request<SetupQuickFixResponse>("/api/dashboard/setup/quick_fix", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
176
web/lib/api/support-community.ts
Normal file
176
web/lib/api/support-community.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { type PageResult } from "@/lib/api/admin"
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { PageData } from "@/lib/api/types"
|
||||
|
||||
export type Category = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
parentId?: number
|
||||
sortNo: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export type Post = {
|
||||
id: number
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
userId: number
|
||||
userName: string
|
||||
userType: string
|
||||
title: string
|
||||
contentType: string
|
||||
content: string
|
||||
tags: string[]
|
||||
status: string
|
||||
acceptedCommentId: number
|
||||
commentCount: number
|
||||
reactionCount: number
|
||||
viewCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type Comment = {
|
||||
id: number
|
||||
postId: number
|
||||
parentId: number
|
||||
authorType: string
|
||||
authorId: number
|
||||
authorName: string
|
||||
contentType: string
|
||||
content: string
|
||||
status: string
|
||||
reactionCount: number
|
||||
replyCount: number
|
||||
reportCount: number
|
||||
isAccepted: boolean
|
||||
replies: Comment[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type PostDetail = {
|
||||
comments: Comment[]
|
||||
post: Post
|
||||
}
|
||||
|
||||
export type ReactionTargetType = "post" | "comment"
|
||||
export type ReactionType = "like"
|
||||
|
||||
export function categoryHref(slug: string) {
|
||||
return `/support/community/categories/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
export function newPostHref() {
|
||||
return "/support/community/posts/new"
|
||||
}
|
||||
|
||||
export function postHref(id: number) {
|
||||
return `/support/community/posts/${id}`
|
||||
}
|
||||
|
||||
export function postsHref() {
|
||||
return "/support/community/posts"
|
||||
}
|
||||
|
||||
export function fetchCategories() {
|
||||
return request<Category[]>("/api/support/community/categories/list", { skipAuth: true })
|
||||
}
|
||||
|
||||
export function fetchPosts(query?: Record<string, string | number | undefined>) {
|
||||
return request<PageData<Post>>(`/api/support/community/posts/list${toQueryString(query)}`, {
|
||||
skipAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchPost(id: number) {
|
||||
return request<PostDetail>(`/api/support/community/posts/${id}`, { skipAuth: true })
|
||||
}
|
||||
|
||||
export function createPost(payload: {
|
||||
categoryId: number
|
||||
content: string
|
||||
contentType: string
|
||||
tags: string[]
|
||||
title: string
|
||||
}) {
|
||||
return request<Post>("/api/support/community/posts/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchComments(query: {
|
||||
limit?: number
|
||||
page?: number
|
||||
parentId?: number
|
||||
postId: number
|
||||
sort?: string
|
||||
}) {
|
||||
return request<PageResult<Comment>>(`/api/support/community/comments/list${toQueryString(query)}`, {
|
||||
skipAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function createComment(payload: {
|
||||
content: string
|
||||
contentType: string
|
||||
parentId?: number
|
||||
postId: number
|
||||
}) {
|
||||
return request<Comment>("/api/support/community/comments/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateComment(payload: { content: string; contentType: string; id: number }) {
|
||||
return request<void>("/api/support/community/comments/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteComment(id: number) {
|
||||
return request<void>("/api/support/community/comments/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
export function reportComment(id: number, reason = "") {
|
||||
return request<void>("/api/support/community/comments/report", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id, reason }),
|
||||
})
|
||||
}
|
||||
|
||||
export function acceptComment(postId: number, commentId: number) {
|
||||
return request<void>("/api/support/community/posts/accept_comment", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ postId, commentId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleReaction(payload: { reactionType?: ReactionType; targetId: number; targetType: ReactionTargetType }) {
|
||||
return request<void>("/api/support/community/reactions/toggle", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reactionType: payload.reactionType || "like", targetId: payload.targetId, targetType: payload.targetType }),
|
||||
})
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== "" && value !== "all") {
|
||||
params.set(key, String(value))
|
||||
}
|
||||
})
|
||||
const raw = params.toString()
|
||||
return raw ? `?${raw}` : ""
|
||||
}
|
||||
19
web/lib/api/support-config.ts
Normal file
19
web/lib/api/support-config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type SupportNavigationMenuItem = {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
openInNewWindow: boolean
|
||||
visible: boolean
|
||||
sortNo: number
|
||||
children?: SupportNavigationMenuItem[]
|
||||
}
|
||||
|
||||
export type PublicSupportConfig = {
|
||||
navigationMenu: SupportNavigationMenuItem[]
|
||||
}
|
||||
|
||||
export function fetchSupportConfig() {
|
||||
return request<PublicSupportConfig>("/api/support/config", { skipAuth: true })
|
||||
}
|
||||
90
web/lib/api/support.ts
Normal file
90
web/lib/api/support.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { type PageResult } from "@/lib/api/admin"
|
||||
import { loginWithPassword } from "@/lib/api/auth"
|
||||
import { request } from "@/lib/api/client"
|
||||
import { type AuthSession, writeSession } from "@/lib/auth"
|
||||
|
||||
export type DocPage = {
|
||||
id: number
|
||||
parentId: number
|
||||
title: string
|
||||
slug: string
|
||||
summary: string
|
||||
contentType: string
|
||||
content: string
|
||||
coverUrl: string
|
||||
tags: string[]
|
||||
status: string
|
||||
sortNo: number
|
||||
viewCount: number
|
||||
helpfulCount: number
|
||||
unhelpfulCount: number
|
||||
publishedAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
docPath?: string
|
||||
}
|
||||
|
||||
export type DocNavigationNode = Pick<DocPage, "id" | "parentId" | "title" | "slug" | "sortNo"> & {
|
||||
children: DocNavigationNode[]
|
||||
}
|
||||
|
||||
export type SupportUser = {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
userType: string
|
||||
}
|
||||
|
||||
export async function loginSupportCustomer(payload: { email: string; password: string }) {
|
||||
return loginWithPassword({ username: payload.email, password: payload.password })
|
||||
}
|
||||
|
||||
export async function registerSupportCustomer(payload: { name: string; email: string; password: string }) {
|
||||
const session = await request<AuthSession>("/api/support/auth/register", {
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
writeSession(session)
|
||||
return session
|
||||
}
|
||||
|
||||
export function fetchSupportMe() {
|
||||
return request<SupportUser>("/api/support/me")
|
||||
}
|
||||
|
||||
export function fetchDocPages(query?: Record<string, string | number | undefined>) {
|
||||
return request<PageResult<DocPage>>(`/api/support/doc-page/list${toQueryString(query)}`, {
|
||||
skipAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDocNavigation() {
|
||||
return request<DocNavigationNode[]>("/api/support/doc-page/navigation", { skipAuth: true })
|
||||
}
|
||||
|
||||
export function fetchDocPage(id: number) {
|
||||
return request<DocPage>(`/api/support/doc-page/${id}`, { skipAuth: true })
|
||||
}
|
||||
|
||||
export function submitDocPageFeedback(id: number, helpful: boolean) {
|
||||
return request<void>("/api/support/doc-page/feedback", {
|
||||
method: "POST",
|
||||
skipAuth: true,
|
||||
body: JSON.stringify({ id, helpful }),
|
||||
})
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== "" && value !== "all") {
|
||||
params.set(key, String(value))
|
||||
}
|
||||
})
|
||||
const raw = params.toString()
|
||||
return raw ? `?${raw}` : ""
|
||||
}
|
||||
239
web/lib/api/ticket.ts
Normal file
239
web/lib/api/ticket.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { Tag } from "@/lib/api/admin"
|
||||
|
||||
export type Paging = {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type PageResult<T> = {
|
||||
results: T[]
|
||||
page: Paging
|
||||
}
|
||||
|
||||
export type TicketStatus = "pending" | "in_progress" | "done"
|
||||
export type TicketSource = "manual" | "conversation"
|
||||
|
||||
export type TicketCustomer = {
|
||||
id: number
|
||||
name: string
|
||||
companyId?: number
|
||||
company?: {
|
||||
id: number
|
||||
name: string
|
||||
code?: string
|
||||
remark?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
primaryMobile?: string
|
||||
primaryEmail?: string
|
||||
}
|
||||
|
||||
export type TicketProgress = {
|
||||
id: number
|
||||
ticketId: number
|
||||
content: string
|
||||
authorId: number
|
||||
authorName?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export type TicketItem = {
|
||||
id: number
|
||||
ticketNo: string
|
||||
title: string
|
||||
description: string
|
||||
source: TicketSource
|
||||
channel: string
|
||||
customerId: number
|
||||
conversationId: number
|
||||
tags?: Tag[]
|
||||
status: TicketStatus
|
||||
currentAssigneeId: number
|
||||
currentAssigneeName?: string
|
||||
createdBy: number
|
||||
createdByName?: string
|
||||
handledAt?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
customer?: TicketCustomer
|
||||
}
|
||||
|
||||
export type TicketDetail = {
|
||||
ticket: TicketItem
|
||||
progresses?: TicketProgress[]
|
||||
}
|
||||
|
||||
export type TicketSummary = {
|
||||
all: number
|
||||
pending: number
|
||||
inProgress: number
|
||||
done: number
|
||||
unassigned: number
|
||||
mine: number
|
||||
stale: number
|
||||
}
|
||||
|
||||
export type TicketSavedView = {
|
||||
id: number
|
||||
name: string
|
||||
filters?: Record<string, unknown>
|
||||
sortNo: number
|
||||
}
|
||||
|
||||
export type TicketListQuery = {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string
|
||||
status?: TicketStatus
|
||||
tagId?: number
|
||||
currentAssigneeId?: number
|
||||
customerId?: number
|
||||
conversationId?: number
|
||||
source?: TicketSource
|
||||
channel?: string
|
||||
mine?: number | boolean
|
||||
unassigned?: number | boolean
|
||||
staleHours?: number
|
||||
}
|
||||
|
||||
export type CreateTicketPayload = {
|
||||
title: string
|
||||
description: string
|
||||
source?: TicketSource
|
||||
channel?: string
|
||||
customerId?: number
|
||||
conversationId?: number
|
||||
tagIds?: number[]
|
||||
currentAssigneeId?: number
|
||||
}
|
||||
|
||||
export type CreateTicketFromConversationPayload = {
|
||||
conversationId: number
|
||||
title: string
|
||||
description: string
|
||||
tagIds?: number[]
|
||||
currentAssigneeId?: number
|
||||
}
|
||||
|
||||
export type UpdateTicketPayload = {
|
||||
ticketId: number
|
||||
title: string
|
||||
description: string
|
||||
tagIds?: number[]
|
||||
currentAssigneeId?: number
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | boolean | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchTickets(query?: TicketListQuery) {
|
||||
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/list${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function fetchTicketSummary(query?: { staleHours?: number }) {
|
||||
return request<TicketSummary>(`/api/dashboard/ticket/summary${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function fetchTicketDetail(id: number) {
|
||||
return request<TicketDetail>(`/api/dashboard/ticket/${id}`)
|
||||
}
|
||||
|
||||
export function createTicket(payload: CreateTicketPayload) {
|
||||
return request<TicketItem>("/api/dashboard/ticket/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function createTicketFromConversation(payload: CreateTicketFromConversationPayload) {
|
||||
return request<TicketItem>("/api/dashboard/ticket/create_from_conversation", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTicket(payload: UpdateTicketPayload) {
|
||||
return request<void>("/api/dashboard/ticket/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function linkTicketToCustomer(payload: {
|
||||
ticketId: number
|
||||
customerId: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/link_customer", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function assignTicket(payload: {
|
||||
ticketId: number
|
||||
toUserId: number
|
||||
reason?: string
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/assign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function changeTicketStatus(payload: {
|
||||
ticketId: number
|
||||
status: TicketStatus
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/change_status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function createTicketProgress(payload: {
|
||||
ticketId: number
|
||||
content: string
|
||||
}) {
|
||||
return request<TicketProgress>("/api/dashboard/ticket/progress/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchTicketViews() {
|
||||
return request<TicketSavedView[]>("/api/dashboard/ticket/view_list")
|
||||
}
|
||||
|
||||
export function saveTicketView(payload: {
|
||||
id?: number
|
||||
name: string
|
||||
filters: Record<string, unknown>
|
||||
}) {
|
||||
return request<TicketSavedView>("/api/dashboard/ticket/save_view", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketView(id: number) {
|
||||
return request<void>("/api/dashboard/ticket/delete_view", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
5
web/lib/api/types.ts
Normal file
5
web/lib/api/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type PageData<T> = {
|
||||
cursor?: string
|
||||
hasMore: boolean
|
||||
results: T[]
|
||||
}
|
||||
16
web/lib/api/websocket.ts
Normal file
16
web/lib/api/websocket.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ||
|
||||
(process.env.NODE_ENV === "development" ? "http://127.0.0.1:8083" : "")
|
||||
|
||||
export function createWebSocketBaseUrl() {
|
||||
if (API_BASE_URL) {
|
||||
return API_BASE_URL.replace(/^http/, "ws").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return ""
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
return `${protocol}//${window.location.host}`
|
||||
}
|
||||
Reference in New Issue
Block a user