improvement: notification feature polished. feature tabs following the same notification socket emmited data to be updated.

This commit is contained in:
2026-07-18 16:57:13 +03:30
parent 9f6ec193d2
commit 9941dca849
23 changed files with 506 additions and 63 deletions

View File

@@ -37,3 +37,7 @@ CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `compo
## Lab case share link
QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts`. Skill: `.cursor/skills/lab-case-share-link/SKILL.md`.
## Notifications (inbox + live tabs)
Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`.

View File

@@ -1,15 +1,16 @@
---
description: Lab tab badges — activity model, tab-counts API, read cursors
description: Sidebar tab badges (Cases/Tasks/Treatment) — activity model, tab-counts API, read cursors
globs: backend/src/modules/notifications/**,backend/src/common/lab-case-activity.ts,frontend/src/lib/hooks/useTabBadgeCounts.ts,frontend/src/lib/tabBadgeUtils.ts,frontend/src/lib/api/notifications.ts,frontend/src/components/ui/shared/NavBadgePill.tsx,frontend/src/components/ui/shared/Sidebar.tsx
alwaysApply: false
---
# Lab tab badges
# Tab badges (Cases / Tasks / Treatment)
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = completions + lab comments; Clinic Treatment = visible lab comments + completions.
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
- **Live:** inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` (and org pending event when relevant). **Mounted** Cases/Tasks/Treatment/Orgs pages soft-refetch lists; unmounted tabs do not. Sidebar badge counts always refetch (hook is always mounted).
- **Orgs connections badge** stays on separate `pending-count` endpoint.
- **Header inbox** (bell) is separate — `.cursor/skills/notifications-inbox/SKILL.md`.
Full map: `.cursor/skills/lab-notifications/SKILL.md`
Full map: `.cursor/skills/tab-badges/SKILL.md`

View File

