improvement: notification feature polished. feature tabs following the same notification socket emmited data to be updated.
This commit is contained in:
@@ -990,7 +990,13 @@
|
||||
"typeTaskAssigned": "A task was assigned to you",
|
||||
"typeConnectionRequest": "New organization connection request",
|
||||
"typeStaffInvite": "Staff invitation created",
|
||||
"typeUnknown": "Notification"
|
||||
"typeUnknown": "Notification",
|
||||
"destCases": "Open Cases",
|
||||
"destTasks": "Open Tasks",
|
||||
"destTreatment": "Open Treatment",
|
||||
"destOrganizations": "Open Organizations",
|
||||
"destStaff": "Open Staff",
|
||||
"destOpen": "Open"
|
||||
},
|
||||
"errors": {
|
||||
"GENERIC": "Something went wrong. Please try again.",
|
||||
|
||||
@@ -991,7 +991,13 @@
|
||||
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
|
||||
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
|
||||
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
|
||||
"typeUnknown": "اعلان"
|
||||
"typeUnknown": "اعلان",
|
||||
"destCases": "باز کردن پروندهها",
|
||||
"destTasks": "باز کردن وظایف",
|
||||
"destTreatment": "باز کردن درمان",
|
||||
"destOrganizations": "باز کردن سازمانها",
|
||||
"destStaff": "باز کردن کارکنان",
|
||||
"destOpen": "باز کردن"
|
||||
},
|
||||
"errors": {
|
||||
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||
|
||||
@@ -990,7 +990,13 @@
|
||||
"typeTaskAssigned": "Er is een taak aan u toegewezen",
|
||||
"typeConnectionRequest": "Nieuw organisatieverzoek",
|
||||
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
|
||||
"typeUnknown": "Melding"
|
||||
"typeUnknown": "Melding",
|
||||
"destCases": "Cases openen",
|
||||
"destTasks": "Taken openen",
|
||||
"destTreatment": "Behandeling openen",
|
||||
"destOrganizations": "Organisaties openen",
|
||||
"destStaff": "Personeel openen",
|
||||
"destOpen": "Openen"
|
||||
},
|
||||
"errors": {
|
||||
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@/components/lab/caseDetailUtils';
|
||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||
import { notificationsApi } from '@/lib/api/notifications';
|
||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
||||
import { casesApi } from '@/lib/api/cases';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
@@ -106,16 +106,22 @@ export function CasesPage() {
|
||||
search.trim() || clinicId || prosthesisTypeCode || sentFrom || sentTo,
|
||||
);
|
||||
|
||||
const loadCases = async (params: {
|
||||
q: string;
|
||||
clinicOrganizationId: string;
|
||||
prosthesisTypeCode: string;
|
||||
sentFrom: string;
|
||||
sentTo: string;
|
||||
page: number;
|
||||
}) => {
|
||||
setLoadingList(true);
|
||||
toast.setError('');
|
||||
const loadCases = async (
|
||||
params: {
|
||||
q: string;
|
||||
clinicOrganizationId: string;
|
||||
prosthesisTypeCode: string;
|
||||
sentFrom: string;
|
||||
sentTo: string;
|
||||
page: number;
|
||||
},
|
||||
options?: { silent?: boolean },
|
||||
) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) {
|
||||
setLoadingList(true);
|
||||
toast.setError('');
|
||||
}
|
||||
try {
|
||||
const response = await casesApi.list({
|
||||
q: params.q.trim() || undefined,
|
||||
@@ -129,9 +135,11 @@ export function CasesPage() {
|
||||
setCases(response.data.items);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error: unknown) {
|
||||
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
|
||||
if (!silent) {
|
||||
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
|
||||
}
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
if (!silent) setLoadingList(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -213,6 +221,28 @@ export function CasesPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload
|
||||
}, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const onBadgesChanged = () => {
|
||||
void loadCases(
|
||||
{
|
||||
q: search,
|
||||
clinicOrganizationId: clinicId,
|
||||
prosthesisTypeCode,
|
||||
sentFrom,
|
||||
sentTo,
|
||||
page,
|
||||
},
|
||||
{ silent: true },
|
||||
);
|
||||
if (selectedCaseId) {
|
||||
void loadDetail(selectedCaseId, { silent: true });
|
||||
}
|
||||
};
|
||||
window.addEventListener(tabBadgesChangedEventName(), onBadgesChanged);
|
||||
return () => window.removeEventListener(tabBadgesChangedEventName(), onBadgesChanged);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- soft refresh from live inbox socket
|
||||
}, [search, clinicId, prosthesisTypeCode, sentFrom, sentTo, page, selectedCaseId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCaseId) {
|
||||
setMobileDetailOpen(false);
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '@/components/lab/tasksViewDefaults';
|
||||
import { parseTasksSearchParams } from '@/components/lab/parseTasksSearchParams';
|
||||
import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts';
|
||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||
@@ -199,17 +199,22 @@ export function TasksPage() {
|
||||
};
|
||||
}, [searchParams, canView]);
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const loadTasks = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
}
|
||||
try {
|
||||
const response = await tasksApi.list(listParams);
|
||||
setTasks(response.data.items);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
|
||||
if (!silent) {
|
||||
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, [listParams, showError, setError, tErrors]);
|
||||
|
||||
@@ -221,6 +226,15 @@ export function TasksPage() {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [canView, loadTasks, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
const onBadgesChanged = () => {
|
||||
void loadTasks({ silent: true });
|
||||
};
|
||||
window.addEventListener(tabBadgesChangedEventName(), onBadgesChanged);
|
||||
return () => window.removeEventListener(tabBadgesChangedEventName(), onBadgesChanged);
|
||||
}, [canView, loadTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
void (async () => {
|
||||
|
||||
@@ -119,26 +119,31 @@ export function NotificationBell() {
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t('dropdownTitle')}
|
||||
className="absolute end-0 z-[200] mt-2 w-[min(22rem,calc(100vw-1.5rem))] rounded-[var(--radius-md)] border border-border bg-background-secondary/95 shadow-lg backdrop-blur-sm"
|
||||
className="fixed inset-x-3 top-[4.75rem] z-[200] max-h-[min(70dvh,28rem)] overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary/95 shadow-lg backdrop-blur-sm sm:absolute sm:inset-x-auto sm:end-0 sm:top-auto sm:mt-2 sm:w-[min(22rem,calc(100vw-1.5rem))]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 px-3 py-2">
|
||||
<p className="text-sm font-medium text-text-primary">{t('dropdownTitle')}</p>
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="text-xs text-primary hover:underline"
|
||||
className="text-xs text-primary hover:underline shrink-0"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t('viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="max-h-[min(24rem,60vh)] overflow-y-auto p-2 space-y-1.5">
|
||||
<div className="max-h-[min(calc(70dvh-2.75rem),24rem)] overflow-y-auto overscroll-contain p-2 space-y-1.5">
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="text-xs text-text-muted px-2 py-3">{t('loading')}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-xs text-text-muted px-2 py-3">{t('empty')}</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<NotificationCard key={item.id} item={item} onSelect={handleSelect} />
|
||||
<NotificationCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
compact
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { formatAppDateTime } from '@/lib/i18n/format';
|
||||
import type { UserNotificationItem, UserNotificationType } from '@/types/notifications';
|
||||
|
||||
@@ -16,40 +17,165 @@ const TYPE_I18N: Record<UserNotificationType, string> = {
|
||||
STAFF_INVITE: 'typeStaffInvite',
|
||||
};
|
||||
|
||||
function destKeyFromHref(href: string): string {
|
||||
if (href.startsWith('/cases')) return 'destCases';
|
||||
if (href.startsWith('/tasks')) return 'destTasks';
|
||||
if (href.startsWith('/treatment')) return 'destTreatment';
|
||||
if (href.startsWith('/organizations')) return 'destOrganizations';
|
||||
if (href.startsWith('/staff')) return 'destStaff';
|
||||
return 'destOpen';
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function formatProsthesisCode(code: string): string {
|
||||
return code.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/** Build a single truncated context string from denormalized inbox payload. */
|
||||
export function notificationContextLine(
|
||||
item: UserNotificationItem,
|
||||
): string | null {
|
||||
const payload = item.payload ?? {};
|
||||
const parts: string[] = [];
|
||||
|
||||
const patientName = asString(payload.patientName);
|
||||
const clinicName = asString(payload.clinicName);
|
||||
const labName = asString(payload.labName);
|
||||
const taskName = asString(payload.taskName);
|
||||
const fromOrganizationName = asString(payload.fromOrganizationName);
|
||||
const inviteeName = asString(payload.inviteeName);
|
||||
const email = asString(payload.email);
|
||||
const prosthesisTypeCode = asString(payload.prosthesisTypeCode);
|
||||
|
||||
const prosthesisCodes = Array.isArray(payload.prosthesisTypeCodes)
|
||||
? payload.prosthesisTypeCodes
|
||||
.filter((code): code is string => typeof code === 'string' && code.trim().length > 0)
|
||||
.map((code) => formatProsthesisCode(code.trim()))
|
||||
: [];
|
||||
|
||||
const prosthesisLabel =
|
||||
prosthesisTypeCode != null
|
||||
? formatProsthesisCode(prosthesisTypeCode)
|
||||
: prosthesisCodes.length > 0
|
||||
? prosthesisCodes.slice(0, 2).join(', ')
|
||||
: null;
|
||||
|
||||
switch (item.type) {
|
||||
case 'CASE_SENT':
|
||||
case 'CLINIC_COMMENT':
|
||||
case 'LAB_COMMENT':
|
||||
case 'CASE_IMPORTANT':
|
||||
if (patientName) parts.push(patientName);
|
||||
if (clinicName) parts.push(clinicName);
|
||||
if (prosthesisLabel) parts.push(prosthesisLabel);
|
||||
break;
|
||||
case 'LAB_COMMENT_CLINIC':
|
||||
if (patientName) parts.push(patientName);
|
||||
if (labName) parts.push(labName);
|
||||
if (prosthesisLabel) parts.push(prosthesisLabel);
|
||||
break;
|
||||
case 'TASK_COMPLETED':
|
||||
case 'TASK_ASSIGNED':
|
||||
if (patientName) parts.push(patientName);
|
||||
if (clinicName) parts.push(clinicName);
|
||||
else if (labName) parts.push(labName);
|
||||
if (taskName) parts.push(taskName);
|
||||
if (prosthesisLabel) parts.push(prosthesisLabel);
|
||||
break;
|
||||
case 'CONNECTION_REQUEST':
|
||||
if (fromOrganizationName) parts.push(fromOrganizationName);
|
||||
break;
|
||||
case 'STAFF_INVITE':
|
||||
if (inviteeName) parts.push(inviteeName);
|
||||
if (email) parts.push(email);
|
||||
break;
|
||||
default:
|
||||
// Fallback: show any denormalized fields even if type is unexpected.
|
||||
if (patientName) parts.push(patientName);
|
||||
if (clinicName) parts.push(clinicName);
|
||||
if (labName) parts.push(labName);
|
||||
if (taskName) parts.push(taskName);
|
||||
if (prosthesisLabel) parts.push(prosthesisLabel);
|
||||
break;
|
||||
}
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function NotificationCard({
|
||||
item,
|
||||
onSelect,
|
||||
compact = false,
|
||||
}: {
|
||||
item: UserNotificationItem;
|
||||
onSelect: (item: UserNotificationItem) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const t = useTranslations('notifications');
|
||||
const locale = useLocale();
|
||||
const unread = !item.readAt;
|
||||
const titleKey = TYPE_I18N[item.type] ?? 'typeUnknown';
|
||||
const destKey = destKeyFromHref(item.href);
|
||||
const context = notificationContextLine(item);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item)}
|
||||
className={`w-full text-start rounded-[var(--radius-md)] border px-3 py-2.5 transition-colors ${
|
||||
className={`w-full min-h-11 text-start rounded-[var(--radius-md)] border px-3 py-2.5 sm:py-3 transition-colors ${
|
||||
unread
|
||||
? 'border-primary/40 bg-primary/5 hover:border-primary/60'
|
||||
: 'border-border/70 bg-background-secondary/40 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-start gap-2.5 sm:gap-3">
|
||||
{unread ? (
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-badge-warning-fg" aria-hidden />
|
||||
) : (
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-text-primary">{t(titleKey)}</p>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">
|
||||
{formatAppDateTime(item.createdAt, locale)}
|
||||
<p
|
||||
className={`font-medium text-text-primary ${
|
||||
compact ? 'text-sm' : 'text-sm sm:text-base'
|
||||
}`}
|
||||
>
|
||||
{t(titleKey)}
|
||||
</p>
|
||||
<div
|
||||
className={`mt-0.5 flex min-w-0 items-center gap-x-2 text-text-muted ${
|
||||
compact ? 'text-[11px]' : 'text-xs sm:text-sm'
|
||||
}`}
|
||||
>
|
||||
{context ? (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate" title={context}>
|
||||
{context}
|
||||
</span>
|
||||
<span className="text-border shrink-0" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="shrink-0 whitespace-nowrap">
|
||||
{formatAppDateTime(item.createdAt, locale)}
|
||||
</span>
|
||||
<span className="text-border shrink-0" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="shrink-0 whitespace-nowrap">{t(destKey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-text-muted rtl:rotate-180"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -83,18 +83,23 @@ export function NotificationsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('pageTitle')}</h1>
|
||||
<p className="text-sm text-text-muted mt-1">{t('pageSubtitle')}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void handleMarkAll()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto shrink-0"
|
||||
onClick={() => void handleMarkAll()}
|
||||
>
|
||||
{t('markAllRead')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 w-full">
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">{t('loading')}</p>
|
||||
) : items.length === 0 ? (
|
||||
@@ -110,6 +115,7 @@ export function NotificationsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
isLoading={loadingMore}
|
||||
onClick={() => void loadPage(nextCursor, true)}
|
||||
>
|
||||
|
||||
@@ -107,21 +107,36 @@ export function OrganizationsPage() {
|
||||
|
||||
const existingRows = items;
|
||||
|
||||
async function loadList() {
|
||||
setLoading(true);
|
||||
toast.setError('');
|
||||
async function loadList(options?: { silent?: boolean }) {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
toast.setError('');
|
||||
}
|
||||
try {
|
||||
const res = await organizationApi.list();
|
||||
setItems(res.data.items);
|
||||
} catch (e) {
|
||||
toast.showError(formatApiMessage(e));
|
||||
if (!silent) {
|
||||
toast.showError(formatApiMessage(e));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onChanged = () => {
|
||||
void loadList({ silent: true });
|
||||
};
|
||||
window.addEventListener('pending-connections-changed', onChanged);
|
||||
return () => window.removeEventListener('pending-connections-changed', onChanged);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- soft refresh from live inbox
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/lib/api/client.ts
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import type { ApiError } from '@/types/api';
|
||||
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
||||
|
||||
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
@@ -79,6 +80,7 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
notifyAccessTokenRefreshed();
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
11
frontend/src/lib/auth/accessTokenEvents.ts
Normal file
11
frontend/src/lib/auth/accessTokenEvents.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** Dispatched after a successful access-token refresh so realtime can reconnect with the new cookie. */
|
||||
export const ACCESS_TOKEN_REFRESHED_EVENT = 'dyolink:access-token-refreshed';
|
||||
|
||||
export function notifyAccessTokenRefreshed() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new Event(ACCESS_TOKEN_REFRESHED_EVENT));
|
||||
}
|
||||
|
||||
export function accessTokenRefreshedEventName() {
|
||||
return ACCESS_TOKEN_REFRESHED_EVENT;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authApi } from '@/lib/api/auth';
|
||||
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
||||
|
||||
const STORAGE_KEY = 'dyolink.accessTokenExpiresAt';
|
||||
/** Refresh this long before the access JWT expires. */
|
||||
@@ -91,6 +92,7 @@ async function refreshAccessTokenWithOrg(): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
lastRefreshAt = Date.now();
|
||||
notifyAccessTokenRefreshed();
|
||||
return expiresAt;
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
rememberAccessTokenExpiresAt,
|
||||
startProactiveSessionRefresh,
|
||||
} from '@/lib/auth/proactiveRefresh';
|
||||
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
||||
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
|
||||
import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect';
|
||||
|
||||
@@ -162,6 +163,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
if (orgId) {
|
||||
await authApi.selectOrganization(orgId);
|
||||
}
|
||||
notifyAccessTokenRefreshed();
|
||||
const retry = await authApi.getProfile();
|
||||
if (retry.success) {
|
||||
const { user: userData, organizations: orgs } = normalizeProfilePayload(retry.data);
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
} from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { accessTokenRefreshedEventName } from '@/lib/auth/accessTokenEvents';
|
||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
|
||||
import type { UserNotificationItem } from '@/types/notifications';
|
||||
|
||||
type RealtimeContextValue = {
|
||||
@@ -37,13 +40,33 @@ function apiOrigin(): string {
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateSidebarBadges(notification?: UserNotificationItem | null) {
|
||||
// Sidebar Cases/Tasks/Treatment badges + open tab soft-refetch (via window event).
|
||||
notifyTabBadgesChanged();
|
||||
// Orgs pending badge/list — only for connection-related inbox events.
|
||||
if (
|
||||
notification?.type === 'CONNECTION_REQUEST' ||
|
||||
notification?.href?.startsWith('/organizations')
|
||||
) {
|
||||
notifyPendingConnectionsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
const { user, currentOrganization, isAuthReady } = useAuth();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [lastNotification, setLastNotification] = useState<UserNotificationItem | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState<number | null>(null);
|
||||
/** Bumped after access-token refresh so the socket reconnects with the new cookie. */
|
||||
const [socketEpoch, setSocketEpoch] = useState(0);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onRefreshed = () => setSocketEpoch((n) => n + 1);
|
||||
window.addEventListener(accessTokenRefreshedEventName(), onRefreshed);
|
||||
return () => window.removeEventListener(accessTokenRefreshedEventName(), onRefreshed);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthReady || !user || !currentOrganization?.id) {
|
||||
socketRef.current?.disconnect();
|
||||
@@ -63,6 +86,9 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
socket.on('notification.created', (payload: { notification?: UserNotificationItem }) => {
|
||||
if (payload?.notification) {
|
||||
setLastNotification(payload.notification);
|
||||
invalidateSidebarBadges(payload.notification);
|
||||
} else {
|
||||
invalidateSidebarBadges(null);
|
||||
}
|
||||
});
|
||||
socket.on('notification.unreadCount', (payload: { count?: number }) => {
|
||||
@@ -76,7 +102,7 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
socketRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, [isAuthReady, user, currentOrganization?.id]);
|
||||
}, [isAuthReady, user, currentOrganization?.id, socketEpoch]);
|
||||
|
||||
const setUnreadCountStable = useCallback((count: number) => {
|
||||
setUnreadCount(count);
|
||||
|
||||
Reference in New Issue
Block a user