chore: ticket section implement sse

This commit is contained in:
2026-06-19 18:06:11 +05:30
parent a567a52e26
commit fffd8e3e12
25 changed files with 711 additions and 307 deletions

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

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

View File

@@ -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]);
}

View File

@@ -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,
});
}