@@ -40,6 +40,10 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`.
## Live soft refresh
Inbox Socket.IO `notification.created``notifyTabBadgesChanged()` → silent `loadCases` + selected `loadDetail` (keeps filters/selection; no full remount). Same event refreshes sidebar Cases badge via `useTabBadgeCounts`. See `.cursor/skills/notifications-inbox/SKILL.md`.
## Permissions
`TAB_CASES_READ` / `TAB_CASES_EDIT`; owner always has Cases access. `LabOrgGuard` on routes.

View File

@@ -91,9 +91,11 @@ Keep changes minimal — match existing `sm:` breakpoint patterns elsewhere in t
- **Tasks filters:** filter `<select>`s use the same 44px mobile height as other form controls.
- **Treatment lab dispatch:** shipment card `p-3 sm:p-4`; **Send to lab** is `w-full sm:w-auto`.
## Tab badges
## Tab badges + live soft refresh
See `.cursor/skills/lab-notifications/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`.
See `.cursor/skills/tab-badges/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`.
Inbox Socket.IO `notification.created``notifyTabBadgesChanged()` → silent `loadTasks({ silent: true })` on an open Tasks page (filters preserved; no remount). Details: `.cursor/skills/notifications-inbox/SKILL.md`.
## Permissions

View File

@@ -12,11 +12,28 @@ Permission-free **feature** (every dashboard user sees the bell). **Cards** are
| | Inbox (`UserNotification`) | Sidebar badges (`LabCaseActivity`) |
|--|--|--|
| Entry | Header bell → dropdown + `/notifications` | Sidebar Cases/Tasks/Treatment/Orgs |
| Live | Socket.IO (`/realtime`) | REST + `tab-badges-changed` |
| Live | Socket.IO (`/realtime`) | Same socket → `notifyTabBadgesChanged()` → REST tab-counts |
| Read | Per-card `readAt` only | Per-case / tab cursors |
Do **not** clear tab badges when marking an inbox card read.
## Live cascade (one socket)
On `notification.created`, [`RealtimeProvider`](frontend/src/lib/realtime/RealtimeProvider.tsx):
1. Updates inbox state (bell list + unread).
2. Calls `notifyTabBadgesChanged()` → Sidebar refetches `GET /notifications/tab-counts`.
3. For connection-request types, also `notifyPendingConnectionsChanged()`.
4. **Currently mounted** feature pages soft-refresh (no remount, no form wipe). Unmounted tabs do **not** fetch list data until opened:
- **Cases** — silent list + selected detail reload
- **Tasks** — silent task list reload
- **Treatment** (`TreatmentWorkspace`, not thin `page.tsx`) — silent patient lab-cases + unread rail
- **Organizations** — silent connections list on `pending-connections-changed` only
Sidebar **badge counts** always refetch (hook lives in the always-mounted Sidebar). Treatment **draft/form state** is not cleared by this cascade.
Full tab-badge map: `.cursor/skills/tab-badges/SKILL.md`.
## Backend
- Model: `UserNotification` + `UserNotificationType` in Prisma
@@ -26,10 +43,13 @@ Do **not** clear tab badges when marking an inbox card read.
## Emit sites (parallel to LabCaseActivity)
CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, TASK_COMPLETED, TASK_ASSIGNED, CONNECTION_REQUEST, STAFF_INVITE — see plan / service call sites.
CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, TASK_COMPLETED, TASK_ASSIGNED, CONNECTION_REQUEST, STAFF_INVITE — see service call sites.
## Frontend
**Inbox card context** is denormalized inside `UserNotificationService.notify()` (`enrichInboxPayload`) from ids already on the payload (`labCaseId`, `taskId`, `fromOrganizationId`). Emit sites stay thin (`{ labCaseId }`, etc.). Inbox list/read does **not** join related tables. Older rows may lack these fields until new events are emitted.
- `RealtimeProvider` in dashboard layout
- `NotificationBell` + `NotificationsPage` + `NotificationCard`
**Realtime auth:** after access-token refresh (proactive, axios 401 retry, or `checkAuth`), frontend dispatches `dyolink:access-token-refreshed` so `RealtimeProvider` reconnects with the new cookie.
## Frontend UI
- `NotificationBell` + `NotificationsPage` + `NotificationCard` (full-width page list; same card height; extra context truncated on one line)
- Deep links: `/cases?caseId=`, `/tasks?taskId=`, `/treatment?labCaseId=`, `/organizations`, `/staff`

View File

@@ -1,9 +1,11 @@
---
name: dyolink-lab-notifications
description: Lab case activity feed + sidebar tab badge counts. Use when changing notifications API, LabCaseActivity, read state, or Sidebar badges for Cases/Tasks/Treatment.
name: dyolink-tab-badges
description: Sidebar tab badge counts (Cases/Tasks/Treatment) from LabCaseActivity + read cursors. Use when changing tab-counts API, activity emit, read state, or Sidebar badges — distinct from the header inbox bell.
---
# Lab notifications (tab badges)
# Tab badges (Cases / Tasks / Treatment)
Sidebar unread pills for **lab Cases**, **lab Tasks**, and **clinic Treatment**. Activity is stored as `LabCaseActivity` (shared clinic↔lab case events); badges are org-type-specific.
Backend: [`backend/src/modules/notifications/`](backend/src/modules/notifications/)
Activity types: [`backend/src/common/lab-case-activity.ts`](backend/src/common/lab-case-activity.ts)
@@ -15,7 +17,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
- **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS`) for sidebar badge clearing on tab visit.
- **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards
## Tab badge buckets (Option B — split lab counts)
## Tab badge buckets (Option B)
| Org | Tab | Activity types |
|-----|-----|----------------|
@@ -45,17 +47,25 @@ Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT`
After mutations, frontend calls `notifyTabBadgesChanged()` (window event).
**Live path:** inbox Socket.IO `notification.created` also dispatches that event so sidebar counts refresh without navigation. **Only currently mounted** feature pages soft-reload list data on the same event (silent; Treatment form/draft untouched). Unmounted tabs do not fetch list data until the user opens them. See `.cursor/skills/notifications-inbox/SKILL.md`.
## Frontend pattern (same as org connections)
- `useTabBadgeCounts()` — fetch on pathname change + `tab-badges-changed` event
- `useTabBadgeCounts()` always mounted in dashboard Sidebar; fetch on pathname change + `tab-badges-changed`
- `useMarkTabReadOnVisit()` — Tasks page only (Cases/Treatment badges clear when opening unread cases)
- `NavBadgePill` in [`Sidebar.tsx`](frontend/src/components/ui/shared/Sidebar.tsx)
- **Organizations** pending connections still use `usePendingConnectionsCount` (separate pending-state API)
- **Live soft refresh (mounted page only):**
- Cases → silent list + selected detail
- Tasks → silent task list
- Treatment (`TreatmentWorkspace`) → silent patient lab cases + unread rail
- Orgs → silent list on `pending-connections-changed` only
## Out of scope (later steps)
- Push / email
- Making Cases/Tasks/Treatment lists live via websockets
- Prefetching unmounted tab list data on every socket event
- Pushing full page remounts / wiping Treatment draft state on live events
- `CASE_AMENDED` emit (Step 7)
## Related: header inbox

View File

@@ -131,7 +131,9 @@ Comments for a shipment live in the Lab dispatch panel (and Cases/Tasks/share),
- UI: `DetailLabCaseCommentsSection``LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`).
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
## Live lab rail refresh
Inbox Socket.IO `notification.created``notifyTabBadgesChanged()` → silent refresh of patient lab cases + unread rail. Does **not** remount the workspace or clear draft/form state. See `.cursor/skills/notifications-inbox/SKILL.md`.
## Appointments default selection

View File

@@ -55,13 +55,14 @@ frontend/src/
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the entry wizard **Lab** step.
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
- **Live lab rail**: `notification.created``notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. See `.cursor/rules/appointments.mdc`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
**Lab case share link (quick ref):**
- Token on first ship → `/{locale}/lab-case/{token}` after login.
@@ -96,7 +97,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
| `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors |
| `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment |
| `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules |
| `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors |
| `.cursor/skills/tab-badges/` | Sidebar tab badges: Cases/Tasks/Treatment, LabCaseActivity, tab-counts API, read cursors |
| `.cursor/skills/notifications-inbox/` | Header bell inbox: UserNotification fan-out, Socket.IO realtime |
| `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry |
| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout |

View File

@@ -41,6 +41,146 @@ export class UserNotificationService {
private readonly realtime: RealtimeEmitter,
) {}
/**
* Lean lab-case snapshot for inbox cards. Used only inside `notify()` so
* inbox list/read never joins patients/orgs/tasks.
*/
private async labCaseInboxPayload(
labCaseId: string,
extra?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const labCase = await this.prisma.labCase.findUnique({
where: { id: labCaseId },
select: {
id: true,
destinationOrganizationId: true,
treatment: {
select: {
organization: { select: { name: true } },
patient: { select: { firstName: true, lastName: true } },
},
},
toothProsthesis: { select: { prosthesisTypeCode: true } },
tasks: { select: { prosthesisTypeCode: true }, take: 40 },
sends: {
orderBy: { sentAt: 'asc' },
take: 1,
select: { organization: { select: { name: true } } },
},
},
});
if (!labCase?.treatment) {
return { labCaseId, ...(extra ?? {}) };
}
let labName: string | null = labCase.sends[0]?.organization.name ?? null;
if (!labName && labCase.destinationOrganizationId) {
const dest = await this.prisma.organization.findUnique({
where: { id: labCase.destinationOrganizationId },
select: { name: true },
});
labName = dest?.name ?? null;
}
const prosthesisTypeCodes = [
...new Set(
[
...labCase.toothProsthesis.map((row) => row.prosthesisTypeCode),
...labCase.tasks.map((row) => row.prosthesisTypeCode),
].filter(Boolean),
),
];
const patient = labCase.treatment.patient;
const patientName = `${patient.firstName} ${patient.lastName}`.trim();
// Caller ids (taskId, commentId, …) come from `extra`; denormalized display
// fields must win so they are never overwritten by a thin emit payload.
return {
...(extra ?? {}),
labCaseId,
patientName,
clinicName: labCase.treatment.organization.name,
labName,
prosthesisTypeCodes,
};
}
/** Denormalize display fields into payload once at write time. */
private async enrichInboxPayload(
input: FanoutInput,
): Promise<Record<string, unknown> | null> {
const base =
input.payload && typeof input.payload === 'object' && !Array.isArray(input.payload)
? { ...(input.payload as Record<string, unknown>) }
: {};
try {
const labCaseId =
(typeof base.labCaseId === 'string' && base.labCaseId) ||
input.labCaseIdForProviderScope ||
null;
let enriched: Record<string, unknown> = { ...base };
if (labCaseId) {
enriched = await this.labCaseInboxPayload(labCaseId, enriched);
}
const taskId = typeof enriched.taskId === 'string' ? enriched.taskId : null;
if (taskId && typeof enriched.taskName !== 'string') {
const task = await this.prisma.labCaseTask.findUnique({
where: { id: taskId },
select: { stepLabel: true, prosthesisTypeCode: true },
});
if (task) {
enriched = {
...enriched,
taskName: task.stepLabel,
prosthesisTypeCode: task.prosthesisTypeCode,
};
}
}
const fromOrganizationId =
typeof enriched.fromOrganizationId === 'string' ? enriched.fromOrganizationId : null;
if (fromOrganizationId && typeof enriched.fromOrganizationName !== 'string') {
const org = await this.prisma.organization.findUnique({
where: { id: fromOrganizationId },
select: { name: true },
});
if (org) {
enriched = { ...enriched, fromOrganizationName: org.name };
}
}
const membershipId =
typeof enriched.membershipId === 'string' ? enriched.membershipId : null;
if (membershipId && typeof enriched.inviteeName !== 'string') {
const membership = await this.prisma.membership.findUnique({
where: { id: membershipId },
select: { user: { select: { name: true, email: true } } },
});
if (membership?.user) {
enriched = {
...enriched,
inviteeName: membership.user.name,
email:
typeof enriched.email === 'string' && enriched.email
? enriched.email
: membership.user.email,
};
}
}
return Object.keys(enriched).length > 0 ? enriched : null;
} catch {
// Never block inbox write if enrichment fails — store the thin payload.
return Object.keys(base).length > 0 ? base : null;
}
}
async notify(input: FanoutInput): Promise<void> {
const recipientIds = input.recipientUserIds?.length
? [...new Set(input.recipientUserIds.filter((id) => id && id !== input.actorUserId))]
@@ -48,13 +188,15 @@ export class UserNotificationService {
if (recipientIds.length === 0) return;
const payload = await this.enrichInboxPayload(input);
const rows = await this.prisma.userNotification.createManyAndReturn({
data: recipientIds.map((userId) => ({
userId,
organizationId: input.organizationId,
type: input.type,
actorUserId: input.actorUserId ?? null,
payload: (input.payload ?? Prisma.JsonNull) as Prisma.InputJsonValue,
payload: (payload ?? Prisma.JsonNull) as Prisma.InputJsonValue,
href: input.href,
})),
});

View File

@@ -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.",

View File

@@ -991,7 +991,13 @@
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
"typeUnknown": "اعلان"
"typeUnknown": "اعلان",
"destCases": "باز کردن پرونده‌ها",
"destTasks": "باز کردن وظایف",
"destTreatment": "باز کردن درمان",
"destOrganizations": "باز کردن سازمان‌ها",
"destStaff": "باز کردن کارکنان",
"destOpen": "باز کردن"
},
"errors": {
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",

View File

@@ -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.",

View File

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

View File

@@ -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 () => {

View File

@@ -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>

View File

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

View File

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

View File

@@ -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(() => {

View File

@@ -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') {

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

View File

@@ -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;

View File

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

View File

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