Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
987 changes: 987 additions & 0 deletions docs/implementation-plans/sse-communication.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuthStoreProvider } from '@/lib/providers/auth-store-provider';
import { OrganizationStateStoreProvider } from '@/lib/providers/organization-state-store-provider';
import { CoachingRelationshipStateStoreProvider } from '@/lib/providers/coaching-relationship-state-store-provider';
import { SessionCleanupProvider } from '@/lib/providers/session-cleanup-provider';
import { SseProvider } from '@/lib/providers/sse-provider';
import { SWRConfig } from 'swr';

interface ProvidersProps {
Expand All @@ -24,7 +25,9 @@ export function Providers({ children }: ProvidersProps) {
provider: () => new Map(),
}}
>
{children}
<SseProvider>
{children}
</SseProvider>
</SWRConfig>
</SessionCleanupProvider>
</CoachingRelationshipStateStoreProvider>
Expand Down
14 changes: 4 additions & 10 deletions src/components/ui/coaching-sessions/actions-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
import { useActionList } from "@/lib/api/actions";
import { DateTime } from "ts-luxon";
import { siteConfig } from "@/site.config";
import { Action, actionToString } from "@/types/action";
import { Action } from "@/types/action";
import { cn } from "@/components/lib/utils";
import {
getTableRowClasses,
Expand Down Expand Up @@ -152,18 +152,16 @@ const ActionsList: React.FC<{
try {
if (editingActionId) {
// Update existing action
const action = await onActionEdited(
await onActionEdited(
editingActionId,
newBody,
newStatus,
newDueBy
);
console.trace("Updated Action: " + actionToString(action));
setEditingActionId(null);
} else {
// Create new action
const action = await onActionAdded(newBody, newStatus, newDueBy);
console.trace("Newly created Action: " + actionToString(action));
await onActionAdded(newBody, newStatus, newDueBy);
}

// Refresh the actions list from the hook
Expand All @@ -182,11 +180,7 @@ const ActionsList: React.FC<{

try {
// Delete action in backend
const deletedAction = await onActionDeleted(id);

console.trace(
"Deleted Action (onActionDeleted): " + actionToString(deletedAction)
);
await onActionDeleted(id);

// Refresh the actions list from the hook
refresh();
Expand Down
17 changes: 4 additions & 13 deletions src/components/ui/coaching-sessions/agreements-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import { MoreHorizontal, ArrowUp, ArrowDown } from "lucide-react";
import { Id } from "@/types/general";
import { useAgreementList } from "@/lib/api/agreements";
import { Agreement, agreementToString } from "@/types/agreement";
import { Agreement } from "@/types/agreement";
import { DateTime } from "ts-luxon";
import { siteConfig } from "@/site.config";
import { cn } from "@/components/lib/utils";
Expand Down Expand Up @@ -86,18 +86,14 @@ const AgreementsList: React.FC<{
try {
if (editingAgreementId) {
// Update existing agreement
const agreement = await onAgreementEdited(
await onAgreementEdited(
editingAgreementId,
newAgreement
);
console.trace("Updated Agreement: " + agreementToString(agreement));
setEditingAgreementId(null);
} else {
// Create new agreement
const agreement = await onAgreementAdded(newAgreement);
console.trace(
"Newly created Agreement: " + agreementToString(agreement)
);
await onAgreementAdded(newAgreement);
}

// Refresh the agreements list from the hook
Expand All @@ -116,12 +112,7 @@ const AgreementsList: React.FC<{

try {
// Delete agreement in backend
const deletedAgreement = await onAgreementDeleted(id);

console.trace(
"Deleted Agreement (onAgreementDeleted): " +
agreementToString(deletedAgreement)
);
await onAgreementDeleted(id);

// Refresh the agreements list from the hook
refresh();
Expand Down
16 changes: 3 additions & 13 deletions src/components/ui/coaching-sessions/overarching-goal-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ import {
useOverarchingGoalBySession,
useOverarchingGoalMutation,
} from "@/lib/api/overarching-goals";
import {
OverarchingGoal,
overarchingGoalToString,
} from "@/types/overarching-goal";
import { OverarchingGoal } from "@/types/overarching-goal";
import { useCurrentCoachingSession } from "@/lib/hooks/use-current-coaching-session";

const OverarchingGoalContainer: React.FC<{
Expand All @@ -33,20 +30,13 @@ const OverarchingGoalContainer: React.FC<{
try {
if (currentCoachingSessionId) {
if (overarchingGoal.id) {
const responseGoal = await updateOverarchingGoal(
await updateOverarchingGoal(
overarchingGoal.id,
newGoal
);
console.trace(
"Updated Overarching Goal: " + overarchingGoalToString(responseGoal)
);
} else if (!overarchingGoal.id) {
newGoal.coaching_session_id = currentCoachingSessionId;
const responseGoal = await createOverarchingGoal(newGoal);
console.trace(
"Newly created Overarching Goal: " +
overarchingGoalToString(responseGoal)
);
await createOverarchingGoal(newGoal);

// Manually trigger a local refresh of the cached OverarchingGoal data such that
// any other local code using the KeyedMutator will also update with this new data.
Expand Down
20 changes: 20 additions & 0 deletions src/lib/contexts/sse-connection-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use client";

import { createContext, useContext } from 'react';
import { type StoreApi, useStore } from 'zustand';
import { type SseConnectionStore } from '@/lib/stores/sse-connection-store';
import { useShallow } from 'zustand/shallow';

export const SseConnectionStoreContext = createContext<StoreApi<SseConnectionStore> | null>(null);

export const useSseConnectionStore = <T,>(
selector: (store: SseConnectionStore) => T
): T => {
const context = useContext(SseConnectionStoreContext);

if (!context) {
throw new Error('useSseConnectionStore must be used within SseProvider');
}

return useStore(context, useShallow(selector));
};
70 changes: 70 additions & 0 deletions src/lib/hooks/use-sse-cache-invalidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import { useCallback } from 'react';
import { useSWRConfig } from 'swr';
import { siteConfig } from '@/site.config';
import { useSseEventHandler } from './use-sse-event-handler';

export function useSseCacheInvalidation(eventSource: EventSource | null) {
const { mutate } = useSWRConfig();
const baseUrl = siteConfig.env.backendServiceURL;

const invalidateAllCaches = useCallback(() => {
mutate(
(key) => {
// Handle string keys
if (typeof key === 'string' && key.includes(baseUrl)) return true;
// Handle array keys (SWR supports both formats)
if (Array.isArray(key) && key[0] && key[0].includes(baseUrl)) return true;
return false;
},
undefined,
{ revalidate: true }
);
}, [mutate, baseUrl]);

useSseEventHandler(eventSource, 'action_created', () => {
invalidateAllCaches();
console.log('[SSE] Revalidated caches after action_created');
});

useSseEventHandler(eventSource, 'action_updated', () => {
invalidateAllCaches();
console.log('[SSE] Revalidated caches after action_updated');
});

useSseEventHandler(eventSource, 'action_deleted', () => {
invalidateAllCaches();
console.log('[SSE] Revalidated caches after action_deleted');
});

useSseEventHandler(eventSource, 'agreement_created', () => {
invalidateAllCaches();
console.log('[SSE] Revalidated caches after agreement_created');
});

useSseEventHandler(eventSource, 'agreement_updated', () => {
invalidateAllCaches();
console.log('[SSE] Revalidated caches after agreement_updated');
});

useSseEventHandler(eventSource, 'agreement_deleted', () => {
invalidateAllCaches();
console.log('[SSE] Revalidated caches after agreement_deleted');
});

useSseEventHandler(eventSource, 'overarching_goal_created', () => {
invalidateAllCaches();
console.log('[SSE] Revalidated caches after overarching_goal_created');
});

useSseEventHandler(eventSource, 'overarching_goal_updated', () => {
invalidateAllCaches();
console.log('[SSE] Revalidated caches after overarching_goal_updated');
});

useSseEventHandler(eventSource, 'overarching_goal_deleted', () => {
invalidateAllCaches();
console.log('[SSE] Revalidated caches after overarching_goal_deleted');
});
}
65 changes: 65 additions & 0 deletions src/lib/hooks/use-sse-connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client";

import { useEffect, useRef } from 'react';
import { siteConfig } from '@/site.config';
import { useSseConnectionStore } from '@/lib/contexts/sse-connection-context';
import { logoutCleanupRegistry } from '@/lib/hooks/logout-cleanup-registry';

export function useSseConnection(isLoggedIn: boolean) {
const eventSourceRef = useRef<EventSource | null>(null);

// Get store instance directly - Zustand actions are stable and don't need to be in dependencies
const store = useSseConnectionStore((state) => state);

useEffect(() => {
if (!isLoggedIn) {
eventSourceRef.current?.close();
eventSourceRef.current = null;
store.setDisconnected();
return;
}

store.setConnecting();

const source = new EventSource(`${siteConfig.env.backendServiceURL}/sse`, {

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/app/coaching-sessions/coaching-session-page.test.tsx > CoachingSessionsPage URL Parameter Persistence > should use tab parameter from URL when present

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/app/coaching-sessions/coaching-session-page.test.tsx > CoachingSessionsPage URL Parameter Persistence > should default to notes tab when no URL parameter is present

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/hooks/use-todays-sessions.test.tsx > useTodaysSessions > should handle empty session lists

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/hooks/use-todays-sessions.test.tsx > useTodaysSessions > should provide refresh function

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/hooks/use-todays-sessions.test.tsx > useTodaysSessions > should handle errors gracefully

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/hooks/use-todays-sessions.test.tsx > useTodaysSessions > should filter to only today's sessions

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/hooks/use-todays-sessions.test.tsx > useTodaysSessions > should sort sessions by date/time ascending

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/hooks/use-todays-sessions.test.tsx > useTodaysSessions > should return enriched sessions with related data

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/hooks/use-todays-sessions.test.tsx > useTodaysSessions > should fetch sessions from all organizations

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11

Check failure on line 24 in src/lib/hooks/use-sse-connection.ts

View workflow job for this annotation

GitHub Actions / Build & Test

__tests__/hooks/use-todays-sessions.test.tsx > useTodaysSessions > should return loading state initially

ReferenceError: EventSource is not defined ❯ src/lib/hooks/use-sse-connection.ts:24:20 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:23953:20 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ commitHookEffectListMount node_modules/react-dom/cjs/react-dom-client.development.js:11905:29 ❯ commitHookPassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:12028:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13841:13 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13957:11 ❯ recursivelyTraversePassiveMountEffects node_modules/react-dom/cjs/react-dom-client.development.js:13815:11 ❯ commitPassiveMountOnFiber node_modules/react-dom/cjs/react-dom-client.development.js:13834:11
withCredentials: true,
});

source.onopen = () => {
console.log('[SSE] Connection established');
store.setConnected();
};

source.onerror = (error) => {
console.error('[SSE] Connection error:', error);

// Check readyState to distinguish network errors from HTTP errors
if (source.readyState === EventSource.CONNECTING) {
// Browser is attempting reconnection (network error)
store.setReconnecting();
console.log('[SSE] Connection lost, browser attempting reconnection...');
} else {
// EventSource.CLOSED - permanent failure (HTTP error like 401, 403, 500)
store.setError('Connection failed - check authentication or server status');
source.close();
}
};

eventSourceRef.current = source;

const unregisterCleanup = logoutCleanupRegistry.register(() => {
console.log('[SSE] Cleaning up connection on logout');
source.close();
});

return () => {
source.close();
store.setDisconnected();
unregisterCleanup();
};
// Zustand actions are stable and never change, so we only depend on isLoggedIn
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoggedIn]);

return eventSourceRef.current;
}
43 changes: 43 additions & 0 deletions src/lib/hooks/use-sse-event-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { useEffect, useRef } from 'react';
import type { SseEvent } from '@/types/sse-events';
import { useSseConnectionStore } from '@/lib/contexts/sse-connection-context';
import { transformEntityDates } from '@/types/general';

export function useSseEventHandler<T extends SseEvent['type']>(
eventSource: EventSource | null,
eventType: T,
handler: (event: Extract<SseEvent, { type: T }>) => void
) {
const handlerRef = useRef(handler);
const recordEvent = useSseConnectionStore((store) => store.recordEvent);

useEffect(() => {
handlerRef.current = handler;
}, [handler]);

useEffect(() => {
if (!eventSource) return;

const listener = (e: MessageEvent) => {
try {
const parsed: SseEvent = JSON.parse(e.data);
const transformed = transformEntityDates(parsed) as SseEvent;

if (transformed.type === eventType) {
recordEvent();
handlerRef.current(transformed as Extract<SseEvent, { type: T }>);
}
} catch (error) {
console.error(`[SSE] Failed to parse ${eventType} event:`, error, e.data);
}
};

eventSource.addEventListener(eventType, listener);

return () => {
eventSource.removeEventListener(eventType, listener);
};
}, [eventSource, eventType, recordEvent]);
}
18 changes: 18 additions & 0 deletions src/lib/hooks/use-sse-system-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import { useAuthStore } from '@/lib/providers/auth-store-provider';
import { useLogoutUser } from '@/lib/hooks/use-logout-user';
import { useSseEventHandler } from './use-sse-event-handler';

export function useSseSystemEvents(eventSource: EventSource | null) {
const logout = useLogoutUser();
const isLoggedIn = useAuthStore((store) => store.isLoggedIn);

useSseEventHandler(eventSource, 'force_logout', async (event) => {
console.warn('[SSE] Force logout received:', event.data.reason);

if (isLoggedIn) {
await logout();
}
});
}
34 changes: 34 additions & 0 deletions src/lib/providers/sse-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import { type ReactNode, useState } from 'react';
import { createSseConnectionStore } from '@/lib/stores/sse-connection-store';
import { SseConnectionStoreContext } from '@/lib/contexts/sse-connection-context';
import { useAuthStore } from '@/lib/providers/auth-store-provider';
import { useSseConnection } from '@/lib/hooks/use-sse-connection';
import { useSseCacheInvalidation } from '@/lib/hooks/use-sse-cache-invalidation';
import { useSseSystemEvents } from '@/lib/hooks/use-sse-system-events';

export interface SseProviderProps {
children: ReactNode;
}

function SseConnectionManager() {
const isLoggedIn = useAuthStore((store) => store.isLoggedIn);
const eventSource = useSseConnection(isLoggedIn);

useSseCacheInvalidation(eventSource);
useSseSystemEvents(eventSource);

return null;
}

export const SseProvider = ({ children }: SseProviderProps) => {
const [store] = useState(() => createSseConnectionStore());

return (
<SseConnectionStoreContext.Provider value={store}>
<SseConnectionManager />
{children}
</SseConnectionStoreContext.Provider>
);
};
Loading