chore: complete clean Agent Desk source snapshot

This commit is contained in:
2026-10-16 12:03:16 +00:00
parent 756ae33d05
commit e57610a27c
123 changed files with 37513 additions and 0 deletions

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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
)
)
}

View 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()}`
}

View 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 }),
})
}

View 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),
})
}

View 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}` : ""
}

View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
export type PageData<T> = {
cursor?: string
hasMore: boolean
results: T[]
}

16
web/lib/api/websocket.ts Normal file
View 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}`
}