diff --git a/.cursor/rules/tab-badges.mdc b/.cursor/rules/tab-badges.mdc index b9520fd..a7c77e7 100644 --- a/.cursor/rules/tab-badges.mdc +++ b/.cursor/rules/tab-badges.mdc @@ -6,7 +6,7 @@ alwaysApply: false # 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. +- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = completions + lab comments + assignments (assignee-only); 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. - **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). diff --git a/.cursor/skills/notifications-inbox/SKILL.md b/.cursor/skills/notifications-inbox/SKILL.md index c51ec99..8330f6c 100644 --- a/.cursor/skills/notifications-inbox/SKILL.md +++ b/.cursor/skills/notifications-inbox/SKILL.md @@ -43,7 +43,7 @@ Full tab-badge map: `.cursor/skills/tab-badges/SKILL.md`. ## 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 service call sites. +CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, TASK_COMPLETED, TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites. **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. diff --git a/.cursor/skills/tab-badges/SKILL.md b/.cursor/skills/tab-badges/SKILL.md index 6da2746..5ddf8f0 100644 --- a/.cursor/skills/tab-badges/SKILL.md +++ b/.cursor/skills/tab-badges/SKILL.md @@ -13,7 +13,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/ ## Models -- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED` +- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED`, `TASK_ASSIGNED` - **`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 @@ -22,7 +22,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/ | Org | Tab | Activity types | |-----|-----|----------------| | LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` | -| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT` | +| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) | | CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_COMPLETED` — **only lab cases for treatments the user provided** | Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`. @@ -44,6 +44,7 @@ Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` | Comment | `lab-case-comments.service` → `CLINIC_COMMENT` / `LAB_COMMENT` | | Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` | | Task completed | `tasks.service` `updateStatus` → `TASK_COMPLETED` | +| Task assigned | `cases.service` `assignTask` → `TASK_ASSIGNED` (inbox + Tasks badge for **assignee**, including self-assign) | After mutations, frontend calls `notifyTabBadgesChanged()` (window event). @@ -60,7 +61,7 @@ After mutations, frontend calls `notifyTabBadgesChanged()` (window event). - Tasks → silent task list - Treatment (`TreatmentWorkspace`) → silent patient lab cases + unread rail - Orgs → silent list on `pending-connections-changed` only - +- **TASK_ASSIGNED** Tasks badge is assignee-scoped (`payload.assigneeUserId`); other Tasks-bucket events stay org-wide for users with tab access. ## Out of scope (later steps) - Push / email diff --git a/backend/prisma/migrations/20260718180000_lab_case_activity_task_assigned/migration.sql b/backend/prisma/migrations/20260718180000_lab_case_activity_task_assigned/migration.sql new file mode 100644 index 0000000..61ddf50 --- /dev/null +++ b/backend/prisma/migrations/20260718180000_lab_case_activity_task_assigned/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "LabCaseActivityType" ADD VALUE 'TASK_ASSIGNED'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 847efd6..bad97b7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -429,6 +429,7 @@ enum LabCaseActivityType { CASE_IMPORTANT CASE_AMENDED TASK_COMPLETED + TASK_ASSIGNED } enum LabCaseTabReadTarget { diff --git a/backend/src/common/lab-case-activity.ts b/backend/src/common/lab-case-activity.ts index c20723c..9b11fd0 100644 --- a/backend/src/common/lab-case-activity.ts +++ b/backend/src/common/lab-case-activity.ts @@ -7,10 +7,11 @@ export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [ LabCaseActivityType.CASE_IMPORTANT, ]; -/** Lab Tasks tab — task completions and lab-side comments. */ +/** Lab Tasks tab — task completions, lab-side comments, and assignments (assignee-scoped in counts). */ export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [ LabCaseActivityType.TASK_COMPLETED, LabCaseActivityType.LAB_COMMENT, + LabCaseActivityType.TASK_ASSIGNED, ]; /** Clinic Treatment tab — visible lab comments and task progress. */ diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index aa11e82..fafaf31 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -486,13 +486,20 @@ export class CasesService { }, }); - if (assigneeUserId && assigneeUserId !== actorUserId) { + if (assigneeUserId) { + await this.labCaseActivity.record({ + labCaseId, + type: LabCaseActivityType.TASK_ASSIGNED, + actorUserId, + payload: { taskId, assigneeUserId }, + }); + void this.userNotifications.notify({ organizationId: labOrganizationId, type: UserNotificationType.TASK_ASSIGNED, href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(labCaseId)}`, actorUserId, - payload: { labCaseId, taskId }, + payload: { labCaseId, taskId, assigneeUserId }, recipientUserIds: [assigneeUserId], }); } diff --git a/backend/src/modules/notifications/lab-case-activity.service.ts b/backend/src/modules/notifications/lab-case-activity.service.ts index bcf3e06..d19bd94 100644 --- a/backend/src/modules/notifications/lab-case-activity.service.ts +++ b/backend/src/modules/notifications/lab-case-activity.service.ts @@ -318,7 +318,10 @@ export class LabCaseActivityService { const commentId = payload?.commentId; if (typeof commentId === 'string') commentIds.push(commentId); } - if (activity.type === LabCaseActivityType.TASK_COMPLETED) { + if ( + activity.type === LabCaseActivityType.TASK_COMPLETED || + activity.type === LabCaseActivityType.TASK_ASSIGNED + ) { const taskId = payload?.taskId; if (typeof taskId === 'string') taskIds.push(taskId); } @@ -387,18 +390,44 @@ export class LabCaseActivityService { }; const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType); + const includesTaskAssigned = types.includes(LabCaseActivityType.TASK_ASSIGNED); + const otherTypes = types.filter((type) => type !== LabCaseActivityType.TASK_ASSIGNED); + + // TASK_ASSIGNED: count for the assignee only (including self-assign). + // Other task-tab types: org-wide, excluding events the current user authored. + const typeFilter: Prisma.LabCaseActivityWhereInput = includesTaskAssigned + ? { + OR: [ + ...(otherTypes.length > 0 + ? [ + { + AND: [ + { type: { in: otherTypes } }, + { + OR: [{ actorUserId: null }, { actorUserId: { not: userId } }], + }, + ], + }, + ] + : []), + { + type: LabCaseActivityType.TASK_ASSIGNED, + payload: { path: ['assigneeUserId'], equals: userId }, + }, + ], + } + : { + AND: [ + { type: { in: types } }, + { OR: [{ actorUserId: null }, { actorUserId: { not: userId } }] }, + ], + }; return this.prisma.labCaseActivity.count({ where: { - type: { in: types }, createdAt: { gt: since }, labCase: labCaseScope, - AND: [ - { - OR: [{ actorUserId: null }, { actorUserId: { not: userId } }], - }, - clinicLabCommentFilter, - ], + AND: [clinicLabCommentFilter, typeFilter], }, }); } diff --git a/backend/src/modules/notifications/user-notification.service.ts b/backend/src/modules/notifications/user-notification.service.ts index 2c32700..a816325 100644 --- a/backend/src/modules/notifications/user-notification.service.ts +++ b/backend/src/modules/notifications/user-notification.service.ts @@ -182,8 +182,9 @@ export class UserNotificationService { } async notify(input: FanoutInput): Promise { + // Explicit recipients keep the actor (e.g. self-assign). Fan-out still skips the actor. const recipientIds = input.recipientUserIds?.length - ? [...new Set(input.recipientUserIds.filter((id) => id && id !== input.actorUserId))] + ? [...new Set(input.recipientUserIds.filter((id): id is string => Boolean(id)))] : await this.resolveRecipients(input); if (recipientIds.length === 0) return; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3a8ce24..ee0bb53 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -761,6 +761,7 @@ "activityClinicComment": "{actor}: “{preview}” · {date}", "activityLabComment": "{actor}: “{preview}” · {date}", "activityTaskCompleted": "{step} completed by {actor} · {date}", + "activityTaskAssigned": "{step} assigned by {actor} · {date}", "activityCaseImportant": "Marked important by {actor} · {date}", "activityCaseAmended": "Case updated by {actor} · {date}", "activityGeneric": "Update · {date}", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index e31bd60..3dd5791 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -762,6 +762,7 @@ "activityClinicComment": "{actor}: «{preview}» · {date}", "activityLabComment": "{actor}: «{preview}» · {date}", "activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}", + "activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}", "activityCaseImportant": "مهم علامت‌گذاری شد توسط {actor} · {date}", "activityCaseAmended": "پرونده به‌روزرسانی شد توسط {actor} · {date}", "activityGeneric": "به‌روزرسانی · {date}", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 75b5fae..7573cdb 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -761,6 +761,7 @@ "activityClinicComment": "{actor}: “{preview}” · {date}", "activityLabComment": "{actor}: “{preview}” · {date}", "activityTaskCompleted": "{step} voltooid door {actor} · {date}", + "activityTaskAssigned": "{step} toegewezen door {actor} · {date}", "activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}", "activityCaseAmended": "Case bijgewerkt door {actor} · {date}", "activityGeneric": "Update · {date}", diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index b9f6e06..cdfd8d5 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -297,6 +297,7 @@ export function CasesPage() { try { const response = await casesApi.assignTask(selectedCaseId, taskId, assigneeUserId); setSelectedCase(response.data); + notifyTabBadgesChanged(); } catch (error: unknown) { toast.showError(getUserFacingError(error, tErrors, t('errorAssignTask'))); } finally { diff --git a/frontend/src/lib/labCaseActivityLabels.ts b/frontend/src/lib/labCaseActivityLabels.ts index 4dd40c3..117f2de 100644 --- a/frontend/src/lib/labCaseActivityLabels.ts +++ b/frontend/src/lib/labCaseActivityLabels.ts @@ -35,6 +35,12 @@ export function formatLabCaseActivityLine( actor, date, }); + case 'TASK_ASSIGNED': + return t('activityTaskAssigned', { + step: activity.stepLabel ?? t('activityUnknownStep'), + actor, + date, + }); case 'CASE_IMPORTANT': return t('activityCaseImportant', { actor, date }); case 'CASE_AMENDED': diff --git a/frontend/src/lib/realtime/RealtimeProvider.tsx b/frontend/src/lib/realtime/RealtimeProvider.tsx index 07a11b7..f5035b5 100644 --- a/frontend/src/lib/realtime/RealtimeProvider.tsx +++ b/frontend/src/lib/realtime/RealtimeProvider.tsx @@ -78,11 +78,16 @@ export function RealtimeProvider({ children }: { children: ReactNode }) { const socket = io(`${apiOrigin()}/realtime`, { withCredentials: true, transports: ['websocket', 'polling'], + // Avoid hammering the gateway with an expired cookie; we reconnect after refresh. + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 2000, }); socketRef.current = socket; socket.on('connect', () => setConnected(true)); socket.on('disconnect', () => setConnected(false)); + socket.on('connect_error', () => setConnected(false)); socket.on('notification.created', (payload: { notification?: UserNotificationItem }) => { if (payload?.notification) { setLastNotification(payload.notification); diff --git a/frontend/src/types/lab-case-activity.ts b/frontend/src/types/lab-case-activity.ts index 07e7532..fed69c9 100644 --- a/frontend/src/types/lab-case-activity.ts +++ b/frontend/src/types/lab-case-activity.ts @@ -4,7 +4,8 @@ export type LabCaseActivityType = | 'LAB_COMMENT' | 'CASE_IMPORTANT' | 'CASE_AMENDED' - | 'TASK_COMPLETED'; + | 'TASK_COMPLETED' + | 'TASK_ASSIGNED'; export interface LabCaseActivityItem { id: string;