chore: ticket section implement sse
This commit is contained in:
167
src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
Normal file
167
src/app/(modules)/ticket/hooks/useTenantTicketTableEvents.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
|
||||
import { ticketService } from '@/services/api';
|
||||
import type {
|
||||
TicketListItem,
|
||||
TicketListParams,
|
||||
TicketListResponse,
|
||||
TicketTableStatusEvent,
|
||||
} from '@/types';
|
||||
|
||||
import { ticketKeys } from '../queries/ticketKeys';
|
||||
|
||||
function patchTicketLists(
|
||||
current: TicketListResponse | undefined,
|
||||
event: TicketTableStatusEvent,
|
||||
params?: TicketListParams,
|
||||
) {
|
||||
if (!current || !event.id) return current;
|
||||
|
||||
const nextRow = buildTicketListItem(event);
|
||||
const existingIndex = current.items.findIndex(
|
||||
(ticket) => ticket.id === event.id,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
return {
|
||||
...current,
|
||||
items: current.items.map((ticket) =>
|
||||
ticket.id === event.id ? mergeTicketListItem(ticket, event) : ticket,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (event.kind !== 'created') return current;
|
||||
if (!shouldCountCreatedTicket(event, params)) return current;
|
||||
|
||||
if (!nextRow || !isFirstPage(params)) {
|
||||
return {
|
||||
...current,
|
||||
total: current.total + 1,
|
||||
};
|
||||
}
|
||||
|
||||
const pageLimit = params?.limit ?? current.items.length + 1;
|
||||
|
||||
return {
|
||||
...current,
|
||||
items: [nextRow, ...current.items].slice(0, pageLimit),
|
||||
total: current.total + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldCountCreatedTicket(
|
||||
event: TicketTableStatusEvent,
|
||||
params?: TicketListParams,
|
||||
) {
|
||||
if (params?.status && event.status !== params.status) return false;
|
||||
if (params?.chainage_id && event.chainage_id !== params.chainage_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isFirstPage(params?: TicketListParams) {
|
||||
return (params?.skip ?? 0) === 0;
|
||||
}
|
||||
|
||||
function getTicketListParams(queryKey: readonly unknown[]) {
|
||||
const params = queryKey[2];
|
||||
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return params as TicketListParams;
|
||||
}
|
||||
|
||||
function buildTicketListItem(
|
||||
event: TicketTableStatusEvent,
|
||||
): TicketListItem | null {
|
||||
if (
|
||||
!event.id ||
|
||||
!event.status ||
|
||||
event.detection_count === undefined ||
|
||||
!event.updated_at
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
chainage_id: event.chainage_id ?? null,
|
||||
chainage_name: event.chainage_name ?? null,
|
||||
status: event.status,
|
||||
assigned_to_name: event.assigned_to_name ?? null,
|
||||
detection_count: event.detection_count,
|
||||
created_by_name: event.created_by_name ?? null,
|
||||
created_at: event.created_at ?? event.updated_at,
|
||||
updated_at: event.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeTicketListItem(
|
||||
current: TicketListItem,
|
||||
event: TicketTableStatusEvent,
|
||||
): TicketListItem {
|
||||
return {
|
||||
...current,
|
||||
chainage_id: event.chainage_id ?? current.chainage_id,
|
||||
chainage_name: event.chainage_name ?? current.chainage_name,
|
||||
status: event.status ?? current.status,
|
||||
assigned_to_name: event.assigned_to_name ?? current.assigned_to_name,
|
||||
detection_count: event.detection_count ?? current.detection_count,
|
||||
created_by_name: event.created_by_name ?? current.created_by_name,
|
||||
created_at: event.created_at ?? current.created_at,
|
||||
updated_at: event.updated_at ?? current.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTenantTicketTableEvents(enabled = true) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const events = useMemo(
|
||||
() => ({
|
||||
ticket_status: (event: TicketTableStatusEvent) => {
|
||||
queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey: ticketKeys.lists() })
|
||||
.forEach((query) => {
|
||||
queryClient.setQueryData<TicketListResponse>(
|
||||
query.queryKey,
|
||||
(current) =>
|
||||
patchTicketLists(
|
||||
current,
|
||||
event,
|
||||
getTicketListParams(query.queryKey),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
}),
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const getPath = useCallback(
|
||||
(token: string) => API_ROUTES.TICKET_EVENTS.TENANT(token),
|
||||
[],
|
||||
);
|
||||
|
||||
const onConnectionError = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
}, [queryClient]);
|
||||
|
||||
useSseWithToken({
|
||||
enabled,
|
||||
getToken: ticketService.createTenantTicketEventsToken,
|
||||
getPath,
|
||||
events,
|
||||
onConnectionError,
|
||||
});
|
||||
}
|
||||
119
src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
Normal file
119
src/app/(modules)/ticket/hooks/useTicketDetailEvents.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||
import { useSseWithToken } from '@/hooks/sse/useSseWithToken';
|
||||
import { ticketService } from '@/services/api';
|
||||
import type {
|
||||
TicketDetailStatusEvent,
|
||||
TicketProgressEvent,
|
||||
TicketStreamErrorEvent,
|
||||
} from '@/types';
|
||||
|
||||
import { ticketKeys } from '../queries/ticketKeys';
|
||||
|
||||
export interface TicketProcessingProgress {
|
||||
progress: number;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface TicketDetailLiveState {
|
||||
progress: TicketProcessingProgress | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
const TICKET_DETAIL_RECONNECT_EVENTS = ['error'] as const;
|
||||
|
||||
function isHeartbeatEvent(event: { type?: string } | null | undefined) {
|
||||
return event?.type === 'heartbeat';
|
||||
}
|
||||
|
||||
export function useTicketDetailEvents(
|
||||
ticketId: string | undefined,
|
||||
enabled = true,
|
||||
): TicketDetailLiveState {
|
||||
const queryClient = useQueryClient();
|
||||
const [progress, setProgress] = useState<TicketProcessingProgress | null>(
|
||||
null,
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const refetchTicket = useCallback(() => {
|
||||
if (!ticketId) return;
|
||||
|
||||
queryClient.refetchQueries({ queryKey: ticketKeys.detail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
}, [queryClient, ticketId]);
|
||||
|
||||
const getToken = useCallback(
|
||||
() => ticketService.createTicketEventsToken(ticketId as string),
|
||||
[ticketId],
|
||||
);
|
||||
|
||||
const getPath = useCallback(
|
||||
(token: string) =>
|
||||
API_ROUTES.TICKETS.DETAIL_EVENTS(ticketId as string, token),
|
||||
[ticketId],
|
||||
);
|
||||
|
||||
const events = useMemo(
|
||||
() => ({
|
||||
ticket_status: (event: TicketDetailStatusEvent) => {
|
||||
if (isHeartbeatEvent(event)) return;
|
||||
refetchTicket();
|
||||
},
|
||||
progress: (event: TicketProgressEvent) => {
|
||||
setErrorMessage(null);
|
||||
setProgress({
|
||||
progress: event.progress,
|
||||
message: event.message,
|
||||
});
|
||||
},
|
||||
complete: (event: TicketProgressEvent) => {
|
||||
setErrorMessage(null);
|
||||
setProgress({
|
||||
progress: 100,
|
||||
message: event.message,
|
||||
});
|
||||
refetchTicket();
|
||||
},
|
||||
error: (event: TicketStreamErrorEvent) => {
|
||||
setErrorMessage(
|
||||
event.message ?? 'Live ticket updates disconnected. Reconnecting...',
|
||||
);
|
||||
},
|
||||
heartbeat: () => undefined,
|
||||
}),
|
||||
[refetchTicket],
|
||||
);
|
||||
|
||||
const shouldStop = useCallback(
|
||||
(eventName: keyof typeof events, event: unknown) =>
|
||||
eventName === 'ticket_status' &&
|
||||
(event as TicketDetailStatusEvent).status === 'closed',
|
||||
[],
|
||||
);
|
||||
|
||||
const onConnectionError = useCallback(() => {
|
||||
setErrorMessage('Live ticket updates disconnected. Reconnecting...');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(null);
|
||||
setErrorMessage(null);
|
||||
}, [ticketId]);
|
||||
|
||||
useSseWithToken({
|
||||
enabled: Boolean(enabled && ticketId),
|
||||
getToken,
|
||||
getPath,
|
||||
events,
|
||||
reconnectOnEvents: TICKET_DETAIL_RECONNECT_EVENTS,
|
||||
shouldStop,
|
||||
onConnectionError,
|
||||
});
|
||||
|
||||
return { progress, errorMessage };
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||
import { ticketService } from '@/services/api';
|
||||
import { sseService } from '@/services/sse';
|
||||
import type {
|
||||
TicketDetail,
|
||||
TicketListResponse,
|
||||
TicketProgressEvent,
|
||||
TicketStatusEvent,
|
||||
} from '@/types';
|
||||
|
||||
import { ticketKeys } from '../queries/ticketKeys';
|
||||
|
||||
function getTicketEventId(event: TicketStatusEvent) {
|
||||
return event.id ?? event.ticket_id;
|
||||
}
|
||||
|
||||
function mergeTicketDetail(
|
||||
current: TicketDetail | undefined,
|
||||
event: TicketStatusEvent,
|
||||
) {
|
||||
if (!current) return current;
|
||||
|
||||
const eventId = getTicketEventId(event);
|
||||
if (eventId && eventId !== current.id) return current;
|
||||
|
||||
return {
|
||||
...current,
|
||||
...event,
|
||||
id: current.id,
|
||||
history: event.history ?? current.history,
|
||||
};
|
||||
}
|
||||
|
||||
function patchTicketLists(
|
||||
current: TicketListResponse | undefined,
|
||||
event: TicketStatusEvent,
|
||||
) {
|
||||
const eventId = getTicketEventId(event);
|
||||
if (!current || !eventId) return current;
|
||||
|
||||
return {
|
||||
...current,
|
||||
items: current.items.map((ticket) =>
|
||||
ticket.id === eventId
|
||||
? {
|
||||
...ticket,
|
||||
status: event.status ?? ticket.status,
|
||||
updated_at: event.timestamps?.updated_at ?? ticket.updated_at,
|
||||
chainage_id: event.chainage_id ?? ticket.chainage_id,
|
||||
video_id: event.video_id ?? ticket.video_id,
|
||||
}
|
||||
: ticket,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function useTenantTicketTableEvents(enabled = true) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let closed = false;
|
||||
let closeConnection: (() => void) | undefined;
|
||||
|
||||
ticketService
|
||||
.createTenantTicketEventsToken()
|
||||
.then(({ sse_token }) => {
|
||||
if (closed) return;
|
||||
|
||||
const connection = sseService.connect(
|
||||
API_ROUTES.TICKET_EVENTS.TENANT(sse_token),
|
||||
{
|
||||
ticket_status: (event: TicketStatusEvent) => {
|
||||
queryClient.setQueriesData<TicketListResponse>(
|
||||
{ queryKey: ticketKeys.lists() },
|
||||
(current) => patchTicketLists(current, event),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
closeConnection = connection.close;
|
||||
})
|
||||
.catch(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
});
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
closeConnection?.();
|
||||
};
|
||||
}, [enabled, queryClient]);
|
||||
}
|
||||
|
||||
export function useTicketDetailEvents(
|
||||
ticketId: string | undefined,
|
||||
enabled = true,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !ticketId) return;
|
||||
|
||||
let closed = false;
|
||||
let closeConnection: (() => void) | undefined;
|
||||
|
||||
ticketService
|
||||
.createTicketEventsToken(ticketId)
|
||||
.then(({ sse_token }) => {
|
||||
if (closed) return;
|
||||
|
||||
const connection = sseService.connect(
|
||||
API_ROUTES.TICKETS.DETAIL_EVENTS(ticketId, sse_token),
|
||||
{
|
||||
ticket_status: (event: TicketStatusEvent) => {
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
(current) => mergeTicketDetail(current, event),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
progress: (event: TicketProgressEvent) => {
|
||||
queryClient.setQueryData<TicketDetail>(
|
||||
ticketKeys.detail(ticketId),
|
||||
(current) =>
|
||||
current && event.status
|
||||
? { ...current, status: current.status }
|
||||
: current,
|
||||
);
|
||||
},
|
||||
complete: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.detail(ticketId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
closeConnection = connection.close;
|
||||
})
|
||||
.catch(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ticketKeys.detail(ticketId),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
closeConnection?.();
|
||||
};
|
||||
}, [enabled, queryClient, ticketId]);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import type {
|
||||
|
||||
import { ticketKeys } from '../queries/ticketKeys';
|
||||
|
||||
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
|
||||
|
||||
interface UseTicketsQueryParams {
|
||||
skip: number;
|
||||
limit: number;
|
||||
@@ -48,6 +50,8 @@ export function useTicketsQuery(params: UseTicketsQueryParams) {
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.list(listParams),
|
||||
queryFn: () => ticketService.getTickets(listParams),
|
||||
staleTime: TICKET_TABLE_REFRESH_MS,
|
||||
refetchInterval: TICKET_TABLE_REFRESH_MS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user