improvement: notification feature polished. feature tabs following the same notification socket emmited data to be updated.
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -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